Merge branch 'main' into hailey/onboarding-profile
# Conflicts: # package.json # src/screens/Onboarding/StepTopicalFeeds.tsx # yarn.lock
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: Build and Submit Android
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- 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 eas build -p android --profile production --local --output build.aab --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p android --non-interactive --path build.aab
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: Build and Submit iOS
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
type: choice
|
||||
description: Build profile to use
|
||||
options:
|
||||
- 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 eas build -p ios --profile production --local --output build.ipa --non-interactive
|
||||
|
||||
- name: 🚀 Deploy
|
||||
run: eas submit -p ios --non-interactive --path build.ipa
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Deploy Nightly Testflight Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy Nightly Testflight Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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@v3
|
||||
with:
|
||||
node-version: 18.x
|
||||
cache: yarn
|
||||
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Bump build number
|
||||
run: yarn bump:ios
|
||||
|
||||
- name: EAS build and submit
|
||||
run: eas build -p ios --profile production --auto-submit --non-interactive
|
||||
|
||||
- name: Commit
|
||||
uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: Nightly iOS Build Bump
|
||||
branch: main
|
||||
commit_user_name: github-actions[bot]
|
||||
commit_user_email: github-actions[bot]@users.noreply.github.com
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install node 18
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Yarn install
|
||||
uses: Wandalen/wretry.action@master
|
||||
with:
|
||||
|
||||
+2
-2
@@ -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.3/install.sh && \
|
||||
https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/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:compile && \
|
||||
yarn intl:build && \
|
||||
yarn build-web
|
||||
|
||||
# DEBUG
|
||||
|
||||
@@ -65,8 +65,6 @@ 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.
|
||||
|
||||
+49
-24
@@ -11,24 +11,23 @@ const DARK_SPLASH_CONFIG = {
|
||||
resizeMode: 'cover',
|
||||
}
|
||||
|
||||
module.exports = function () {
|
||||
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) {
|
||||
/**
|
||||
* 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.
|
||||
* WARNING: Always leave this variable on line 24! If it is moved, you need to update ./scripts/bumpIosBuildNumber.sh
|
||||
*/
|
||||
const IOS_BUILD_NUMBER = '7'
|
||||
|
||||
/**
|
||||
* Android build number. Must be incremented for each release.
|
||||
* WARNING: Always leave this variable on line 30! If it is moved, you need to update ./scripts/bumpAndroidBuildNumber.sh
|
||||
*/
|
||||
const ANDROID_VERSION_CODE = 62
|
||||
|
||||
/**
|
||||
* Uses built-in Expo env vars
|
||||
*
|
||||
@@ -36,11 +35,10 @@ module.exports = function () {
|
||||
*/
|
||||
const PLATFORM = process.env.EAS_BUILD_PLATFORM
|
||||
|
||||
/**
|
||||
* Additional granularity for the `dist` field
|
||||
*/
|
||||
const DIST_BUILD_NUMBER =
|
||||
PLATFORM === 'android' ? ANDROID_VERSION_CODE : IOS_BUILD_NUMBER
|
||||
PLATFORM === 'android'
|
||||
? process.env.BSKY_ANDROID_VERSION_CODE
|
||||
: process.env.BSKY_IOS_BUILD_NUMBER
|
||||
|
||||
return {
|
||||
expo: {
|
||||
@@ -57,7 +55,6 @@ module.exports = function () {
|
||||
userInterfaceStyle: 'automatic',
|
||||
splash: SPLASH_CONFIG,
|
||||
ios: {
|
||||
buildNumber: IOS_BUILD_NUMBER,
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
config: {
|
||||
@@ -79,13 +76,15 @@ module.exports = function () {
|
||||
...SPLASH_CONFIG,
|
||||
dark: DARK_SPLASH_CONFIG,
|
||||
},
|
||||
entitlements: {
|
||||
'com.apple.security.application-groups': 'group.app.bsky',
|
||||
},
|
||||
},
|
||||
androidStatusBar: {
|
||||
barStyle: 'dark-content',
|
||||
backgroundColor: '#ffffff',
|
||||
barStyle: 'light-content',
|
||||
backgroundColor: '#00000000',
|
||||
},
|
||||
android: {
|
||||
versionCode: ANDROID_VERSION_CODE,
|
||||
icon: './assets/icon.png',
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/icon-android-foreground.png',
|
||||
@@ -104,13 +103,17 @@ module.exports = function () {
|
||||
scheme: 'https',
|
||||
host: 'bsky.app',
|
||||
},
|
||||
{
|
||||
scheme: 'http',
|
||||
host: 'localhost:19006',
|
||||
},
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT'],
|
||||
},
|
||||
],
|
||||
splash: {
|
||||
...SPLASH_CONFIG,
|
||||
dark: DARK_SPLASH_CONFIG,
|
||||
...SPLASH_CONFIG_ANDROID,
|
||||
dark: DARK_SPLASH_CONFIG_ANDROID,
|
||||
},
|
||||
},
|
||||
web: {
|
||||
@@ -129,12 +132,14 @@ module.exports = function () {
|
||||
{
|
||||
ios: {
|
||||
deploymentTarget: '13.4',
|
||||
newArchEnabled: false,
|
||||
},
|
||||
android: {
|
||||
compileSdkVersion: 34,
|
||||
targetSdkVersion: 34,
|
||||
buildToolsVersion: '34.0.0',
|
||||
kotlinVersion: '1.8.0',
|
||||
newArchEnabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -148,13 +153,33 @@ module.exports = function () {
|
||||
'expo-notifications',
|
||||
{
|
||||
icon: './assets/icon-android-notification.png',
|
||||
color: '#ffffff',
|
||||
color: '#1185fe',
|
||||
},
|
||||
],
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
'./plugins/withAndroidManifestFCMIconPlugin.js',
|
||||
'./plugins/withAndroidStylesWindowBackgroundPlugin.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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 300 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 269 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 465 B |
@@ -0,0 +1 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 356 B |
+3
-3
@@ -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 18 (specifically)
|
||||
nvm install 18
|
||||
nvm use 18
|
||||
# install nodejs
|
||||
nvm install
|
||||
nvm use
|
||||
npm install --global yarn
|
||||
|
||||
# setup tools and deps (in top level of this repo)
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -40,18 +40,6 @@ 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,11 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -29,25 +27,19 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
mailmodo *Mailmodo
|
||||
xrpcc *xrpc.Client
|
||||
echo *echo.Echo
|
||||
httpd *http.Server
|
||||
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(),
|
||||
@@ -77,9 +69,8 @@ func serve(cctx *cli.Context) error {
|
||||
// server
|
||||
//
|
||||
server := &Server{
|
||||
echo: e,
|
||||
mailmodo: mailmodo,
|
||||
xrpcc: xrpcc,
|
||||
echo: e,
|
||||
xrpcc: xrpcc,
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
@@ -180,6 +171,7 @@ 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)
|
||||
@@ -191,7 +183,7 @@ 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/home-feed", server.WebGeneric)
|
||||
e.GET("/settings/following-feed", server.WebGeneric)
|
||||
e.GET("/settings/saved-feeds", server.WebGeneric)
|
||||
e.GET("/settings/threads", server.WebGeneric)
|
||||
e.GET("/settings/external-embeds", server.WebGeneric)
|
||||
@@ -202,6 +194,7 @@ func serve(cctx *cli.Context) error {
|
||||
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)
|
||||
@@ -219,9 +212,6 @@ func serve(cctx *cli.Context) error {
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric)
|
||||
e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric)
|
||||
|
||||
// Mailmodo
|
||||
e.POST("/api/waitlist", server.apiWaitlist)
|
||||
|
||||
// Start the server.
|
||||
log.Infof("starting server address=%s", httpAddress)
|
||||
go func() {
|
||||
@@ -396,36 +386,3 @@ 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})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover">
|
||||
<meta name="referrer" content="origin-when-cross-origin">
|
||||
<!--
|
||||
Preconnect to essential domains
|
||||
-->
|
||||
<link rel="preconnect" href="https://bsky.social">
|
||||
<link rel="preconnect" href="https://bsky.network">
|
||||
<title>{%- block head_title -%}Bluesky{%- endblock -%}</title>
|
||||
|
||||
<!-- Hello Humans! API docs at https://atproto.com -->
|
||||
@@ -39,6 +44,12 @@
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
|
||||
button, input, textarea {
|
||||
font: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Color theming */
|
||||
/* Default will always be white */
|
||||
:root {
|
||||
@@ -200,16 +211,23 @@
|
||||
[data-tooltip]:hover::before {
|
||||
display:block;
|
||||
}
|
||||
|
||||
/* NativeDropdown component */
|
||||
.radix-dropdown-item:focus,
|
||||
.nativeDropdown-item:focus {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/favicon-16x16.png">
|
||||
<link rel="mask-icon" href="/static/safari-pinned-tab.svg" color="#1185fe">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="theme-color">
|
||||
<meta name="application-name" content="Bluesky">
|
||||
<meta name="generator" content="bskyweb">
|
||||
<meta property="og:site_name" content="Bluesky Social" />
|
||||
<link type="application/activity+json" href="" />
|
||||
|
||||
{% block html_head_extra -%}{%- endblock %}
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 3.8.1",
|
||||
"promptToConfigurePushNotifications": false
|
||||
"promptToConfigurePushNotifications": false,
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"base": {
|
||||
@@ -28,7 +29,21 @@
|
||||
"production": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"resourceClass": "large"
|
||||
"resourceClass": "large",
|
||||
"autoIncrement": true
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
},
|
||||
"github": {
|
||||
"extends": "base",
|
||||
"ios": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"android": {
|
||||
"autoIncrement": true
|
||||
},
|
||||
"channel": "production"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import '#/platform/markBundleStartTime'
|
||||
|
||||
import '#/platform/polyfills'
|
||||
import {registerRootComponent} from 'expo'
|
||||
import {doPolyfill} from '#/lib/api/api-polyfill'
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsText</key>
|
||||
<true/>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||
<integer>10</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
</dict>
|
||||
<key>MainAppScheme</key>
|
||||
<string>bluesky</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Extension</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.app.bsky</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,153 @@
|
||||
import UIKit
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
// This allows other forks to use this extension while also changing their
|
||||
// scheme.
|
||||
let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
|
||||
|
||||
//
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = extensionItem.attachments,
|
||||
let firstAttachment = extensionItem.attachments?.first
|
||||
else {
|
||||
self.completeRequest()
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
if firstAttachment.hasItemConformingToTypeIdentifier("public.text") {
|
||||
await self.handleText(item: firstAttachment)
|
||||
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") {
|
||||
await self.handleUrl(item: firstAttachment)
|
||||
} else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
|
||||
await self.handleImages(items: attachments)
|
||||
} else {
|
||||
self.completeRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleText(item: NSItemProvider) async -> Void {
|
||||
do {
|
||||
if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
|
||||
if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
|
||||
{
|
||||
_ = self.openURL(url)
|
||||
}
|
||||
}
|
||||
self.completeRequest()
|
||||
} catch {
|
||||
self.completeRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleUrl(item: NSItemProvider) async -> Void {
|
||||
do {
|
||||
if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
|
||||
if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
|
||||
{
|
||||
_ = self.openURL(url)
|
||||
}
|
||||
}
|
||||
self.completeRequest()
|
||||
} catch {
|
||||
self.completeRequest()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImages(items: [NSItemProvider]) async -> Void {
|
||||
let firstFourItems: [NSItemProvider]
|
||||
if items.count < 4 {
|
||||
firstFourItems = items
|
||||
} else {
|
||||
firstFourItems = Array(items[0...3])
|
||||
}
|
||||
|
||||
var valid = true
|
||||
var imageUris = ""
|
||||
|
||||
for (index, item) in firstFourItems.enumerated() {
|
||||
var imageUriInfo: String? = nil
|
||||
|
||||
do {
|
||||
if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
|
||||
// We need to duplicate this image, since we don't have access to the outgoing temp directory
|
||||
// We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files
|
||||
let data = try Data(contentsOf: dataUri)
|
||||
let image = UIImage(data: data)
|
||||
imageUriInfo = self.saveImageWithInfo(image)
|
||||
} else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage {
|
||||
imageUriInfo = self.saveImageWithInfo(image)
|
||||
}
|
||||
} catch {
|
||||
valid = false
|
||||
}
|
||||
|
||||
if let imageUriInfo = imageUriInfo {
|
||||
imageUris.append(imageUriInfo)
|
||||
if index < items.count - 1 {
|
||||
imageUris.append(",")
|
||||
}
|
||||
} else {
|
||||
valid = false
|
||||
}
|
||||
}
|
||||
|
||||
if valid,
|
||||
let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
|
||||
let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
|
||||
{
|
||||
_ = self.openURL(url)
|
||||
}
|
||||
|
||||
self.completeRequest()
|
||||
}
|
||||
|
||||
private func saveImageWithInfo(_ image: UIImage?) -> String? {
|
||||
guard let image = image else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
// Saving this file to the bundle group's directory lets us access it from
|
||||
// inside of the app. Otherwise, we wouldn't have access even though the
|
||||
// extension does.
|
||||
if let dir = FileManager()
|
||||
.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.app.bsky")
|
||||
{
|
||||
let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
|
||||
|
||||
if let newUri = URL(string: filePath),
|
||||
let jpegData = image.jpegData(compressionQuality: 1)
|
||||
{
|
||||
try jpegData.write(to: newUri)
|
||||
return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func completeRequest() -> Void {
|
||||
self.extensionContext?.completeRequest(returningItems: nil)
|
||||
}
|
||||
|
||||
@objc func openURL(_ url: URL) -> Bool {
|
||||
var responder: UIResponder? = self
|
||||
while responder != nil {
|
||||
if let application = responder as? UIApplication {
|
||||
return application.perform(#selector(openURL(_:)), with: url) != nil
|
||||
}
|
||||
responder = responder?.next
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
# Expo Receive Android Intents
|
||||
|
||||
This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
|
||||
The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
|
||||
to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
|
||||
platforms.
|
||||
|
||||
No installation is required. Gradle will automatically add this module on build.
|
||||
@@ -0,0 +1,15 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
@@ -0,0 +1,92 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'maven-publish'
|
||||
|
||||
group = 'xyz.blueskyweb.app.exporeceiveandroidintents'
|
||||
version = '0.4.1'
|
||||
|
||||
buildscript {
|
||||
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
|
||||
if (expoModulesCorePlugin.exists()) {
|
||||
apply from: expoModulesCorePlugin
|
||||
applyKotlinExpoModulesCorePlugin()
|
||||
}
|
||||
|
||||
// Simple helper that allows the root project to override versions declared by this library.
|
||||
ext.safeExtGet = { prop, fallback ->
|
||||
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
||||
}
|
||||
|
||||
// Ensures backward compatibility
|
||||
ext.getKotlinVersion = {
|
||||
if (ext.has("kotlinVersion")) {
|
||||
ext.kotlinVersion()
|
||||
} else {
|
||||
ext.safeExtGet("kotlinVersion", "1.8.10")
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
publishing {
|
||||
publications {
|
||||
release(MavenPublication) {
|
||||
from components.release
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
url = mavenLocal().url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion safeExtGet("compileSdkVersion", 33)
|
||||
|
||||
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
|
||||
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.majorVersion
|
||||
}
|
||||
}
|
||||
|
||||
namespace "xyz.blueskyweb.app.exporeceiveandroidintents"
|
||||
defaultConfig {
|
||||
minSdkVersion safeExtGet("minSdkVersion", 21)
|
||||
targetSdkVersion safeExtGet("targetSdkVersion", 34)
|
||||
versionCode 1
|
||||
versionName "0.4.1"
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
publishing {
|
||||
singleVariant("release") {
|
||||
withSourcesJar()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':expo-modules-core')
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<manifest>
|
||||
</manifest>
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package xyz.blueskyweb.app.exporeceiveandroidintents
|
||||
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.core.net.toUri
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.net.URLEncoder
|
||||
|
||||
class ExpoReceiveAndroidIntentsModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("ExpoReceiveAndroidIntents")
|
||||
|
||||
OnNewIntent {
|
||||
handleIntent(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
if(appContext.currentActivity == null || intent == null) return
|
||||
|
||||
if (intent.action == Intent.ACTION_SEND) {
|
||||
if (intent.type == "text/plain") {
|
||||
handleTextIntent(intent)
|
||||
} else if (intent.type.toString().startsWith("image/")) {
|
||||
handleImageIntent(intent)
|
||||
}
|
||||
} else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
|
||||
if (intent.type.toString().startsWith("image/")) {
|
||||
handleImagesIntent(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTextIntent(intent: Intent) {
|
||||
intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
|
||||
val encoded = URLEncoder.encode(it, "UTF-8")
|
||||
"bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
|
||||
val newIntent = Intent(Intent.ACTION_VIEW, uri)
|
||||
appContext.currentActivity?.startActivity(newIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImageIntent(intent: Intent) {
|
||||
val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
if (uri == null) return
|
||||
|
||||
handleImageIntents(listOf(uri))
|
||||
}
|
||||
|
||||
private fun handleImagesIntent(intent: Intent) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let {
|
||||
handleImageIntents(it.filterIsInstance<Uri>().take(4))
|
||||
}
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
|
||||
handleImageIntents(it.filterIsInstance<Uri>().take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleImageIntents(uris: List<Uri>) {
|
||||
var allParams = ""
|
||||
|
||||
uris.forEachIndexed { index, uri ->
|
||||
val info = getImageInfo(uri)
|
||||
val params = buildUriData(info)
|
||||
allParams = "${allParams}${params}"
|
||||
|
||||
if (index < uris.count() - 1) {
|
||||
allParams = "${allParams},"
|
||||
}
|
||||
}
|
||||
|
||||
val encoded = URLEncoder.encode(allParams, "UTF-8")
|
||||
|
||||
"bluesky://intent/compose?imageUris=${encoded}".toUri().let {
|
||||
val newIntent = Intent(Intent.ACTION_VIEW, it)
|
||||
appContext.currentActivity?.startActivity(newIntent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getImageInfo(uri: Uri): Map<String, Any> {
|
||||
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
|
||||
// We have to save this so that we can access it later when uploading the image.
|
||||
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
|
||||
val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir)
|
||||
val out = FileOutputStream(file)
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
|
||||
out.flush()
|
||||
out.close()
|
||||
|
||||
return mapOf(
|
||||
"width" to bitmap.width,
|
||||
"height" to bitmap.height,
|
||||
"path" to file.path.toString()
|
||||
)
|
||||
}
|
||||
|
||||
// We will pas the width and height to the app here, since getting measurements
|
||||
// on the RN side is a bit more involved, and we already have them here anyway.
|
||||
private fun buildUriData(info: Map<String, Any>): String {
|
||||
val path = info.getValue("path")
|
||||
val width = info.getValue("width")
|
||||
val height = info.getValue("height")
|
||||
return "file://${path}|${width}|${height}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["android"],
|
||||
"android": {
|
||||
"modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
|
||||
}
|
||||
}
|
||||
@@ -108,14 +108,26 @@ class RNUITextView: UIView {
|
||||
fractionOfDistanceBetweenInsertionPoints: nil
|
||||
)
|
||||
|
||||
var lastUpperBound: String.Index? = nil
|
||||
for child in self.reactSubviews() {
|
||||
if let child = child as? RNUITextViewChild, let childText = child.text {
|
||||
let fullText = self.textView.attributedText.string
|
||||
let range = fullText.range(of: childText)
|
||||
|
||||
|
||||
// We want to skip over the children we have already checked, otherwise we could run into
|
||||
// collisions of similar strings (i.e. links that get shortened to the same hostname but
|
||||
// different paths)
|
||||
let range = fullText.range(of: childText, options: [], range: (lastUpperBound ?? String.Index(utf16Offset: 0, in: fullText) )..<fullText.endIndex)
|
||||
|
||||
if let lowerBound = range?.lowerBound, let upperBound = range?.upperBound {
|
||||
if charIndex >= lowerBound.utf16Offset(in: fullText) && charIndex <= upperBound.utf16Offset(in: fullText) {
|
||||
let lowerOffset = lowerBound.utf16Offset(in: fullText)
|
||||
let upperOffset = upperBound.utf16Offset(in: fullText)
|
||||
|
||||
if charIndex >= lowerOffset,
|
||||
charIndex <= upperOffset
|
||||
{
|
||||
return child
|
||||
} else {
|
||||
lastUpperBound = upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
RCT_REMAP_SHADOW_PROPERTY(numberOfLines, numberOfLines, NSInteger)
|
||||
RCT_REMAP_SHADOW_PROPERTY(allowsFontScaling, allowsFontScaling, BOOL)
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(numberOfLines, NSInteger)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onTextLayout, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(ellipsizeMode, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(selectable, BOOL)
|
||||
|
||||
@@ -40,19 +40,19 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
self.setAttributedText()
|
||||
}
|
||||
|
||||
// Tell yoga not to use flexbox
|
||||
// Returning true here will tell Yoga to not use flexbox and instead use our custom measure func.
|
||||
override func isYogaLeafNode() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// We only need to insert text children
|
||||
// We should only insert children that are UITextView shadows
|
||||
override func insertReactSubview(_ subview: RCTShadowView!, at atIndex: Int) {
|
||||
if subview.isKind(of: RNUITextViewChildShadow.self) {
|
||||
super.insertReactSubview(subview, at: atIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Whenever the subvies update, set the text
|
||||
// Every time the subviews change, we need to reformat and render the text.
|
||||
override func didUpdateReactSubviews() {
|
||||
self.setAttributedText()
|
||||
}
|
||||
@@ -64,7 +64,7 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
return
|
||||
}
|
||||
|
||||
// Update the text
|
||||
// Since we are inside the shadow view here, we have to find the real view and update the text.
|
||||
self.bridge.uiManager.addUIBlock { uiManager, viewRegistry in
|
||||
guard let textView = viewRegistry?[self.reactTag] as? RNUITextView else {
|
||||
return
|
||||
@@ -100,18 +100,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
// Create the attributed string with the generic attributes
|
||||
let string = NSMutableAttributedString(string: child.text, attributes: attributes)
|
||||
|
||||
// Set the paragraph style attributes if necessary
|
||||
// Set the paragraph style attributes if necessary. We can check this by seeing if the provided
|
||||
// line height is not 0.0.
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
if child.lineHeight != 0.0 {
|
||||
paragraphStyle.minimumLineHeight = child.lineHeight
|
||||
paragraphStyle.maximumLineHeight = child.lineHeight
|
||||
// Whenever we change the line height for the text, we are also removing the DynamicType
|
||||
// adjustment for line height. We need to get the multiplier and apply that to the
|
||||
// line height.
|
||||
let scaleMultiplier = scaledFontSize / child.fontSize
|
||||
paragraphStyle.minimumLineHeight = child.lineHeight * scaleMultiplier
|
||||
paragraphStyle.maximumLineHeight = child.lineHeight * scaleMultiplier
|
||||
|
||||
string.addAttribute(
|
||||
NSAttributedString.Key.paragraphStyle,
|
||||
value: paragraphStyle,
|
||||
range: NSMakeRange(0, string.length)
|
||||
)
|
||||
|
||||
// Store that height
|
||||
// To calcualte the size of the text without creating a new UILabel or UITextView, we have
|
||||
// to store this line height for later.
|
||||
self.lineHeight = child.lineHeight
|
||||
} else {
|
||||
self.lineHeight = font.lineHeight
|
||||
@@ -124,24 +131,22 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
self.dirtyLayout()
|
||||
}
|
||||
|
||||
// Create a YGSize based on the max width
|
||||
// To create the needed size we need to:
|
||||
// 1. Get the max size that we can use for the view
|
||||
// 2. Calculate the height of the text based on that max size
|
||||
// 3. Determine how many lines the text is, and limit that number if it exceeds the max
|
||||
// 4. Set the frame size and return the YGSize. YGSize requires Float values while CGSize needs CGFloat
|
||||
func getNeededSize(maxWidth: Float) -> YGSize {
|
||||
// Create the max size and figure out the size of the entire text
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
// Figure out how many total lines there are
|
||||
let totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
var totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
|
||||
// Default to the text size
|
||||
var neededSize: CGSize = textSize.size
|
||||
|
||||
// If the total lines > max number, return size with the max
|
||||
if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
neededSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(self.numberOfLines) * self.lineHeight))
|
||||
totalLines = self.numberOfLines
|
||||
}
|
||||
|
||||
self.frameSize = neededSize
|
||||
return YGSize(width: Float(neededSize.width), height: Float(neededSize.height))
|
||||
self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
}
|
||||
|
||||
+16
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.68.0",
|
||||
"version": "1.72.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -12,8 +12,12 @@
|
||||
"android": "npx expo run:android",
|
||||
"ios": "npx expo run:ios",
|
||||
"web": "npx expo start --web",
|
||||
"use-build-number": "./scripts/useBuildNumberEnv.sh",
|
||||
"build-web": "npx expo export:web && node ./scripts/post-web-build.js && cp -v ./web-build/static/js/*.* ./bskyweb/static/js/",
|
||||
"build-all": "yarn intl:build && eas build --platform all",
|
||||
"build-all": "yarn intl:build && yarn use-build-number eas build --platform all",
|
||||
"build-ios": "yarn use-build-number eas build -p ios",
|
||||
"build-android": "yarn use-build-number eas build -p android",
|
||||
"build": "yarn use-build-number eas build",
|
||||
"start": "npx expo start --dev-client",
|
||||
"start:prod": "npx expo start --dev-client --no-dev --minify",
|
||||
"clean-cache": "rm -rf node_modules/.cache/babel-loader/*",
|
||||
@@ -32,17 +36,15 @@
|
||||
"perf:test:measure": "NODE_ENV=test flashlight test --bundleId xyz.blueskyweb.app --testCommand 'yarn perf:test' --duration 150000 --resultsFilePath .perf/results.json",
|
||||
"perf:test:results": "NODE_ENV=test flashlight report .perf/results.json",
|
||||
"perf:measure": "NODE_ENV=test flashlight measure",
|
||||
"intl:build": "yarn intl:check && yarn intl:compile",
|
||||
"intl:build": "yarn intl:extract && yarn intl:compile",
|
||||
"intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
|
||||
"intl:extract": "lingui extract",
|
||||
"intl:compile": "lingui compile",
|
||||
"nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
|
||||
"bump": "./scripts/bumpIosBuildNumber.sh && ./scripts/bumpAndroidBuildNumber.sh",
|
||||
"bump:ios": "./scripts/bumpIosBuildNumber.sh",
|
||||
"bump:android": "./scripts/bumpAndroidBuildNumber.sh"
|
||||
"update-extensions": "scripts/updateExtensions.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.9.5",
|
||||
"@atproto/api": "^0.10.5",
|
||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
@@ -56,11 +58,11 @@
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.6.4",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-native-async-storage/async-storage": "1.21.0",
|
||||
"@react-native-camera-roll/camera-roll": "^5.2.2",
|
||||
"@react-native-clipboard/clipboard": "^1.10.0",
|
||||
"@react-native-community/blur": "^4.3.0",
|
||||
"@react-native-community/datetimepicker": "7.6.1",
|
||||
"@react-native-masked-view/masked-view": "0.3.0",
|
||||
"@react-native-menu/menu": "^0.8.0",
|
||||
"@react-native-picker/picker": "2.6.1",
|
||||
@@ -108,6 +110,7 @@
|
||||
"expo-image": "~1.10.3",
|
||||
"expo-image-manipulator": "^11.8.0",
|
||||
"expo-image-picker": "~14.7.1",
|
||||
"expo-linking": "^6.2.2",
|
||||
"expo-localization": "~14.8.2",
|
||||
"expo-media-library": "~15.9.1",
|
||||
"expo-notifications": "~0.27.3",
|
||||
@@ -123,7 +126,6 @@
|
||||
"js-sha256": "^0.9.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lande": "^1.0.10",
|
||||
"libphonenumber-js": "^1.10.53",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
@@ -137,7 +139,7 @@
|
||||
"mobx": "^6.6.1",
|
||||
"mobx-react-lite": "^3.4.0",
|
||||
"mobx-utils": "^6.0.6",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
"patch-package": "^6.5.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
@@ -146,8 +148,10 @@
|
||||
"react-avatar-editor": "^13.0.0",
|
||||
"react-circular-progressbar": "^2.1.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-keyed-flatten-children": "^3.0.0",
|
||||
"react-native": "0.73.2",
|
||||
"react-native-appstate-hook": "^1.0.6",
|
||||
"react-native-date-picker": "^4.4.0",
|
||||
"react-native-drawer-layout": "^4.0.0-alpha.3",
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.14.0",
|
||||
@@ -176,6 +180,8 @@
|
||||
"react-responsive": "^9.0.2",
|
||||
"rn-fetch-blob": "^0.12.0",
|
||||
"sentry-expo": "~7.0.1",
|
||||
"statsig-react": "^1.36.0",
|
||||
"statsig-react-native-expo": "^4.6.1",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tlds": "^1.234.0",
|
||||
"use-deep-compare": "^1.1.0",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
diff --git a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
index ef4f368..2b0da35 100644
|
||||
--- a/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
+++ b/node_modules/@react-navigation/native/lib/commonjs/useLinking.js
|
||||
@@ -273,8 +273,12 @@ function useLinking(ref, _ref) {
|
||||
});
|
||||
const currentIndex = history.index;
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
- // An existing entry for this path exists and it's less than current index, go back to that
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
// We couldn't find an existing entry to go back to, so we'll go back by the delta
|
||||
diff --git a/node_modules/@react-navigation/native/lib/module/useLinking.js b/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
index 62a3b43..11a5a28 100644
|
||||
--- a/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
+++ b/node_modules/@react-navigation/native/lib/module/useLinking.js
|
||||
@@ -264,8 +264,12 @@ export default function useLinking(ref, _ref) {
|
||||
});
|
||||
const currentIndex = history.index;
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
- // An existing entry for this path exists and it's less than current index, go back to that
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) { // An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
// We couldn't find an existing entry to go back to, so we'll go back by the delta
|
||||
diff --git a/node_modules/@react-navigation/native/src/useLinking.tsx b/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
index 3db40b7..9ba4ecd 100644
|
||||
--- a/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
+++ b/node_modules/@react-navigation/native/src/useLinking.tsx
|
||||
@@ -381,7 +381,12 @@ export default function useLinking(
|
||||
const currentIndex = history.index;
|
||||
|
||||
try {
|
||||
- if (nextIndex !== -1 && nextIndex < currentIndex) {
|
||||
+ if (
|
||||
+ nextIndex !== -1 &&
|
||||
+ nextIndex < currentIndex &&
|
||||
+ // We should only go back if the entry exists and it's less than current index
|
||||
+ history.get(nextIndex - currentIndex)
|
||||
+ ) {
|
||||
// An existing entry for this path exists and it's less than current index, go back to that
|
||||
await history.go(nextIndex - currentIndex);
|
||||
} else {
|
||||
@@ -0,0 +1,5 @@
|
||||
# React Navigation history bug patch
|
||||
|
||||
This patches react-navigation to fix the issues in https://github.com/bluesky-social/social-app/issues/710.
|
||||
|
||||
This is based on the PR found at https://github.com/react-navigation/react-navigation/pull/11833
|
||||
@@ -0,0 +1,22 @@
|
||||
# Share extension plugin for Expo
|
||||
|
||||
This plugin handles moving the necessary files into their respective iOS and Android targets and updating the build
|
||||
phases, plists, manifests, etc.
|
||||
|
||||
## Steps
|
||||
|
||||
### ios
|
||||
|
||||
1. Update entitlements
|
||||
2. Set the app group to group.<identifier>
|
||||
3. Add the extension plist
|
||||
4. Add the view controller
|
||||
5. Update the xcode project's build phases
|
||||
|
||||
### android
|
||||
|
||||
1. Update the manifest with the intents the app can receive
|
||||
|
||||
## Credits
|
||||
|
||||
Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
const {withEntitlementsPlist} = require('@expo/config-plugins')
|
||||
|
||||
const withAppEntitlements = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withEntitlementsPlist(config, async config => {
|
||||
config.modResults['com.apple.security.application-groups'] = [
|
||||
`group.app.bsky`,
|
||||
]
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withAppEntitlements}
|
||||
@@ -0,0 +1,31 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionEntitlements = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const extensionEntitlementsPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
extensionName,
|
||||
`${extensionName}.entitlements`,
|
||||
)
|
||||
|
||||
const shareExtensionEntitlements = {
|
||||
'com.apple.security.application-groups': [`group.app.bsky`],
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
|
||||
recursive: true,
|
||||
})
|
||||
fs.writeFileSync(
|
||||
extensionEntitlementsPath,
|
||||
plist.default.build(shareExtensionEntitlements),
|
||||
)
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withExtensionEntitlements}
|
||||
@@ -0,0 +1,39 @@
|
||||
const {withInfoPlist} = require('@expo/config-plugins')
|
||||
const plist = require('@expo/plist')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionInfoPlist = (config, {extensionName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withInfoPlist(config, config => {
|
||||
const plistPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
'modules',
|
||||
extensionName,
|
||||
'Info.plist',
|
||||
)
|
||||
const targetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
extensionName,
|
||||
'Info.plist',
|
||||
)
|
||||
|
||||
const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
|
||||
|
||||
extPlist.MainAppScheme = config.scheme
|
||||
extPlist.CFBundleName = '$(PRODUCT_NAME)'
|
||||
extPlist.CFBundleDisplayName = 'Extension'
|
||||
extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
|
||||
extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
|
||||
extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
|
||||
extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
|
||||
extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||
fs.writeFileSync(targetPath, plist.default.build(extPlist))
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withExtensionInfoPlist}
|
||||
@@ -0,0 +1,31 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
|
||||
const withExtensionViewController = (
|
||||
config,
|
||||
{controllerName, extensionName},
|
||||
) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const controllerPath = path.join(
|
||||
config.modRequest.projectRoot,
|
||||
'modules',
|
||||
extensionName,
|
||||
`${controllerName}.swift`,
|
||||
)
|
||||
|
||||
const targetPath = path.join(
|
||||
config.modRequest.platformProjectRoot,
|
||||
extensionName,
|
||||
`${controllerName}.swift`,
|
||||
)
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), {recursive: true})
|
||||
fs.copyFileSync(controllerPath, targetPath)
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withExtensionViewController}
|
||||
@@ -0,0 +1,89 @@
|
||||
const {withAndroidManifest} = require('@expo/config-plugins')
|
||||
|
||||
const withIntentFilters = config => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withAndroidManifest(config, config => {
|
||||
const intents = [
|
||||
{
|
||||
action: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.action.SEND',
|
||||
},
|
||||
},
|
||||
],
|
||||
category: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.category.DEFAULT',
|
||||
},
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{
|
||||
$: {
|
||||
'android:mimeType': 'image/*',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.action.SEND',
|
||||
},
|
||||
},
|
||||
],
|
||||
category: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.category.DEFAULT',
|
||||
},
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{
|
||||
$: {
|
||||
'android:mimeType': 'text/plain',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
action: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.action.SEND_MULTIPLE',
|
||||
},
|
||||
},
|
||||
],
|
||||
category: [
|
||||
{
|
||||
$: {
|
||||
'android:name': 'android.intent.category.DEFAULT',
|
||||
},
|
||||
},
|
||||
],
|
||||
data: [
|
||||
{
|
||||
$: {
|
||||
'android:mimeType': 'image/*',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const intentFilter =
|
||||
config.modResults.manifest.application?.[0].activity?.[0]['intent-filter']
|
||||
|
||||
if (intentFilter) {
|
||||
intentFilter.push(...intents)
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withIntentFilters}
|
||||
@@ -0,0 +1,47 @@
|
||||
const {withPlugins} = require('@expo/config-plugins')
|
||||
const {withAppEntitlements} = require('./withAppEntitlements')
|
||||
const {withXcodeTarget} = require('./withXcodeTarget')
|
||||
const {withExtensionEntitlements} = require('./withExtensionEntitlements')
|
||||
const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
|
||||
const {withExtensionViewController} = require('./withExtensionViewController')
|
||||
const {withIntentFilters} = require('./withIntentFilters')
|
||||
|
||||
const SHARE_EXTENSION_NAME = 'Share-with-Bluesky'
|
||||
const SHARE_EXTENSION_CONTROLLER_NAME = 'ShareViewController'
|
||||
|
||||
const withShareExtensions = config => {
|
||||
return withPlugins(config, [
|
||||
// IOS
|
||||
withAppEntitlements,
|
||||
[
|
||||
withExtensionEntitlements,
|
||||
{
|
||||
extensionName: SHARE_EXTENSION_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withExtensionInfoPlist,
|
||||
{
|
||||
extensionName: SHARE_EXTENSION_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withExtensionViewController,
|
||||
{
|
||||
extensionName: SHARE_EXTENSION_NAME,
|
||||
controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
|
||||
},
|
||||
],
|
||||
[
|
||||
withXcodeTarget,
|
||||
{
|
||||
extensionName: SHARE_EXTENSION_NAME,
|
||||
controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
|
||||
},
|
||||
],
|
||||
// Android
|
||||
withIntentFilters,
|
||||
])
|
||||
}
|
||||
|
||||
module.exports = withShareExtensions
|
||||
@@ -0,0 +1,63 @@
|
||||
const {withXcodeProject} = require('@expo/config-plugins')
|
||||
|
||||
const withXcodeTarget = (config, {extensionName, controllerName}) => {
|
||||
// eslint-disable-next-line no-shadow
|
||||
return withXcodeProject(config, config => {
|
||||
const pbxProject = config.modResults
|
||||
|
||||
const target = pbxProject.addTarget(
|
||||
extensionName,
|
||||
'app_extension',
|
||||
extensionName,
|
||||
)
|
||||
pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
|
||||
pbxProject.addBuildPhase(
|
||||
[],
|
||||
'PBXResourcesBuildPhase',
|
||||
'Resources',
|
||||
target.uuid,
|
||||
)
|
||||
const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
|
||||
pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
|
||||
pbxProject.addSourceFile(
|
||||
`${extensionName}/${controllerName}.swift`,
|
||||
{target: target.uuid},
|
||||
pbxGroupKey,
|
||||
)
|
||||
|
||||
var configurations = pbxProject.pbxXCBuildConfigurationSection()
|
||||
for (var key in configurations) {
|
||||
if (typeof configurations[key].buildSettings !== 'undefined') {
|
||||
var buildSettingsObj = configurations[key].buildSettings
|
||||
if (
|
||||
typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
|
||||
buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
|
||||
) {
|
||||
buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
|
||||
buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
|
||||
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
|
||||
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
|
||||
buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
|
||||
buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
|
||||
buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
|
||||
buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
|
||||
buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
|
||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
|
||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pbxProject.addTargetAttribute(
|
||||
'DevelopmentTeam',
|
||||
'B3LX46C5HS',
|
||||
extensionName,
|
||||
)
|
||||
pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS')
|
||||
|
||||
return config
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {withXcodeTarget}
|
||||
@@ -0,0 +1,37 @@
|
||||
const {withAndroidManifest} = require('expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidManifestFCMIconPlugin(appConfig) {
|
||||
return withAndroidManifest(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
function addOrModifyMetaData(metaData, name, resource) {
|
||||
const elem = metaData.find(elem => elem.$['android:name'] === name)
|
||||
if (elem === undefined) {
|
||||
metaData.push({
|
||||
$: {
|
||||
'android:name': name,
|
||||
'android:resource': resource,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
elem.$['android:resource'] = resource
|
||||
}
|
||||
}
|
||||
const androidManifest = decoratedAppConfig.modResults.manifest
|
||||
const metaData = androidManifest.application[0]['meta-data']
|
||||
addOrModifyMetaData(
|
||||
metaData,
|
||||
'com.google.firebase.messaging.default_notification_color',
|
||||
'@color/notification_icon_color',
|
||||
)
|
||||
addOrModifyMetaData(
|
||||
metaData,
|
||||
'com.google.firebase.messaging.default_notification_icon',
|
||||
'@drawable/notification_icon',
|
||||
)
|
||||
return decoratedAppConfig
|
||||
} catch (e) {
|
||||
console.error(`withAndroidManifestFCMIconPlugin failed`, e)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const {withAndroidStyles, AndroidConfig} = require('@expo/config-plugins')
|
||||
|
||||
module.exports = function withAndroidStylesWindowBackgroundPlugin(appConfig) {
|
||||
return withAndroidStyles(appConfig, function (decoratedAppConfig) {
|
||||
try {
|
||||
decoratedAppConfig.modResults = AndroidConfig.Styles.assignStylesValue(
|
||||
decoratedAppConfig.modResults,
|
||||
{
|
||||
add: true,
|
||||
parent: AndroidConfig.Styles.getAppThemeLightNoActionBarGroup(),
|
||||
name: 'android:windowBackground',
|
||||
value: '@drawable/splashscreen',
|
||||
},
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(`withAndroidStylesWindowBackgroundPlugin failed`, e)
|
||||
}
|
||||
return decoratedAppConfig
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Tool Scripts
|
||||
|
||||
## updateExtensions.sh
|
||||
|
||||
Updates the extensions in `/modules` with the current iOS/Android project changes.
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The number here should always be the line number the iOS build variable is on
|
||||
line=$(sed "30q;d" ./app.config.js)
|
||||
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
|
||||
newBuildNumber=$((currentBuildNumber+1))
|
||||
newBuildVariable="const ANDROID_VERSION_CODE = '$newBuildNumber'"
|
||||
sed -i.bak "30s/.*/ $newBuildVariable/" ./app.config.js
|
||||
rm -rf ./app.config.js.bak
|
||||
|
||||
echo "Android build number bumped to $newBuildNumber"
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# The number here should always be the line number the iOS build variable is on
|
||||
line=$(sed "24q;d" ./app.config.js)
|
||||
currentBuildNumber=$(echo "$line" | grep -oE '[0-9]+([.][0-9]+)?')
|
||||
newBuildNumber=$((currentBuildNumber+1))
|
||||
newBuildVariable="const IOS_BUILD_NUMBER = '$newBuildNumber'"
|
||||
sed -i.bak "24s/.*/ $newBuildVariable/" ./app.config.js
|
||||
rm -rf ./app.config.js.bak
|
||||
|
||||
echo "iOS build number bumped to $newBuildNumber"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
|
||||
MODULES_DIRECTORY="./modules"
|
||||
|
||||
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
|
||||
echo "$IOS_SHARE_EXTENSION_DIRECTORY not found inside of your iOS project."
|
||||
exit 1
|
||||
else
|
||||
cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
|
||||
fi
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
outputIos=$(eas build:version:get -p ios)
|
||||
outputAndroid=$(eas build:version:get -p android)
|
||||
currentIosVersion=${outputIos#*buildNumber - }
|
||||
currentAndroidVersion=${outputAndroid#*versionCode - }
|
||||
|
||||
BSKY_IOS_BUILD_NUMBER=$((currentIosVersion+1))
|
||||
BSKY_ANDROID_VERSION_CODE=$((currentAndroidVersion+1))
|
||||
|
||||
bash -c "BSKY_IOS_BUILD_NUMBER=$BSKY_IOS_BUILD_NUMBER BSKY_ANDROID_VERSION_CODE=$BSKY_ANDROID_VERSION_CODE $*"
|
||||
|
||||
+23
-15
@@ -43,8 +43,12 @@ import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unre
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
import {StatusBar} from 'expo-status-bar'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -53,6 +57,7 @@ function InnerApp() {
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
const {_} = useLingui()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -67,26 +72,29 @@ function InnerApp() {
|
||||
|
||||
return (
|
||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
||||
{isAndroid && <StatusBar />}
|
||||
<Alf theme={theme}>
|
||||
<Splash isReady={!isInitialLoad}>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
<StatsigProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<GestureHandlerRootView style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</StatsigProvider>
|
||||
</React.Fragment>
|
||||
</Splash>
|
||||
</Alf>
|
||||
|
||||
+20
-15
@@ -32,11 +32,14 @@ import {
|
||||
import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
|
||||
import * as persisted from '#/state/persisted'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||
import {useIntentHandler} from 'lib/hooks/useIntentHandler'
|
||||
|
||||
function InnerApp() {
|
||||
const {isInitialLoad, currentAccount} = useSession()
|
||||
const {resumeSession} = useSessionApi()
|
||||
const theme = useColorModeTheme()
|
||||
useIntentHandler()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
@@ -52,21 +55,23 @@ function InnerApp() {
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
<StatsigProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
{/* All components should be within this provider */}
|
||||
<RootSiblingParent>
|
||||
<SafeAreaProvider>
|
||||
<Shell />
|
||||
</SafeAreaProvider>
|
||||
</RootSiblingParent>
|
||||
<ToastContainer />
|
||||
</ThemeProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</StatsigProvider>
|
||||
</React.Fragment>
|
||||
</Alf>
|
||||
)
|
||||
|
||||
+33
-6
@@ -71,12 +71,14 @@ import {AppPasswords} from 'view/screens/AppPasswords'
|
||||
import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
|
||||
import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts'
|
||||
import {SavedFeeds} from 'view/screens/SavedFeeds'
|
||||
import {PreferencesHomeFeed} from 'view/screens/PreferencesHomeFeed'
|
||||
import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
|
||||
import {PreferencesThreads} from 'view/screens/PreferencesThreads'
|
||||
import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds'
|
||||
import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {i18n, MessageDescriptor} from '@lingui/core'
|
||||
import HashtagScreen from '#/screens/Hashtag'
|
||||
import {logEvent} from './lib/statsig/statsig'
|
||||
|
||||
const navigationRef = createNavigationContainerRef<AllNavigatorParams>()
|
||||
|
||||
@@ -242,9 +244,12 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
options={{title: title(msg`Edit My Feeds`), requireAuth: true}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesHomeFeed"
|
||||
getComponent={() => PreferencesHomeFeed}
|
||||
options={{title: title(msg`Home Feed Preferences`), requireAuth: true}}
|
||||
name="PreferencesFollowingFeed"
|
||||
getComponent={() => PreferencesFollowingFeed}
|
||||
options={{
|
||||
title: title(msg`Following Feed Preferences`),
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreferencesThreads"
|
||||
@@ -259,6 +264,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
||||
requireAuth: true,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Hashtag"
|
||||
getComponent={() => HashtagScreen}
|
||||
options={{title: title(msg`Hashtag`)}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -457,7 +467,8 @@ const FlatNavigator = () => {
|
||||
*/
|
||||
|
||||
const LINKING = {
|
||||
prefixes: ['bsky://', 'https://bsky.app'],
|
||||
// TODO figure out what we are going to use
|
||||
prefixes: ['bsky://', 'bluesky://', 'https://bsky.app'],
|
||||
|
||||
getPathFromState(state: State) {
|
||||
// find the current node in the navigation tree
|
||||
@@ -476,6 +487,18 @@ const LINKING = {
|
||||
|
||||
getStateFromPath(path: string) {
|
||||
const [name, params] = router.matchPath(path)
|
||||
|
||||
// Any time we receive a url that starts with `intent/` we want to ignore it here. It will be handled in the
|
||||
// intent handler hook. We should check for the trailing slash, because if there isn't one then it isn't a valid
|
||||
// intent
|
||||
// On web, there is no route state that's created by default, so we should initialize it as the home route. On
|
||||
// native, since the home tab and the home screen are defined as initial routes, we don't need to return a state
|
||||
// since it will be created by react-navigation.
|
||||
if (path.includes('intent/')) {
|
||||
if (isNative) return
|
||||
return buildStateObject('Flat', 'Home', params)
|
||||
}
|
||||
|
||||
if (isNative) {
|
||||
if (name === 'Search') {
|
||||
return buildStateObject('SearchTab', 'Search', params)
|
||||
@@ -494,7 +517,8 @@ const LINKING = {
|
||||
},
|
||||
])
|
||||
} else {
|
||||
return buildStateObject('Flat', name, params)
|
||||
const res = buildStateObject('Flat', name, params)
|
||||
return res
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -626,11 +650,14 @@ function logModuleInitTime() {
|
||||
return
|
||||
}
|
||||
didInit = true
|
||||
|
||||
const initMs = Math.round(
|
||||
// @ts-ignore Emitted by Metro in the bundle prelude
|
||||
performance.now() - global.__BUNDLE_START_TIME__,
|
||||
)
|
||||
console.log(`Time to first paint: ${initMs} ms`)
|
||||
logEvent('init', initMs)
|
||||
|
||||
if (__DEV__) {
|
||||
// This log is noisy, so keep false committed
|
||||
const shouldLog = false
|
||||
|
||||
+8
-7
@@ -21,7 +21,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Svg, {Path, SvgProps} from 'react-native-svg'
|
||||
|
||||
import {isAndroid} from '#/platform/detection'
|
||||
import {useThemePrefs} from 'state/shell'
|
||||
import {Logotype} from '#/view/icons/Logotype'
|
||||
|
||||
// @ts-ignore
|
||||
@@ -75,10 +74,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
isLayoutReady &&
|
||||
reduceMotion !== undefined
|
||||
|
||||
const {colorMode} = useThemePrefs()
|
||||
const colorScheme = useColorScheme()
|
||||
const themeName = colorMode === 'system' ? colorScheme : colorMode
|
||||
const isDarkMode = themeName === 'dark'
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -184,6 +181,8 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
return (
|
||||
<View style={{flex: 1}} onLayout={onLayout}>
|
||||
@@ -235,7 +234,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
},
|
||||
]}>
|
||||
<AnimatedLogo
|
||||
fill="#fff"
|
||||
fill={logoBg}
|
||||
style={[{opacity: 0}, logoAnimations]}
|
||||
/>
|
||||
</Animated.View>
|
||||
@@ -256,14 +255,16 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<AnimatedLogo fill="#fff" style={[logoAnimations]} />
|
||||
<AnimatedLogo fill={logoBg} style={[logoAnimations]} />
|
||||
</Animated.View>
|
||||
}>
|
||||
{!isAnimationComplete && (
|
||||
<View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{backgroundColor: '#fff'},
|
||||
{
|
||||
backgroundColor: logoBg,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
+57
-1
@@ -1,3 +1,4 @@
|
||||
import {web, native} from '#/alf/util/platform'
|
||||
import * as tokens from '#/alf/tokens'
|
||||
|
||||
export const atoms = {
|
||||
@@ -113,6 +114,9 @@ export const atoms = {
|
||||
flex_wrap: {
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
flex_0: {
|
||||
flex: web('0 0 auto') || (native(0) as number),
|
||||
},
|
||||
flex_1: {
|
||||
flex: 1,
|
||||
},
|
||||
@@ -122,6 +126,9 @@ export const atoms = {
|
||||
flex_shrink: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
justify_start: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
justify_center: {
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -140,10 +147,31 @@ export const atoms = {
|
||||
align_end: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
self_auto: {
|
||||
alignSelf: 'auto',
|
||||
},
|
||||
self_start: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
self_end: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
self_center: {
|
||||
alignSelf: 'center',
|
||||
},
|
||||
self_stretch: {
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
self_baseline: {
|
||||
alignSelf: 'baseline',
|
||||
},
|
||||
|
||||
/*
|
||||
* Text
|
||||
*/
|
||||
text_left: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
text_center: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
@@ -152,53 +180,75 @@ export const atoms = {
|
||||
},
|
||||
text_2xs: {
|
||||
fontSize: tokens.fontSize._2xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xs: {
|
||||
fontSize: tokens.fontSize.xs,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_sm: {
|
||||
fontSize: tokens.fontSize.sm,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_md: {
|
||||
fontSize: tokens.fontSize.md,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_lg: {
|
||||
fontSize: tokens.fontSize.lg,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_xl: {
|
||||
fontSize: tokens.fontSize.xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_2xl: {
|
||||
fontSize: tokens.fontSize._2xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_3xl: {
|
||||
fontSize: tokens.fontSize._3xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_4xl: {
|
||||
fontSize: tokens.fontSize._4xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
text_5xl: {
|
||||
fontSize: tokens.fontSize._5xl,
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
leading_tight: {
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
leading_snug: {
|
||||
lineHeight: 1.25,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
leading_normal: {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
tracking_normal: {
|
||||
letterSpacing: 0,
|
||||
},
|
||||
tracking_wide: {
|
||||
letterSpacing: 0.25,
|
||||
},
|
||||
font_normal: {
|
||||
fontWeight: tokens.fontWeight.normal,
|
||||
},
|
||||
font_bold: {
|
||||
fontWeight: tokens.fontWeight.semibold,
|
||||
},
|
||||
italic: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
|
||||
/*
|
||||
* Border
|
||||
*/
|
||||
border_0: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
border: {
|
||||
borderWidth: 1,
|
||||
},
|
||||
@@ -208,6 +258,12 @@ export const atoms = {
|
||||
border_b: {
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
border_l: {
|
||||
borderLeftWidth: 1,
|
||||
},
|
||||
border_r: {
|
||||
borderRightWidth: 1,
|
||||
},
|
||||
|
||||
/*
|
||||
* Shadow
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ const breakpoints: {
|
||||
[key: string]: number
|
||||
} = {
|
||||
gtMobile: 800,
|
||||
gtTablet: 1200,
|
||||
gtTablet: 1300,
|
||||
}
|
||||
function getActiveBreakpoints({width}: {width: number}) {
|
||||
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
|
||||
|
||||
+73
-14
@@ -1,6 +1,7 @@
|
||||
import * as tokens from '#/alf/tokens'
|
||||
import type {Mutable} from '#/alf/types'
|
||||
import {atoms} from '#/alf/atoms'
|
||||
import {BLUE_HUE, GREEN_HUE, RED_HUE} from '#/alf/util/colorGeneration'
|
||||
|
||||
export type ThemeName = 'light' | 'dim' | 'dark'
|
||||
export type ReadonlyTheme = typeof light
|
||||
@@ -132,21 +133,63 @@ export const darkPalette: Palette = {
|
||||
|
||||
export const dimPalette: Palette = {
|
||||
...darkPalette,
|
||||
black: tokens.color.gray_1000,
|
||||
black: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[0]}%)`,
|
||||
|
||||
contrast_25: tokens.color.gray_975,
|
||||
contrast_50: tokens.color.gray_950,
|
||||
contrast_100: tokens.color.gray_900,
|
||||
contrast_200: tokens.color.gray_800,
|
||||
contrast_300: tokens.color.gray_700,
|
||||
contrast_400: tokens.color.gray_600,
|
||||
contrast_500: tokens.color.gray_500,
|
||||
contrast_600: tokens.color.gray_400,
|
||||
contrast_700: tokens.color.gray_300,
|
||||
contrast_800: tokens.color.gray_200,
|
||||
contrast_900: tokens.color.gray_100,
|
||||
contrast_950: tokens.color.gray_50,
|
||||
contrast_975: tokens.color.gray_25,
|
||||
contrast_25: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[1]}%)`,
|
||||
contrast_50: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[2]}%)`,
|
||||
contrast_100: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[3]}%)`,
|
||||
contrast_200: `hsl(${BLUE_HUE}, 28%, ${tokens.dimScale[4]}%)`,
|
||||
contrast_300: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[5]}%)`,
|
||||
contrast_400: `hsl(${BLUE_HUE}, 24%, ${tokens.dimScale[6]}%)`,
|
||||
contrast_500: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[7]}%)`,
|
||||
contrast_600: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[8]}%)`,
|
||||
contrast_700: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[9]}%)`,
|
||||
contrast_800: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[10]}%)`,
|
||||
contrast_900: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[11]}%)`,
|
||||
contrast_950: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[12]}%)`,
|
||||
contrast_975: `hsl(${BLUE_HUE}, 20%, ${tokens.dimScale[13]}%)`,
|
||||
|
||||
primary_25: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[13]}%)`,
|
||||
primary_50: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[12]}%)`,
|
||||
primary_100: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[11]}%)`,
|
||||
primary_200: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[10]}%)`,
|
||||
primary_300: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[9]}%)`,
|
||||
primary_400: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[8]}%)`,
|
||||
primary_500: `hsl(${BLUE_HUE}, 99%, ${tokens.dimScale[7]}%)`,
|
||||
primary_600: `hsl(${BLUE_HUE}, 95%, ${tokens.dimScale[6]}%)`,
|
||||
primary_700: `hsl(${BLUE_HUE}, 90%, ${tokens.dimScale[5]}%)`,
|
||||
primary_800: `hsl(${BLUE_HUE}, 82%, ${tokens.dimScale[4]}%)`,
|
||||
primary_900: `hsl(${BLUE_HUE}, 70%, ${tokens.dimScale[3]}%)`,
|
||||
primary_950: `hsl(${BLUE_HUE}, 60%, ${tokens.dimScale[2]}%)`,
|
||||
primary_975: `hsl(${BLUE_HUE}, 50%, ${tokens.dimScale[1]}%)`,
|
||||
|
||||
positive_25: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[13]}%)`,
|
||||
positive_50: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[12]}%)`,
|
||||
positive_100: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[11]}%)`,
|
||||
positive_200: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[10]}%)`,
|
||||
positive_300: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[9]}%)`,
|
||||
positive_400: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[8]}%)`,
|
||||
positive_500: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[7]}%)`,
|
||||
positive_600: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[6]}%)`,
|
||||
positive_700: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[5]}%)`,
|
||||
positive_800: `hsl(${GREEN_HUE}, 82%, ${tokens.dimScale[4]}%)`,
|
||||
positive_900: `hsl(${GREEN_HUE}, 70%, ${tokens.dimScale[3]}%)`,
|
||||
positive_950: `hsl(${GREEN_HUE}, 60%, ${tokens.dimScale[2]}%)`,
|
||||
positive_975: `hsl(${GREEN_HUE}, 50%, ${tokens.dimScale[1]}%)`,
|
||||
|
||||
negative_25: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[13]}%)`,
|
||||
negative_50: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[12]}%)`,
|
||||
negative_100: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[11]}%)`,
|
||||
negative_200: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[10]}%)`,
|
||||
negative_300: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[9]}%)`,
|
||||
negative_400: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[8]}%)`,
|
||||
negative_500: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[7]}%)`,
|
||||
negative_600: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[6]}%)`,
|
||||
negative_700: `hsl(${RED_HUE}, 91%, ${tokens.dimScale[5]}%)`,
|
||||
negative_800: `hsl(${RED_HUE}, 88%, ${tokens.dimScale[4]}%)`,
|
||||
negative_900: `hsl(${RED_HUE}, 84%, ${tokens.dimScale[3]}%)`,
|
||||
negative_950: `hsl(${RED_HUE}, 80%, ${tokens.dimScale[2]}%)`,
|
||||
negative_975: `hsl(${RED_HUE}, 70%, ${tokens.dimScale[1]}%)`,
|
||||
} as const
|
||||
|
||||
export const light = {
|
||||
@@ -325,6 +368,7 @@ export const dark: Theme = {
|
||||
export const dim: Theme = {
|
||||
...dark,
|
||||
name: 'dim',
|
||||
palette: dimPalette,
|
||||
atoms: {
|
||||
...dark.atoms,
|
||||
text: {
|
||||
@@ -393,5 +437,20 @@ export const dim: Theme = {
|
||||
border_contrast_high: {
|
||||
borderColor: dimPalette.contrast_300,
|
||||
},
|
||||
shadow_sm: {
|
||||
...atoms.shadow_sm,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
|
||||
},
|
||||
shadow_md: {
|
||||
...atoms.shadow_md,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
|
||||
},
|
||||
shadow_lg: {
|
||||
...atoms.shadow_lg,
|
||||
shadowOpacity: 0.7,
|
||||
shadowColor: `hsl(${BLUE_HUE}, 28%, 6%)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
+25
-18
@@ -1,25 +1,32 @@
|
||||
const BLUE_HUE = 211
|
||||
const RED_HUE = 346
|
||||
const GREEN_HUE = 152
|
||||
import {
|
||||
BLUE_HUE,
|
||||
RED_HUE,
|
||||
GREEN_HUE,
|
||||
generateScale,
|
||||
} from '#/alf/util/colorGeneration'
|
||||
|
||||
export const scale = generateScale(6, 100)
|
||||
// dim shifted 6% lighter
|
||||
export const dimScale = generateScale(12, 100)
|
||||
|
||||
export const color = {
|
||||
trueBlack: '#000000',
|
||||
|
||||
gray_0: `hsl(${BLUE_HUE}, 20%, 100%)`,
|
||||
gray_25: `hsl(${BLUE_HUE}, 20%, 97%)`,
|
||||
gray_50: `hsl(${BLUE_HUE}, 20%, 95%)`,
|
||||
gray_100: `hsl(${BLUE_HUE}, 20%, 90%)`,
|
||||
gray_200: `hsl(${BLUE_HUE}, 20%, 80%)`,
|
||||
gray_300: `hsl(${BLUE_HUE}, 20%, 70%)`,
|
||||
gray_400: `hsl(${BLUE_HUE}, 20%, 60%)`,
|
||||
gray_500: `hsl(${BLUE_HUE}, 20%, 50%)`,
|
||||
gray_600: `hsl(${BLUE_HUE}, 24%, 42%)`,
|
||||
gray_700: `hsl(${BLUE_HUE}, 24%, 34%)`,
|
||||
gray_800: `hsl(${BLUE_HUE}, 28%, 26%)`,
|
||||
gray_900: `hsl(${BLUE_HUE}, 28%, 18%)`,
|
||||
gray_950: `hsl(${BLUE_HUE}, 28%, 10%)`,
|
||||
gray_975: `hsl(${BLUE_HUE}, 28%, 7%)`,
|
||||
gray_1000: `hsl(${BLUE_HUE}, 28%, 4%)`,
|
||||
gray_0: `hsl(${BLUE_HUE}, 20%, ${scale[14]}%)`,
|
||||
gray_25: `hsl(${BLUE_HUE}, 20%, ${scale[13]}%)`,
|
||||
gray_50: `hsl(${BLUE_HUE}, 20%, ${scale[12]}%)`,
|
||||
gray_100: `hsl(${BLUE_HUE}, 20%, ${scale[11]}%)`,
|
||||
gray_200: `hsl(${BLUE_HUE}, 20%, ${scale[10]}%)`,
|
||||
gray_300: `hsl(${BLUE_HUE}, 20%, ${scale[9]}%)`,
|
||||
gray_400: `hsl(${BLUE_HUE}, 20%, ${scale[8]}%)`,
|
||||
gray_500: `hsl(${BLUE_HUE}, 20%, ${scale[7]}%)`,
|
||||
gray_600: `hsl(${BLUE_HUE}, 24%, ${scale[6]}%)`,
|
||||
gray_700: `hsl(${BLUE_HUE}, 24%, ${scale[5]}%)`,
|
||||
gray_800: `hsl(${BLUE_HUE}, 28%, ${scale[4]}%)`,
|
||||
gray_900: `hsl(${BLUE_HUE}, 28%, ${scale[3]}%)`,
|
||||
gray_950: `hsl(${BLUE_HUE}, 28%, ${scale[2]}%)`,
|
||||
gray_975: `hsl(${BLUE_HUE}, 28%, ${scale[1]}%)`,
|
||||
gray_1000: `hsl(${BLUE_HUE}, 28%, ${scale[0]}%)`,
|
||||
|
||||
blue_25: `hsl(${BLUE_HUE}, 99%, 97%)`,
|
||||
blue_50: `hsl(${BLUE_HUE}, 99%, 95%)`,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export const BLUE_HUE = 211
|
||||
export const RED_HUE = 346
|
||||
export const GREEN_HUE = 152
|
||||
|
||||
/**
|
||||
* Smooth progression of lightness "stops" for generating HSL colors.
|
||||
*/
|
||||
export const COLOR_STOPS = [
|
||||
0, 0.05, 0.1, 0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.85, 0.9, 0.95, 1,
|
||||
]
|
||||
|
||||
export function generateScale(start: number, end: number) {
|
||||
const range = end - start
|
||||
return COLOR_STOPS.map(stop => {
|
||||
return start + range * stop
|
||||
})
|
||||
}
|
||||
+13
-13
@@ -1,25 +1,25 @@
|
||||
import {Platform} from 'react-native'
|
||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||
|
||||
export function web(value: any) {
|
||||
return Platform.select({
|
||||
web: value,
|
||||
})
|
||||
if (isWeb) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function ios(value: any) {
|
||||
return Platform.select({
|
||||
ios: value,
|
||||
})
|
||||
if (isIOS) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function android(value: any) {
|
||||
return Platform.select({
|
||||
android: value,
|
||||
})
|
||||
if (isAndroid) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export function native(value: any) {
|
||||
return Platform.select({
|
||||
native: value,
|
||||
})
|
||||
if (isNative) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useColorModeTheme(): ThemeName {
|
||||
React.useLayoutEffect(() => {
|
||||
const theme = getThemeName(colorScheme, colorMode, darkTheme)
|
||||
updateDocument(theme)
|
||||
updateSystemBackground(theme)
|
||||
SystemUI.setBackgroundColorAsync(getBackgroundColor(theme))
|
||||
}, [colorMode, colorScheme, darkTheme])
|
||||
|
||||
return React.useMemo(
|
||||
@@ -42,23 +42,24 @@ function updateDocument(theme: ThemeName) {
|
||||
if (isWeb && typeof window !== 'undefined') {
|
||||
// @ts-ignore web only
|
||||
const html = window.document.documentElement
|
||||
// @ts-ignore web only
|
||||
const meta = window.document.querySelector('meta[name="theme-color"]')
|
||||
|
||||
// remove any other color mode classes
|
||||
html.className = html.className.replace(/(theme)--\w+/g, '')
|
||||
|
||||
html.classList.add(`theme--${theme}`)
|
||||
// set color to 'theme-color' meta tag
|
||||
meta?.setAttribute('content', getBackgroundColor(theme))
|
||||
}
|
||||
}
|
||||
|
||||
function updateSystemBackground(theme: ThemeName) {
|
||||
function getBackgroundColor(theme: ThemeName): string {
|
||||
switch (theme) {
|
||||
case 'light':
|
||||
SystemUI.setBackgroundColorAsync(light.atoms.bg.backgroundColor)
|
||||
break
|
||||
return light.atoms.bg.backgroundColor
|
||||
case 'dark':
|
||||
SystemUI.setBackgroundColorAsync(dark.atoms.bg.backgroundColor)
|
||||
break
|
||||
return dark.atoms.bg.backgroundColor
|
||||
case 'dim':
|
||||
SystemUI.setBackgroundColorAsync(dim.atoms.bg.backgroundColor)
|
||||
break
|
||||
return dim.atoms.bg.backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
+87
-63
@@ -27,7 +27,7 @@ export type ButtonColor =
|
||||
| 'gradient_sunset'
|
||||
| 'gradient_nordic'
|
||||
| 'gradient_bonfire'
|
||||
export type ButtonSize = 'small' | 'large'
|
||||
export type ButtonSize = 'tiny' | 'small' | 'large'
|
||||
export type ButtonShape = 'round' | 'square' | 'default'
|
||||
export type VariantProps = {
|
||||
/**
|
||||
@@ -48,25 +48,32 @@ export type VariantProps = {
|
||||
shape?: ButtonShape
|
||||
}
|
||||
|
||||
export type ButtonProps = React.PropsWithChildren<
|
||||
Pick<PressableProps, 'disabled' | 'onPress'> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
>
|
||||
export type ButtonState = {
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
pressed: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export type ButtonContext = VariantProps & ButtonState
|
||||
|
||||
export type ButtonProps = Pick<
|
||||
PressableProps,
|
||||
'disabled' | 'onPress' | 'testID'
|
||||
> &
|
||||
AccessibilityProps &
|
||||
VariantProps & {
|
||||
testID?: string
|
||||
label: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
children:
|
||||
| React.ReactNode
|
||||
| string
|
||||
| ((context: ButtonContext) => React.ReactNode | string)
|
||||
}
|
||||
export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean}
|
||||
|
||||
const Context = React.createContext<
|
||||
VariantProps & {
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
pressed: boolean
|
||||
disabled: boolean
|
||||
}
|
||||
>({
|
||||
const Context = React.createContext<VariantProps & ButtonState>({
|
||||
hovered: false,
|
||||
focused: false,
|
||||
pressed: false,
|
||||
@@ -158,7 +165,7 @@ export function Button({
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: tokens.color.blue_500,
|
||||
borderColor: t.palette.primary_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
@@ -167,7 +174,7 @@ export function Button({
|
||||
})
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light ? tokens.color.blue_200 : tokens.color.blue_900,
|
||||
borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
@@ -184,20 +191,14 @@ export function Button({
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_50
|
||||
: tokens.color.gray_900,
|
||||
backgroundColor: t.palette.contrast_50,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_100
|
||||
: tokens.color.gray_950,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_200
|
||||
: tokens.color.gray_950,
|
||||
backgroundColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
@@ -207,21 +208,19 @@ export function Button({
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light ? tokens.color.gray_300 : tokens.color.gray_700,
|
||||
borderColor: t.palette.contrast_300,
|
||||
})
|
||||
hoverStyles.push(a.border, t.atoms.bg_contrast_50)
|
||||
hoverStyles.push(t.atoms.bg_contrast_50)
|
||||
} else {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: light ? tokens.color.gray_200 : tokens.color.gray_800,
|
||||
borderColor: t.palette.contrast_200,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push(t.atoms.bg)
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? tokens.color.gray_100
|
||||
: tokens.color.gray_900,
|
||||
backgroundColor: t.palette.contrast_100,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,14 +228,14 @@ export function Button({
|
||||
if (variant === 'solid') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_400,
|
||||
backgroundColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push({
|
||||
backgroundColor: t.palette.negative_500,
|
||||
backgroundColor: t.palette.negative_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
backgroundColor: t.palette.negative_600,
|
||||
backgroundColor: t.palette.negative_700,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
@@ -246,7 +245,7 @@ export function Button({
|
||||
|
||||
if (!disabled) {
|
||||
baseStyles.push(a.border, {
|
||||
borderColor: t.palette.negative_400,
|
||||
borderColor: t.palette.negative_500,
|
||||
})
|
||||
hoverStyles.push(a.border, {
|
||||
backgroundColor: light
|
||||
@@ -266,7 +265,7 @@ export function Button({
|
||||
hoverStyles.push({
|
||||
backgroundColor: light
|
||||
? t.palette.negative_100
|
||||
: t.palette.negative_950,
|
||||
: t.palette.negative_975,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -277,6 +276,8 @@ export function Button({
|
||||
baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
|
||||
}
|
||||
} else if (shape === 'round' || shape === 'square') {
|
||||
if (size === 'large') {
|
||||
@@ -287,12 +288,18 @@ export function Button({
|
||||
}
|
||||
} else if (size === 'small') {
|
||||
baseStyles.push({height: 40, width: 40})
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push({height: 20, width: 20})
|
||||
}
|
||||
|
||||
if (shape === 'round') {
|
||||
baseStyles.push(a.rounded_full)
|
||||
} else if (shape === 'square') {
|
||||
baseStyles.push(a.rounded_sm)
|
||||
if (size === 'tiny') {
|
||||
baseStyles.push(a.rounded_xs)
|
||||
} else {
|
||||
baseStyles.push(a.rounded_sm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,7 +345,7 @@ export function Button({
|
||||
}
|
||||
}, [variant, color])
|
||||
|
||||
const context = React.useMemo(
|
||||
const context = React.useMemo<ButtonContext>(
|
||||
() => ({
|
||||
...state,
|
||||
variant,
|
||||
@@ -349,6 +356,8 @@ export function Button({
|
||||
[state, variant, color, size, disabled],
|
||||
)
|
||||
|
||||
const flattenedBaseStyles = flatten(baseStyles)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
role="button"
|
||||
@@ -362,15 +371,14 @@ export function Button({
|
||||
disabled: disabled || false,
|
||||
}}
|
||||
style={[
|
||||
flatten(style),
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.overflow_hidden,
|
||||
a.justify_center,
|
||||
...baseStyles,
|
||||
flattenedBaseStyles,
|
||||
...(state.hovered || state.pressed ? hoverStyles : []),
|
||||
...(state.focused ? focusStyles : []),
|
||||
flatten(style),
|
||||
]}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
@@ -379,21 +387,31 @@ export function Button({
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
{variant === 'gradient' && (
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed || state.focused
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
locations={gradientLocations}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.overflow_hidden,
|
||||
{borderRadius: flattenedBaseStyles.borderRadius},
|
||||
]}>
|
||||
<LinearGradient
|
||||
colors={
|
||||
state.hovered || state.pressed || state.focused
|
||||
? gradientHoverColors
|
||||
: gradientColors
|
||||
}
|
||||
locations={gradientLocations}
|
||||
start={{x: 0, y: 0}}
|
||||
end={{x: 1, y: 1}}
|
||||
style={[a.absolute, a.inset_0]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<Context.Provider value={context}>
|
||||
{typeof children === 'string' ? (
|
||||
<ButtonText>{children}</ButtonText>
|
||||
) : typeof children === 'function' ? (
|
||||
children(context)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
@@ -435,31 +453,31 @@ export function useSharedButtonTextStyles() {
|
||||
if (variant === 'solid' || variant === 'gradient') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_700 : tokens.color.gray_100,
|
||||
color: t.palette.contrast_700,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
|
||||
color: t.palette.contrast_400,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'outline') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_400 : tokens.color.gray_700,
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
} else if (variant === 'ghost') {
|
||||
if (!disabled) {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_600 : tokens.color.gray_300,
|
||||
color: t.palette.contrast_600,
|
||||
})
|
||||
} else {
|
||||
baseStyles.push({
|
||||
color: light ? tokens.color.gray_400 : tokens.color.gray_600,
|
||||
color: t.palette.contrast_300,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -493,6 +511,8 @@ export function useSharedButtonTextStyles() {
|
||||
|
||||
if (size === 'large') {
|
||||
baseStyles.push(a.text_md, android({paddingBottom: 1}))
|
||||
} else if (size === 'tiny') {
|
||||
baseStyles.push(a.text_xs, android({paddingBottom: 1}))
|
||||
} else {
|
||||
baseStyles.push(a.text_sm, android({paddingBottom: 1}))
|
||||
}
|
||||
@@ -514,9 +534,11 @@ export function ButtonText({children, style, ...rest}: ButtonTextProps) {
|
||||
export function ButtonIcon({
|
||||
icon: Comp,
|
||||
position,
|
||||
size: iconSize,
|
||||
}: {
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
size?: SVGIconProps['size']
|
||||
}) {
|
||||
const {size, disabled} = useButtonContext()
|
||||
const textStyles = useSharedButtonTextStyles()
|
||||
@@ -532,7 +554,9 @@ export function ButtonIcon({
|
||||
},
|
||||
]}>
|
||||
<Comp
|
||||
size={size === 'large' ? 'md' : 'sm'}
|
||||
size={
|
||||
iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm')
|
||||
}
|
||||
style={[{color: textStyles.color, pointerEvents: 'none'}]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
import {DialogContextProps, DialogControlProps} from '#/components/Dialog/types'
|
||||
import {
|
||||
DialogContextProps,
|
||||
DialogControlRefProps,
|
||||
DialogOuterProps,
|
||||
} from '#/components/Dialog/types'
|
||||
|
||||
export const Context = React.createContext<DialogContextProps>({
|
||||
close: () => {},
|
||||
@@ -11,13 +15,14 @@ export function useDialogContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function useDialogControl() {
|
||||
export function useDialogControl(): DialogOuterProps['control'] {
|
||||
const id = React.useId()
|
||||
const control = React.useRef<DialogControlProps>({
|
||||
const control = React.useRef<DialogControlRefProps>({
|
||||
open: () => {},
|
||||
close: () => {},
|
||||
})
|
||||
const {activeDialogs} = useDialogStateContext()
|
||||
const {activeDialogs, openDialogs} = useDialogStateContext()
|
||||
const isOpen = openDialogs.includes(id)
|
||||
|
||||
React.useEffect(() => {
|
||||
activeDialogs.current.set(id, control)
|
||||
@@ -27,9 +32,18 @@ export function useDialogControl() {
|
||||
}
|
||||
}, [id, activeDialogs])
|
||||
|
||||
return {
|
||||
ref: control,
|
||||
open: () => control.current.open(),
|
||||
close: () => control.current.close(),
|
||||
}
|
||||
return React.useMemo<DialogOuterProps['control']>(
|
||||
() => ({
|
||||
id,
|
||||
ref: control,
|
||||
isOpen,
|
||||
open: () => {
|
||||
control.current.open()
|
||||
},
|
||||
close: cb => {
|
||||
control.current.close(cb)
|
||||
},
|
||||
}),
|
||||
[id, control, isOpen],
|
||||
)
|
||||
}
|
||||
|
||||
+147
-62
@@ -1,16 +1,21 @@
|
||||
import React, {useImperativeHandle} from 'react'
|
||||
import {View, Dimensions} from 'react-native'
|
||||
import {View, Dimensions, Keyboard, Pressable} from 'react-native'
|
||||
import BottomSheet, {
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetBackdropProps,
|
||||
BottomSheetScrollView,
|
||||
BottomSheetTextInput,
|
||||
BottomSheetView,
|
||||
useBottomSheet,
|
||||
WINDOW_HEIGHT,
|
||||
} from '@gorhom/bottom-sheet'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated'
|
||||
|
||||
import {useTheme, atoms as a} from '#/alf'
|
||||
import {useTheme, atoms as a, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
import {createInput} from '#/components/forms/TextField'
|
||||
import {logger} from '#/logger'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
import {
|
||||
DialogOuterProps,
|
||||
@@ -24,6 +29,47 @@ export * from '#/components/Dialog/types'
|
||||
// @ts-ignore
|
||||
export const Input = createInput(BottomSheetTextInput)
|
||||
|
||||
function Backdrop(props: BottomSheetBackdropProps) {
|
||||
const t = useTheme()
|
||||
const bottomSheet = useBottomSheet()
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
const opacity =
|
||||
(Math.abs(WINDOW_HEIGHT - props.animatedPosition.value) - 50) / 1000
|
||||
|
||||
return {
|
||||
opacity: Math.min(Math.max(opacity, 0), 0.55),
|
||||
}
|
||||
})
|
||||
|
||||
const onPress = React.useCallback(() => {
|
||||
bottomSheet.close()
|
||||
}, [bottomSheet])
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
t.atoms.bg_contrast_300,
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
position: 'absolute',
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dialog backdrop"
|
||||
accessibilityHint="Press the backdrop to close the dialog"
|
||||
style={{flex: 1}}
|
||||
onPress={onPress}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
control,
|
||||
@@ -35,12 +81,32 @@ export function Outer({
|
||||
const sheetOptions = nativeOptions?.sheet || {}
|
||||
const hasSnapPoints = !!sheetOptions.snapPoints
|
||||
const insets = useSafeAreaInsets()
|
||||
const closeCallback = React.useRef<() => void>()
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>((i = 0) => {
|
||||
sheet.current?.snapToIndex(i)
|
||||
}, [])
|
||||
/*
|
||||
* Used to manage open/closed, but index is otherwise handled internally by `BottomSheet`
|
||||
*/
|
||||
const [openIndex, setOpenIndex] = React.useState(-1)
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
/*
|
||||
* `openIndex` is the index of the snap point to open the bottom sheet to. If >0, the bottom sheet is open.
|
||||
*/
|
||||
const isOpen = openIndex > -1
|
||||
|
||||
const open = React.useCallback<DialogControlProps['open']>(
|
||||
({index} = {}) => {
|
||||
setDialogIsOpen(control.id, true)
|
||||
// can be set to any index of `snapPoints`, but `0` is the first i.e. "open"
|
||||
setOpenIndex(index || 0)
|
||||
},
|
||||
[setOpenIndex, setDialogIsOpen, control.id],
|
||||
)
|
||||
|
||||
const close = React.useCallback<DialogControlProps['close']>(cb => {
|
||||
if (cb && typeof cb === 'function') {
|
||||
closeCallback.current = cb
|
||||
}
|
||||
sheet.current?.close()
|
||||
}, [])
|
||||
|
||||
@@ -53,81 +119,91 @@ export function Outer({
|
||||
[open, close],
|
||||
)
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(index: number) => {
|
||||
if (index === -1) {
|
||||
onClose?.()
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
const onCloseInner = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
try {
|
||||
closeCallback.current?.()
|
||||
} catch (e: any) {
|
||||
logger.error(`Dialog closeCallback failed`, {
|
||||
message: e.message,
|
||||
})
|
||||
} finally {
|
||||
closeCallback.current = undefined
|
||||
}
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
setOpenIndex(-1)
|
||||
}, [control.id, onClose, setDialogIsOpen])
|
||||
|
||||
const context = React.useMemo(() => ({close}), [close])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
ref={sheet}
|
||||
index={-1}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={props => (
|
||||
<BottomSheetBackdrop
|
||||
opacity={0.4}
|
||||
appearsOnIndex={0}
|
||||
disappearsOnIndex={-1}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onChange={onChange}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</Portal>
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<View
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
style={[a.absolute, a.inset_0]}>
|
||||
<BottomSheet
|
||||
enableDynamicSizing={!hasSnapPoints}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="interactive"
|
||||
android_keyboardInputMode="adjustResize"
|
||||
keyboardBlurBehavior="restore"
|
||||
topInset={insets.top}
|
||||
{...sheetOptions}
|
||||
snapPoints={sheetOptions.snapPoints || ['100%']}
|
||||
ref={sheet}
|
||||
index={openIndex}
|
||||
backgroundStyle={{backgroundColor: 'transparent'}}
|
||||
backdropComponent={Backdrop}
|
||||
handleIndicatorStyle={{backgroundColor: t.palette.primary_500}}
|
||||
handleStyle={{display: 'none'}}
|
||||
onClose={onCloseInner}>
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.bg,
|
||||
{
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
height: Dimensions.get('window').height * 2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
</BottomSheet>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// TODO a11y props here, or is that handled by the sheet?
|
||||
export function Inner(props: DialogInnerProps) {
|
||||
export function Inner({children, style}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetView
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.p_xl,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
paddingBottom: insets.bottom + a.pb_5xl.paddingBottom,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{props.children}
|
||||
{children}
|
||||
</BottomSheetView>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScrollableInner(props: DialogInnerProps) {
|
||||
export function ScrollableInner({children, style}: DialogInnerProps) {
|
||||
const insets = useSafeAreaInsets()
|
||||
return (
|
||||
<BottomSheetScrollView
|
||||
@@ -136,13 +212,15 @@ export function ScrollableInner(props: DialogInnerProps) {
|
||||
style={[
|
||||
a.flex_1, // main diff is this
|
||||
a.p_xl,
|
||||
a.h_full,
|
||||
{
|
||||
paddingTop: 40,
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
flatten(style),
|
||||
]}>
|
||||
{props.children}
|
||||
{children}
|
||||
<View style={{height: insets.bottom + a.pt_5xl.paddingTop}} />
|
||||
</BottomSheetScrollView>
|
||||
)
|
||||
@@ -150,8 +228,15 @@ export function ScrollableInner(props: DialogInnerProps) {
|
||||
|
||||
export function Handle() {
|
||||
const t = useTheme()
|
||||
|
||||
const onTouchStart = React.useCallback(() => {
|
||||
Keyboard.dismiss()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}>
|
||||
<View
|
||||
style={[a.absolute, a.w_full, a.align_center, a.z_10, {height: 40}]}
|
||||
onTouchStart={onTouchStart}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -5,11 +5,14 @@ import Animated, {FadeInDown, FadeIn} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useTheme, atoms as a, useBreakpoints, web} from '#/alf'
|
||||
import {useTheme, atoms as a, useBreakpoints, web, flatten} from '#/alf'
|
||||
import {Portal} from '#/components/Portal'
|
||||
|
||||
import {DialogOuterProps, DialogInnerProps} from '#/components/Dialog/types'
|
||||
import {Context} from '#/components/Dialog/context'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||
|
||||
export {useDialogControl, useDialogContext} from '#/components/Dialog/context'
|
||||
export * from '#/components/Dialog/types'
|
||||
@@ -18,27 +21,30 @@ export {Input} from '#/components/forms/TextField'
|
||||
const stopPropagation = (e: any) => e.stopPropagation()
|
||||
|
||||
export function Outer({
|
||||
children,
|
||||
control,
|
||||
onClose,
|
||||
children,
|
||||
}: React.PropsWithChildren<DialogOuterProps>) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [isVisible, setIsVisible] = React.useState(true)
|
||||
const {setDialogIsOpen} = useDialogStateControlContext()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
setIsOpen(true)
|
||||
}, [setIsOpen])
|
||||
setDialogIsOpen(control.id, true)
|
||||
}, [setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
const close = React.useCallback(async () => {
|
||||
setIsVisible(false)
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
setIsOpen(false)
|
||||
setIsVisible(true)
|
||||
setDialogIsOpen(control.id, false)
|
||||
onClose?.()
|
||||
}, [onClose, setIsOpen])
|
||||
}, [onClose, setIsOpen, setDialogIsOpen, control.id])
|
||||
|
||||
useImperativeHandle(
|
||||
control.ref,
|
||||
@@ -147,7 +153,7 @@ export function Inner({
|
||||
a.rounded_md,
|
||||
a.w_full,
|
||||
a.border,
|
||||
gtMobile ? a.p_xl : a.p_lg,
|
||||
gtMobile ? a.p_2xl : a.p_xl,
|
||||
t.atoms.bg,
|
||||
{
|
||||
maxWidth: 600,
|
||||
@@ -156,7 +162,7 @@ export function Inner({
|
||||
shadowOpacity: t.name === 'light' ? 0.1 : 0.4,
|
||||
shadowRadius: 30,
|
||||
},
|
||||
...(Array.isArray(style) ? style : [style || {}]),
|
||||
flatten(style),
|
||||
]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
@@ -170,25 +176,28 @@ export function Handle() {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO(eric) unused rn
|
||||
*/
|
||||
// export function Close() {
|
||||
// const {_} = useLingui()
|
||||
// const t = useTheme()
|
||||
// const {close} = useDialogContext()
|
||||
// return (
|
||||
// <View
|
||||
// style={[
|
||||
// a.absolute,
|
||||
// a.z_10,
|
||||
// {
|
||||
// top: a.pt_lg.paddingTop,
|
||||
// right: a.pr_lg.paddingRight,
|
||||
// },
|
||||
// ]}>
|
||||
// <Button onPress={close} label={_(msg`Close active dialog`)}>
|
||||
// </Button>
|
||||
// </View>
|
||||
// )
|
||||
// }
|
||||
export function Close() {
|
||||
const {_} = useLingui()
|
||||
const {close} = React.useContext(Context)
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.z_10,
|
||||
{
|
||||
top: a.pt_md.paddingTop,
|
||||
right: a.pr_md.paddingRight,
|
||||
},
|
||||
]}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
onPress={() => close()}
|
||||
label={_(msg`Close active dialog`)}>
|
||||
<ButtonIcon icon={X} size="md" />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
import React from 'react'
|
||||
import type {ViewStyle, AccessibilityProps} from 'react-native'
|
||||
import type {AccessibilityProps} from 'react-native'
|
||||
import {BottomSheetProps} from '@gorhom/bottom-sheet'
|
||||
|
||||
import {ViewStyleProp} from '#/alf'
|
||||
|
||||
type A11yProps = Required<AccessibilityProps>
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: () => void
|
||||
/**
|
||||
* Mutated by useImperativeHandle to provide a public API for controlling the
|
||||
* dialog. The methods here will actually become the handlers defined within
|
||||
* the `Dialog.Outer` component.
|
||||
*/
|
||||
export type DialogControlRefProps = {
|
||||
open: (options?: DialogControlOpenOptions) => void
|
||||
close: (callback?: () => void) => void
|
||||
}
|
||||
|
||||
export type DialogControlProps = {
|
||||
open: (index?: number) => void
|
||||
close: () => void
|
||||
/**
|
||||
* The return type of the useDialogControl hook.
|
||||
*/
|
||||
export type DialogControlProps = DialogControlRefProps & {
|
||||
id: string
|
||||
ref: React.RefObject<DialogControlRefProps>
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
export type DialogContextProps = {
|
||||
close: DialogControlProps['close']
|
||||
}
|
||||
|
||||
export type DialogControlOpenOptions = {
|
||||
/**
|
||||
* NATIVE ONLY
|
||||
*
|
||||
* Optional index of the snap point to open the bottom sheet to. Defaults to
|
||||
* 0, which is the first snap point (i.e. "open").
|
||||
*/
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type DialogOuterProps = {
|
||||
control: {
|
||||
ref: React.RefObject<DialogControlProps>
|
||||
open: (index?: number) => void
|
||||
close: () => void
|
||||
}
|
||||
control: DialogControlProps
|
||||
onClose?: () => void
|
||||
nativeOptions?: {
|
||||
sheet?: Omit<BottomSheetProps, 'children'>
|
||||
@@ -26,10 +48,7 @@ export type DialogOuterProps = {
|
||||
webOptions?: {}
|
||||
}
|
||||
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<{
|
||||
style?: ViewStyle
|
||||
}> &
|
||||
T
|
||||
type DialogInnerPropsBase<T> = React.PropsWithChildren<ViewStyleProp> & T
|
||||
export type DialogInnerProps =
|
||||
| DialogInnerPropsBase<{
|
||||
label?: undefined
|
||||
|
||||
+97
-75
@@ -1,9 +1,5 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
GestureResponderEvent,
|
||||
Linking,
|
||||
TouchableWithoutFeedback,
|
||||
} from 'react-native'
|
||||
import {GestureResponderEvent} from 'react-native'
|
||||
import {
|
||||
useLinkProps,
|
||||
useNavigation,
|
||||
@@ -13,7 +9,7 @@ import {sanitizeUrl} from '@braintree/sanitize-url'
|
||||
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useTheme, web, flatten, TextStyleProp} from '#/alf'
|
||||
import {useTheme, web, flatten, TextStyleProp, atoms as a} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import {AllNavigatorParams, NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
@@ -23,7 +19,8 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {router} from '#/routes'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {useOpenLink} from 'state/preferences/in-app-browser'
|
||||
|
||||
/**
|
||||
* Only available within a `Link`, since that inherits from `Button`.
|
||||
@@ -35,6 +32,13 @@ type BaseLinkProps = Pick<
|
||||
Parameters<typeof useLinkProps<AllNavigatorParams>>[0],
|
||||
'to'
|
||||
> & {
|
||||
testID?: string
|
||||
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
|
||||
/**
|
||||
* The React Navigation `StackAction` to perform when the link is pressed.
|
||||
*/
|
||||
@@ -45,14 +49,28 @@ type BaseLinkProps = Pick<
|
||||
*
|
||||
* Note: atm this only works for `InlineLink`s with a string child.
|
||||
*/
|
||||
warnOnMismatchingTextChild?: boolean
|
||||
disableMismatchWarning?: boolean
|
||||
|
||||
/**
|
||||
* Callback for when the link is pressed. Prevent default and return `false`
|
||||
* to exit early and prevent navigation.
|
||||
*
|
||||
* DO NOT use this for navigation, that's what the `to` prop is for.
|
||||
*/
|
||||
onPress?: (e: GestureResponderEvent) => void | false
|
||||
|
||||
/**
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
}
|
||||
|
||||
export function useLink({
|
||||
to,
|
||||
displayText,
|
||||
action = 'push',
|
||||
warnOnMismatchingTextChild,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
}: BaseLinkProps & {
|
||||
displayText: string
|
||||
}) {
|
||||
@@ -63,11 +81,16 @@ export function useLink({
|
||||
})
|
||||
const isExternal = isExternalUrl(href)
|
||||
const {openModal, closeModal} = useModalControls()
|
||||
const openLink = useOpenLink()
|
||||
|
||||
const onPress = React.useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
const exitEarlyIfFalse = outerOnPress?.(e)
|
||||
|
||||
if (exitEarlyIfFalse === false) return
|
||||
|
||||
const requiresWarning = Boolean(
|
||||
warnOnMismatchingTextChild &&
|
||||
!disableMismatchWarning &&
|
||||
displayText &&
|
||||
isExternal &&
|
||||
linkRequiresWarning(href, displayText),
|
||||
@@ -85,7 +108,7 @@ export function useLink({
|
||||
e.preventDefault()
|
||||
|
||||
if (isExternal) {
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
} else {
|
||||
/**
|
||||
* A `GestureResponderEvent`, but cast to `any` to avoid using a bunch
|
||||
@@ -103,7 +126,7 @@ export function useLink({
|
||||
href.startsWith('http') ||
|
||||
href.startsWith('mailto')
|
||||
) {
|
||||
Linking.openURL(href)
|
||||
openLink(href)
|
||||
} else {
|
||||
closeModal() // close any active modals
|
||||
|
||||
@@ -124,14 +147,16 @@ export function useLink({
|
||||
}
|
||||
},
|
||||
[
|
||||
href,
|
||||
isExternal,
|
||||
warnOnMismatchingTextChild,
|
||||
navigation,
|
||||
action,
|
||||
outerOnPress,
|
||||
disableMismatchWarning,
|
||||
displayText,
|
||||
closeModal,
|
||||
isExternal,
|
||||
href,
|
||||
openModal,
|
||||
openLink,
|
||||
closeModal,
|
||||
action,
|
||||
navigation,
|
||||
],
|
||||
)
|
||||
|
||||
@@ -142,17 +167,8 @@ export function useLink({
|
||||
}
|
||||
}
|
||||
|
||||
export type LinkProps = Omit<BaseLinkProps, 'warnOnMismatchingTextChild'> &
|
||||
Omit<ButtonProps, 'style' | 'onPress' | 'disabled' | 'label'> & {
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
/**
|
||||
* Web-only attribute. Sets `download` attr on web.
|
||||
*/
|
||||
download?: string
|
||||
}
|
||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
||||
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
|
||||
|
||||
/**
|
||||
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||
@@ -166,6 +182,7 @@ export function Link({
|
||||
children,
|
||||
to,
|
||||
action = 'push',
|
||||
onPress: outerOnPress,
|
||||
download,
|
||||
...rest
|
||||
}: LinkProps) {
|
||||
@@ -173,24 +190,26 @@ export function Link({
|
||||
to,
|
||||
displayText: typeof children === 'string' ? children : '',
|
||||
action,
|
||||
onPress: outerOnPress,
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[a.justify_start, flatten(rest.style)]}
|
||||
role="link"
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
onPress={onPress}
|
||||
onPress={download ? undefined : onPress}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
// no underline, only `InlineLink` has underlines
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
@@ -200,21 +219,18 @@ export function Link({
|
||||
}
|
||||
|
||||
export type InlineLinkProps = React.PropsWithChildren<
|
||||
BaseLinkProps &
|
||||
TextStyleProp & {
|
||||
/**
|
||||
* Label for a11y. Defaults to the href.
|
||||
*/
|
||||
label?: string
|
||||
}
|
||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||
>
|
||||
|
||||
export function InlineLink({
|
||||
children,
|
||||
to,
|
||||
action = 'push',
|
||||
warnOnMismatchingTextChild,
|
||||
disableMismatchWarning,
|
||||
style,
|
||||
onPress: outerOnPress,
|
||||
download,
|
||||
selectable,
|
||||
...rest
|
||||
}: InlineLinkProps) {
|
||||
const t = useTheme()
|
||||
@@ -223,52 +239,58 @@ export function InlineLink({
|
||||
to,
|
||||
displayText: stringChildren ? children : '',
|
||||
action,
|
||||
warnOnMismatchingTextChild,
|
||||
disableMismatchWarning,
|
||||
onPress: outerOnPress,
|
||||
})
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
const flattenedStyle = flatten(style)
|
||||
|
||||
return (
|
||||
<TouchableWithoutFeedback
|
||||
accessibilityRole="button"
|
||||
onPress={onPress}
|
||||
<Text
|
||||
selectable={selectable}
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: flattenedStyle.color ?? t.palette.primary_500,
|
||||
},
|
||||
flattenedStyle,
|
||||
]}
|
||||
role="link"
|
||||
onPress={download ? undefined : onPress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}>
|
||||
<Text
|
||||
label={href}
|
||||
{...rest}
|
||||
style={[
|
||||
{color: t.palette.primary_500},
|
||||
(focused || pressed) && {
|
||||
outline: 0,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
flatten(style),
|
||||
]}
|
||||
role="link"
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
},
|
||||
dataSet: stringChildren
|
||||
? {}
|
||||
: {
|
||||
// default to no underline, apply this ourselves
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
{children}
|
||||
</Text>
|
||||
</TouchableWithoutFeedback>
|
||||
onBlur={onBlur}
|
||||
onMouseEnter={onHoverIn}
|
||||
onMouseLeave={onHoverOut}
|
||||
accessibilityRole="link"
|
||||
href={href}
|
||||
{...web({
|
||||
hrefAttrs: {
|
||||
target: download ? undefined : isExternal ? 'blank' : undefined,
|
||||
rel: isExternal ? 'noopener noreferrer' : undefined,
|
||||
download,
|
||||
},
|
||||
dataSet: {
|
||||
// default to no underline, apply this ourselves
|
||||
noUnderline: '1',
|
||||
},
|
||||
})}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import React from 'react'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {View} from 'react-native'
|
||||
import {CenteredView} from 'view/com/util/Views'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {cleanError} from 'lib/strings/errors'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {StackActions} from '@react-navigation/native'
|
||||
import {useNavigation} from '@react-navigation/core'
|
||||
import {NavigationProp} from 'lib/routes/types'
|
||||
import {router} from '#/routes'
|
||||
|
||||
export function ListFooter({
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
isFetching: boolean
|
||||
isError: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.border_t,
|
||||
a.pb_lg,
|
||||
t.atoms.border_contrast_low,
|
||||
{height: 100},
|
||||
]}>
|
||||
{isFetching ? (
|
||||
<Loader size="xl" />
|
||||
) : (
|
||||
<ListFooterMaybeError
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ListFooterMaybeError({
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
isError: boolean
|
||||
error?: string
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (!isError) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.px_lg]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.gap_md,
|
||||
a.p_md,
|
||||
a.rounded_sm,
|
||||
a.align_center,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Text
|
||||
style={[a.flex_1, a.text_sm, t.atoms.text_contrast_medium]}
|
||||
numberOfLines={2}>
|
||||
{error ? (
|
||||
cleanError(error)
|
||||
) : (
|
||||
<Trans>Oops, something went wrong!</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="gradient"
|
||||
label="Press to retry"
|
||||
style={[
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
a.px_md,
|
||||
a.py_sm,
|
||||
]}
|
||||
onPress={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListHeaderDesktop({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string
|
||||
subtitle?: string
|
||||
}) {
|
||||
const {gtTablet} = useBreakpoints()
|
||||
const t = useTheme()
|
||||
|
||||
if (!gtTablet) return null
|
||||
|
||||
return (
|
||||
<View style={[a.w_full, a.py_lg, a.px_xl, a.gap_xs]}>
|
||||
<Text style={[a.text_3xl, a.font_bold]}>{title}</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListMaybePlaceholder({
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isError,
|
||||
empty,
|
||||
error,
|
||||
notFoundType = 'page',
|
||||
onRetry,
|
||||
}: {
|
||||
isLoading: boolean
|
||||
isEmpty: boolean
|
||||
isError: boolean
|
||||
empty?: string
|
||||
error?: string
|
||||
notFoundType?: 'page' | 'results'
|
||||
onRetry?: () => Promise<unknown>
|
||||
}) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const t = useTheme()
|
||||
const {gtMobile, gtTablet} = useBreakpoints()
|
||||
|
||||
const canGoBack = navigation.canGoBack()
|
||||
const onGoBack = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
navigation.goBack()
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
|
||||
// Checking the state for routes ensures that web doesn't encounter errors while going back
|
||||
if (navigation.getState()?.routes) {
|
||||
navigation.dispatch(StackActions.push(...router.matchPath('/')))
|
||||
} else {
|
||||
navigation.navigate('HomeTab')
|
||||
navigation.dispatch(StackActions.popToTop())
|
||||
}
|
||||
}
|
||||
}, [navigation, canGoBack])
|
||||
|
||||
if (!isEmpty) return null
|
||||
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
{paddingTop: 175, paddingBottom: 110},
|
||||
]}
|
||||
sideBorders={gtMobile}
|
||||
topBorder={!gtTablet}>
|
||||
{isLoading ? (
|
||||
<View style={[a.w_full, a.align_center, {top: 100}]}>
|
||||
<Loader size="xl" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={[a.w_full, a.align_center, a.gap_lg]}>
|
||||
<Text style={[a.font_bold, a.text_3xl]}>
|
||||
{isError ? (
|
||||
<Trans>Oops!</Trans>
|
||||
) : isEmpty ? (
|
||||
<>
|
||||
{notFoundType === 'results' ? (
|
||||
<Trans>No results found</Trans>
|
||||
) : (
|
||||
<Trans>Page not found</Trans>
|
||||
)}
|
||||
</>
|
||||
) : undefined}
|
||||
</Text>
|
||||
|
||||
{isError ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{error ? error : <Trans>Something went wrong!</Trans>}
|
||||
</Text>
|
||||
) : isEmpty ? (
|
||||
<Text
|
||||
style={[a.text_md, a.text_center, t.atoms.text_contrast_high]}>
|
||||
{empty ? (
|
||||
empty
|
||||
) : (
|
||||
<Trans>
|
||||
We're sorry! We can't find the page you were looking for.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
) : undefined}
|
||||
</View>
|
||||
<View
|
||||
style={[a.gap_md, !gtMobile ? [a.w_full, a.px_lg] : {width: 350}]}>
|
||||
{isError && onRetry && (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
label="Click here"
|
||||
onPress={onRetry}
|
||||
size="large"
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{paddingVertical: 10},
|
||||
]}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color={isError && onRetry ? 'secondary' : 'primary'}
|
||||
label="Click here"
|
||||
onPress={onGoBack}
|
||||
size="large"
|
||||
style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}>
|
||||
Go Back
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</CenteredView>
|
||||
)
|
||||
}
|
||||
@@ -7,11 +7,12 @@ import Animated, {
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme, flatten} from '#/alf'
|
||||
import {Props, useCommonSVGProps} from '#/components/icons/common'
|
||||
import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
|
||||
|
||||
export function Loader(props: Props) {
|
||||
const t = useTheme()
|
||||
const common = useCommonSVGProps(props)
|
||||
const rotation = useSharedValue(0)
|
||||
|
||||
@@ -35,7 +36,15 @@ export function Loader(props: Props) {
|
||||
{width: common.size, height: common.size},
|
||||
animatedStyles,
|
||||
]}>
|
||||
<Icon {...props} style={[a.absolute, a.inset_0, props.style]} />
|
||||
<Icon
|
||||
{...props}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
t.atoms.text_contrast_high,
|
||||
flatten(props.style),
|
||||
]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
import type {ContextType} from '#/components/Menu/types'
|
||||
|
||||
export const Context = React.createContext<ContextType>({
|
||||
// @ts-ignore
|
||||
control: null,
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import flattenReactChildren from 'react-keyed-flatten-children'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {Context} from '#/components/Menu/context'
|
||||
import {
|
||||
ContextType,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
} from '#/components/Menu/types'
|
||||
|
||||
export {useDialogControl as useMenuControl} from '#/components/Dialog'
|
||||
|
||||
export function useMemoControlContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function Root({
|
||||
children,
|
||||
control,
|
||||
}: React.PropsWithChildren<{
|
||||
control?: Dialog.DialogOuterProps['control']
|
||||
}>) {
|
||||
const defaultControl = Dialog.useDialogControl()
|
||||
const context = React.useMemo<ContextType>(
|
||||
() => ({
|
||||
control: control || defaultControl,
|
||||
}),
|
||||
[control, defaultControl],
|
||||
)
|
||||
|
||||
return <Context.Provider value={context}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function Trigger({children, label}: TriggerProps) {
|
||||
const {control} = React.useContext(Context)
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
|
||||
return children({
|
||||
isNative: true,
|
||||
control,
|
||||
state: {
|
||||
hovered: false,
|
||||
focused,
|
||||
pressed,
|
||||
},
|
||||
props: {
|
||||
onPress: control.open,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onPressIn,
|
||||
onPressOut,
|
||||
accessibilityLabel: label,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
const context = React.useContext(Context)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={context.control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
{/* Re-wrap with context since Dialogs are portal-ed to root */}
|
||||
<Context.Provider value={context}>
|
||||
<Dialog.ScrollableInner label="Menu TODO">
|
||||
<View style={[a.gap_lg]}>{children}</View>
|
||||
<View style={{height: a.gap_lg.gap}} />
|
||||
</Dialog.ScrollableInner>
|
||||
</Context.Provider>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
export function Item({children, label, style, onPress, ...rest}: ItemProps) {
|
||||
const t = useTheme()
|
||||
const {control} = React.useContext(Context)
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
{...rest}
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onPress={e => {
|
||||
onPress(e)
|
||||
|
||||
if (!e.defaultPrevented) {
|
||||
control?.close()
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.px_md,
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
t.atoms.bg_contrast_25,
|
||||
t.atoms.border_contrast_low,
|
||||
{minHeight: 44, paddingVertical: 10},
|
||||
style,
|
||||
(focused || pressed) && [t.atoms.bg_contrast_50],
|
||||
]}>
|
||||
{children}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingTop: 3},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
return <Comp size="lg" fill={t.atoms.text_contrast_medium.color} />
|
||||
}
|
||||
|
||||
export function Group({children, style}: GroupProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
a.border,
|
||||
t.atoms.border_contrast_low,
|
||||
style,
|
||||
]}>
|
||||
{flattenReactChildren(children).map((child, i) => {
|
||||
return React.isValidElement(child) && child.type === Item ? (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 ? (
|
||||
<View style={[a.border_b, t.atoms.border_contrast_low]} />
|
||||
) : null}
|
||||
{React.cloneElement(child, {
|
||||
// @ts-ignore
|
||||
style: {
|
||||
borderRadius: 0,
|
||||
borderWidth: 0,
|
||||
},
|
||||
})}
|
||||
</React.Fragment>
|
||||
) : null
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {atoms as a, useTheme, flatten, web} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
import {
|
||||
ContextType,
|
||||
TriggerProps,
|
||||
ItemProps,
|
||||
GroupProps,
|
||||
ItemTextProps,
|
||||
ItemIconProps,
|
||||
} from '#/components/Menu/types'
|
||||
import {Context} from '#/components/Menu/context'
|
||||
|
||||
export function useMenuControl(): Dialog.DialogControlProps {
|
||||
const id = React.useId()
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
id,
|
||||
ref: {current: null},
|
||||
isOpen,
|
||||
open() {
|
||||
setIsOpen(true)
|
||||
},
|
||||
close() {
|
||||
setIsOpen(false)
|
||||
},
|
||||
}),
|
||||
[id, isOpen, setIsOpen],
|
||||
)
|
||||
}
|
||||
|
||||
export function useMemoControlContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function Root({
|
||||
children,
|
||||
control,
|
||||
}: React.PropsWithChildren<{
|
||||
control?: Dialog.DialogOuterProps['control']
|
||||
}>) {
|
||||
const defaultControl = useMenuControl()
|
||||
const context = React.useMemo<ContextType>(
|
||||
() => ({
|
||||
control: control || defaultControl,
|
||||
}),
|
||||
[control, defaultControl],
|
||||
)
|
||||
const onOpenChange = React.useCallback(
|
||||
(open: boolean) => {
|
||||
if (context.control.isOpen && !open) {
|
||||
context.control.close()
|
||||
} else if (!context.control.isOpen && open) {
|
||||
context.control.open()
|
||||
}
|
||||
},
|
||||
[context.control],
|
||||
)
|
||||
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<DropdownMenu.Root
|
||||
open={context.control.isOpen}
|
||||
onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
</DropdownMenu.Root>
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Trigger({children, label, style}: TriggerProps) {
|
||||
const {control} = React.useContext(Context)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
return (
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<Pressable
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={flatten([style, web({outline: 0})])}
|
||||
onPointerDown={() => {
|
||||
control.open()
|
||||
}}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}>
|
||||
{children({
|
||||
isNative: false,
|
||||
control,
|
||||
state: {
|
||||
hovered,
|
||||
focused,
|
||||
pressed: false,
|
||||
},
|
||||
props: {},
|
||||
})}
|
||||
</Pressable>
|
||||
</DropdownMenu.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export function Outer({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content sideOffset={5} loop aria-label="Test">
|
||||
<View
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
|
||||
t.atoms.shadow_md,
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
|
||||
<DropdownMenu.Arrow
|
||||
className="DropdownMenuArrow"
|
||||
fill={
|
||||
(t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25)
|
||||
.backgroundColor
|
||||
}
|
||||
/>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export function Item({children, label, onPress, ...rest}: ItemProps) {
|
||||
const t = useTheme()
|
||||
const {control} = React.useContext(Context)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
|
||||
return (
|
||||
<DropdownMenu.Item asChild>
|
||||
<Pressable
|
||||
{...rest}
|
||||
className="radix-dropdown-item"
|
||||
accessibilityHint=""
|
||||
accessibilityLabel={label}
|
||||
onPress={e => {
|
||||
onPress(e)
|
||||
|
||||
/**
|
||||
* Ported forward from Radix
|
||||
* @see https://www.radix-ui.com/primitives/docs/components/dropdown-menu#item
|
||||
*/
|
||||
if (!e.defaultPrevented) {
|
||||
control.close()
|
||||
}
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
// need `flatten` here for Radix compat
|
||||
style={flatten([
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.py_sm,
|
||||
a.rounded_xs,
|
||||
{minHeight: 32, paddingHorizontal: 10},
|
||||
web({outline: 0}),
|
||||
(hovered || focused) && [
|
||||
web({outline: '0 !important'}),
|
||||
t.name === 'light'
|
||||
? t.atoms.bg_contrast_25
|
||||
: t.atoms.bg_contrast_50,
|
||||
],
|
||||
])}
|
||||
{...web({
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
})}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</DropdownMenu.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemText({children, style}: ItemTextProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Text style={[a.flex_1, a.font_bold, t.atoms.text_contrast_high, style]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemIcon({icon: Comp, position = 'left'}: ItemIconProps) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Comp
|
||||
size="md"
|
||||
fill={t.atoms.text_contrast_medium.color}
|
||||
style={[
|
||||
position === 'left' && {
|
||||
marginLeft: -2,
|
||||
},
|
||||
position === 'right' && {
|
||||
marginRight: -2,
|
||||
marginLeft: 12,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Group({children}: GroupProps) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<DropdownMenu.Separator
|
||||
style={flatten([
|
||||
a.my_xs,
|
||||
t.atoms.bg_contrast_100,
|
||||
{
|
||||
height: 1,
|
||||
},
|
||||
])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react'
|
||||
import {GestureResponderEvent, PressableProps} from 'react-native'
|
||||
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {TextStyleProp, ViewStyleProp} from '#/alf'
|
||||
|
||||
export type ContextType = {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
}
|
||||
|
||||
export type TriggerProps = ViewStyleProp & {
|
||||
children(props: TriggerChildProps): React.ReactNode
|
||||
label: string
|
||||
}
|
||||
export type TriggerChildProps =
|
||||
| {
|
||||
isNative: true
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
state: {
|
||||
/**
|
||||
* Web only, `false` on native
|
||||
*/
|
||||
hovered: false
|
||||
focused: boolean
|
||||
pressed: boolean
|
||||
}
|
||||
/**
|
||||
* We don't necessarily know what these will be spread on to, so we
|
||||
* should add props one-by-one.
|
||||
*
|
||||
* On web, these properties are applied to a parent `Pressable`, so this
|
||||
* object is empty.
|
||||
*/
|
||||
props: {
|
||||
onPress: () => void
|
||||
onFocus: () => void
|
||||
onBlur: () => void
|
||||
onPressIn: () => void
|
||||
onPressOut: () => void
|
||||
accessibilityLabel: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
isNative: false
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
state: {
|
||||
hovered: boolean
|
||||
focused: boolean
|
||||
/**
|
||||
* Native only, `false` on web
|
||||
*/
|
||||
pressed: false
|
||||
}
|
||||
props: {}
|
||||
}
|
||||
|
||||
export type ItemProps = React.PropsWithChildren<
|
||||
Omit<PressableProps, 'style'> &
|
||||
ViewStyleProp & {
|
||||
label: string
|
||||
onPress: (e: GestureResponderEvent) => void
|
||||
}
|
||||
>
|
||||
|
||||
export type ItemTextProps = React.PropsWithChildren<TextStyleProp & {}>
|
||||
export type ItemIconProps = React.PropsWithChildren<{
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
position?: 'left' | 'right'
|
||||
}>
|
||||
|
||||
export type GroupProps = React.PropsWithChildren<ViewStyleProp & {}>
|
||||
@@ -41,7 +41,7 @@ export function Outer({
|
||||
<Dialog.Inner
|
||||
accessibilityLabelledBy={titleId}
|
||||
accessibilityDescribedBy={descriptionId}
|
||||
style={{width: 'auto', maxWidth: 400}}>
|
||||
style={[{width: 'auto', maxWidth: 400}]}>
|
||||
{children}
|
||||
</Dialog.Inner>
|
||||
</Context.Provider>
|
||||
@@ -89,7 +89,7 @@ export function Cancel({
|
||||
color="secondary"
|
||||
size="small"
|
||||
label={_(msg`Cancel`)}
|
||||
onPress={close}>
|
||||
onPress={() => close()}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
|
||||
+125
-32
@@ -1,11 +1,15 @@
|
||||
import React from 'react'
|
||||
import {RichText as RichTextAPI, AppBskyRichtextFacet} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, TextStyleProp} from '#/alf'
|
||||
import {atoms as a, TextStyleProp, flatten, useTheme, web, native} from '#/alf'
|
||||
import {InlineLink} from '#/components/Link'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Text, TextProps} from '#/components/Typography'
|
||||
import {toShortUrl} from 'lib/strings/url-helpers'
|
||||
import {getAgent} from '#/state/session'
|
||||
import {TagMenu, useTagMenuControl} from '#/components/TagMenu'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
|
||||
const WORD_WRAP = {wordWrap: 1}
|
||||
|
||||
@@ -15,34 +19,24 @@ export function RichText({
|
||||
style,
|
||||
numberOfLines,
|
||||
disableLinks,
|
||||
resolveFacets = false,
|
||||
}: TextStyleProp & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
resolveFacets?: boolean
|
||||
}) {
|
||||
const detected = React.useRef(false)
|
||||
const [richText, setRichText] = React.useState<RichTextAPI>(() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
selectable,
|
||||
enableTags = false,
|
||||
authorHandle,
|
||||
}: TextStyleProp &
|
||||
Pick<TextProps, 'selectable'> & {
|
||||
value: RichTextAPI | string
|
||||
testID?: string
|
||||
numberOfLines?: number
|
||||
disableLinks?: boolean
|
||||
enableTags?: boolean
|
||||
authorHandle?: string
|
||||
}) {
|
||||
const richText = React.useMemo(
|
||||
() =>
|
||||
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
|
||||
[value],
|
||||
)
|
||||
const styles = [a.leading_normal, style]
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolveFacets) return
|
||||
|
||||
async function detectFacets() {
|
||||
const rt = new RichTextAPI({text: richText.text})
|
||||
await rt.detectFacets(getAgent())
|
||||
setRichText(rt)
|
||||
}
|
||||
|
||||
if (!detected.current) {
|
||||
detected.current = true
|
||||
detectFacets()
|
||||
}
|
||||
}, [richText, setRichText, resolveFacets])
|
||||
const styles = [a.leading_snug, flatten(style)]
|
||||
|
||||
const {text, facets} = richText
|
||||
|
||||
@@ -50,6 +44,7 @@ export function RichText({
|
||||
if (text.length <= 5 && /^\p{Extended_Pictographic}+$/u.test(text)) {
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={[
|
||||
{
|
||||
@@ -65,6 +60,7 @@ export function RichText({
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
numberOfLines={numberOfLines}
|
||||
@@ -81,6 +77,7 @@ export function RichText({
|
||||
for (const segment of richText.segments()) {
|
||||
const link = segment.link
|
||||
const mention = segment.mention
|
||||
const tag = segment.tag
|
||||
if (
|
||||
mention &&
|
||||
AppBskyRichtextFacet.validateMention(mention).success &&
|
||||
@@ -88,6 +85,7 @@ export function RichText({
|
||||
) {
|
||||
els.push(
|
||||
<InlineLink
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={`/profile/${mention.did}`}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
@@ -102,16 +100,32 @@ export function RichText({
|
||||
} else {
|
||||
els.push(
|
||||
<InlineLink
|
||||
selectable={selectable}
|
||||
key={key}
|
||||
to={link.uri}
|
||||
style={[...styles, {pointerEvents: 'auto'}]}
|
||||
// @ts-ignore TODO
|
||||
dataSet={WORD_WRAP}
|
||||
warnOnMismatchingLabel>
|
||||
dataSet={WORD_WRAP}>
|
||||
{toShortUrl(segment.text)}
|
||||
</InlineLink>,
|
||||
)
|
||||
}
|
||||
} else if (
|
||||
!disableLinks &&
|
||||
enableTags &&
|
||||
tag &&
|
||||
AppBskyRichtextFacet.validateTag(tag).success
|
||||
) {
|
||||
els.push(
|
||||
<RichTextTag
|
||||
key={key}
|
||||
text={segment.text}
|
||||
tag={tag.tag}
|
||||
style={styles}
|
||||
selectable={selectable}
|
||||
authorHandle={authorHandle}
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
els.push(segment.text)
|
||||
}
|
||||
@@ -120,6 +134,7 @@ export function RichText({
|
||||
|
||||
return (
|
||||
<Text
|
||||
selectable={selectable}
|
||||
testID={testID}
|
||||
style={styles}
|
||||
numberOfLines={numberOfLines}
|
||||
@@ -129,3 +144,81 @@ export function RichText({
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function RichTextTag({
|
||||
text,
|
||||
tag,
|
||||
style,
|
||||
selectable,
|
||||
authorHandle,
|
||||
}: {
|
||||
text: string
|
||||
tag: string
|
||||
selectable?: boolean
|
||||
authorHandle?: string
|
||||
} & TextStyleProp) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const control = useTagMenuControl()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHoverIn,
|
||||
onOut: onHoverOut,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const {
|
||||
state: pressed,
|
||||
onIn: onPressIn,
|
||||
onOut: onPressOut,
|
||||
} = useInteractionState()
|
||||
|
||||
const open = React.useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
|
||||
/*
|
||||
* N.B. On web, this is wrapped in another pressable comopnent with a11y
|
||||
* labels, etc. That's why only some of these props are applied here.
|
||||
*/
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
|
||||
<Text
|
||||
selectable={selectable}
|
||||
{...native({
|
||||
accessibilityLabel: _(msg`Hashtag: #${tag}`),
|
||||
accessibilityHint: _(msg`Click here to open tag menu for #${tag}`),
|
||||
accessibilityRole: isNative ? 'button' : undefined,
|
||||
onPress: open,
|
||||
onPressIn: onPressIn,
|
||||
onPressOut: onPressOut,
|
||||
})}
|
||||
{...web({
|
||||
onMouseEnter: onHoverIn,
|
||||
onMouseLeave: onHoverOut,
|
||||
})}
|
||||
// @ts-ignore
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
pointerEvents: 'auto',
|
||||
color: t.palette.primary_500,
|
||||
},
|
||||
web({
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
(hovered || focused || pressed) && {
|
||||
...web({outline: 0}),
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationColor: t.palette.primary_500,
|
||||
},
|
||||
]}>
|
||||
{text}
|
||||
</Text>
|
||||
</TagMenu>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2'
|
||||
import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
|
||||
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Link} from '#/components/Link'
|
||||
import {makeSearchLink} from '#/lib/routes/links'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
|
||||
export function useTagMenuControl() {
|
||||
return Dialog.useDialogControl()
|
||||
}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
control,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
/**
|
||||
* This should be the sanitized tag value from the facet itself, not the
|
||||
* "display" value with a leading `#`.
|
||||
*/
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {isLoading: isPreferencesLoading, data: preferences} =
|
||||
usePreferencesQuery()
|
||||
const {
|
||||
mutateAsync: upsertMutedWord,
|
||||
variables: optimisticUpsert,
|
||||
reset: resetUpsert,
|
||||
} = useUpsertMutedWordsMutation()
|
||||
const {
|
||||
mutateAsync: removeMutedWord,
|
||||
variables: optimisticRemove,
|
||||
reset: resetRemove,
|
||||
} = useRemoveMutedWordMutation()
|
||||
const displayTag = '#' + tag
|
||||
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === tag),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
|
||||
<Dialog.Inner label={_(msg`Tag menu: ${displayTag}`)}>
|
||||
{isPreferencesLoading ? (
|
||||
<View style={[a.w_full, a.align_center]}>
|
||||
<Loader size="lg" />
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.border,
|
||||
a.mb_md,
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Link
|
||||
label={_(msg`Search for all posts with tag ${displayTag}`)}
|
||||
to={makeSearchLink({query: displayTag})}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
|
||||
control.close(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Search size="lg" style={[t.atoms.text_contrast_medium]} />
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
posts
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
|
||||
{authorHandle && !isInvalidHandle(authorHandle) && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Link
|
||||
label={_(
|
||||
msg`Search for all posts by @${authorHandle} with tag ${displayTag}`,
|
||||
)}
|
||||
to={makeSearchLink({
|
||||
query: displayTag,
|
||||
from: authorHandle,
|
||||
})}
|
||||
onPress={e => {
|
||||
e.preventDefault()
|
||||
|
||||
control.close(() => {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
author: authorHandle,
|
||||
})
|
||||
})
|
||||
|
||||
return false
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Person
|
||||
size="lg"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
See{' '}
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
posts by this user
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{preferences ? (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
label={
|
||||
isMuted
|
||||
? _(msg`Unmute all ${displayTag} posts`)
|
||||
: _(msg`Mute all ${displayTag} posts`)
|
||||
}
|
||||
onPress={() => {
|
||||
control.close(() => {
|
||||
if (isMuted) {
|
||||
resetUpsert()
|
||||
removeMutedWord({
|
||||
value: tag,
|
||||
targets: ['tag'],
|
||||
})
|
||||
} else {
|
||||
resetRemove()
|
||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
||||
}
|
||||
})
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_start,
|
||||
a.gap_md,
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
]}>
|
||||
<Mute
|
||||
size="lg"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="middle"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
native({top: 2}),
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{isMuted ? _(msg`Unmute`) : _(msg`Mute`)}{' '}
|
||||
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>
|
||||
{displayTag}
|
||||
</Text>{' '}
|
||||
<Trans>posts</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Button
|
||||
label={_(msg`Close this dialog`)}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.close()}>
|
||||
<ButtonText>Cancel</ButtonText>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Inner>
|
||||
</Dialog.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {isInvalidHandle} from '#/lib/strings/handles'
|
||||
import {EventStopper} from '#/view/com/util/EventStopper'
|
||||
import {NativeDropdown} from '#/view/com/util/forms/NativeDropdown'
|
||||
import {NavigationProp} from '#/lib/routes/types'
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {enforceLen} from '#/lib/strings/helpers'
|
||||
import {web} from '#/alf'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
export function useTagMenuControl(): Dialog.DialogControlProps {
|
||||
return {
|
||||
id: '',
|
||||
// @ts-ignore
|
||||
ref: null,
|
||||
open: () => {
|
||||
throw new Error(`TagMenu controls are only available on native platforms`)
|
||||
},
|
||||
close: () => {
|
||||
throw new Error(`TagMenu controls are only available on native platforms`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function TagMenu({
|
||||
children,
|
||||
tag,
|
||||
authorHandle,
|
||||
}: React.PropsWithChildren<{
|
||||
/**
|
||||
* This should be the sanitized tag value from the facet itself, not the
|
||||
* "display" value with a leading `#`.
|
||||
*/
|
||||
tag: string
|
||||
authorHandle?: string
|
||||
}>) {
|
||||
const {_} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const {data: preferences} = usePreferencesQuery()
|
||||
const {mutateAsync: upsertMutedWord, variables: optimisticUpsert} =
|
||||
useUpsertMutedWordsMutation()
|
||||
const {mutateAsync: removeMutedWord, variables: optimisticRemove} =
|
||||
useRemoveMutedWordMutation()
|
||||
const isMuted = Boolean(
|
||||
(preferences?.mutedWords?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
) ??
|
||||
optimisticUpsert?.find(
|
||||
m => m.value === tag && m.targets.includes('tag'),
|
||||
)) &&
|
||||
!(optimisticRemove?.value === tag),
|
||||
)
|
||||
const truncatedTag = '#' + enforceLen(tag, 15, true, 'middle')
|
||||
|
||||
const dropdownItems = React.useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: _(msg`See ${truncatedTag} posts`),
|
||||
onPress() {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSearch',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: 'magnifying-glass',
|
||||
},
|
||||
},
|
||||
authorHandle &&
|
||||
!isInvalidHandle(authorHandle) && {
|
||||
label: _(msg`See ${truncatedTag} posts by user`),
|
||||
onPress() {
|
||||
navigation.push('Hashtag', {
|
||||
tag: encodeURIComponent(tag),
|
||||
author: authorHandle,
|
||||
})
|
||||
},
|
||||
testID: 'tagMenuSeachByUser',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'magnifyingglass',
|
||||
},
|
||||
android: '',
|
||||
web: ['far', 'user'],
|
||||
},
|
||||
},
|
||||
preferences && {
|
||||
label: 'separator',
|
||||
},
|
||||
preferences && {
|
||||
label: isMuted
|
||||
? _(msg`Unmute ${truncatedTag}`)
|
||||
: _(msg`Mute ${truncatedTag}`),
|
||||
onPress() {
|
||||
if (isMuted) {
|
||||
removeMutedWord({value: tag, targets: ['tag']})
|
||||
} else {
|
||||
upsertMutedWord([{value: tag, targets: ['tag']}])
|
||||
}
|
||||
},
|
||||
testID: 'tagMenuMute',
|
||||
icon: {
|
||||
ios: {
|
||||
name: 'speaker.slash',
|
||||
},
|
||||
android: 'ic_menu_sort_alphabetically',
|
||||
web: isMuted ? 'eye' : ['far', 'eye-slash'],
|
||||
},
|
||||
},
|
||||
].filter(Boolean)
|
||||
}, [
|
||||
_,
|
||||
authorHandle,
|
||||
isMuted,
|
||||
navigation,
|
||||
preferences,
|
||||
tag,
|
||||
truncatedTag,
|
||||
upsertMutedWord,
|
||||
removeMutedWord,
|
||||
])
|
||||
|
||||
return (
|
||||
<EventStopper>
|
||||
<NativeDropdown
|
||||
accessibilityLabel={_(msg`Click here to open tag menu for ${tag}`)}
|
||||
accessibilityHint=""
|
||||
// @ts-ignore
|
||||
items={dropdownItems}
|
||||
triggerStyle={web({
|
||||
textAlign: 'left',
|
||||
})}>
|
||||
{children}
|
||||
</NativeDropdown>
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,16 @@
|
||||
import React from 'react'
|
||||
import {Text as RNText, TextStyle, TextProps} from 'react-native'
|
||||
import {Text as RNText, TextStyle, TextProps as RNTextProps} from 'react-native'
|
||||
import {UITextView} from 'react-native-ui-text-view'
|
||||
|
||||
import {useTheme, atoms, web, flatten} from '#/alf'
|
||||
import {isIOS} from '#/platform/detection'
|
||||
|
||||
export type TextProps = RNTextProps & {
|
||||
/**
|
||||
* Lets the user select text, to use the native copy and paste functionality.
|
||||
*/
|
||||
selectable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Util to calculate lineHeight from a text size atom and a leading atom
|
||||
@@ -44,27 +53,24 @@ function normalizeTextStyles(styles: TextStyle[]) {
|
||||
/**
|
||||
* Our main text component. Use this most of the time.
|
||||
*/
|
||||
export function Text({style, ...rest}: TextProps) {
|
||||
export function Text({style, selectable, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)])
|
||||
return <RNText style={s} {...rest} />
|
||||
return selectable && isIOS ? (
|
||||
<UITextView style={s} {...rest} />
|
||||
) : (
|
||||
<RNText selectable={selectable} style={s} {...rest} />
|
||||
)
|
||||
}
|
||||
|
||||
export function createHeadingElement({level}: {level: number}) {
|
||||
return function HeadingElement({style, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const attr =
|
||||
web({
|
||||
role: 'heading',
|
||||
'aria-level': level,
|
||||
}) || {}
|
||||
return (
|
||||
<RNText
|
||||
{...attr}
|
||||
{...rest}
|
||||
style={normalizeTextStyles([t.atoms.text, flatten(style)])}
|
||||
/>
|
||||
)
|
||||
return <Text {...attr} {...rest} style={style} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,21 +84,15 @@ export const H4 = createHeadingElement({level: 4})
|
||||
export const H5 = createHeadingElement({level: 5})
|
||||
export const H6 = createHeadingElement({level: 6})
|
||||
export function P({style, ...rest}: TextProps) {
|
||||
const t = useTheme()
|
||||
const attr =
|
||||
web({
|
||||
role: 'paragraph',
|
||||
}) || {}
|
||||
return (
|
||||
<RNText
|
||||
<Text
|
||||
{...attr}
|
||||
{...rest}
|
||||
style={normalizeTextStyles([
|
||||
atoms.text_md,
|
||||
atoms.leading_normal,
|
||||
t.atoms.text,
|
||||
flatten(style),
|
||||
])}
|
||||
style={[atoms.text_md, atoms.leading_normal, flatten(style)]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
|
||||
type Control = Dialog.DialogOuterProps['control']
|
||||
|
||||
type ControlsContext = {
|
||||
mutedWordsDialogControl: Control
|
||||
}
|
||||
|
||||
const ControlsContext = React.createContext({
|
||||
mutedWordsDialogControl: {} as Control,
|
||||
})
|
||||
|
||||
export function useGlobalDialogsControlContext() {
|
||||
return React.useContext(ControlsContext)
|
||||
}
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const mutedWordsDialogControl = Dialog.useDialogControl()
|
||||
const ctx = React.useMemo(
|
||||
() => ({mutedWordsDialogControl}),
|
||||
[mutedWordsDialogControl],
|
||||
)
|
||||
|
||||
return (
|
||||
<ControlsContext.Provider value={ctx}>{children}</ControlsContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import React from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {AppBskyActorDefs, sanitizeMutedWordValue} from '@atproto/api'
|
||||
|
||||
import {
|
||||
usePreferencesQuery,
|
||||
useUpsertMutedWordsMutation,
|
||||
useRemoveMutedWordMutation,
|
||||
} from '#/state/queries/preferences'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {
|
||||
atoms as a,
|
||||
useTheme,
|
||||
useBreakpoints,
|
||||
ViewStyleProp,
|
||||
web,
|
||||
native,
|
||||
} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||
import {PageText_Stroke2_Corner0_Rounded as PageText} from '#/components/icons/PageText'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {logger} from '#/logger'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
|
||||
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
|
||||
|
||||
export function MutedWordsDialog() {
|
||||
const {mutedWordsDialogControl: control} = useGlobalDialogsControlContext()
|
||||
return (
|
||||
<Dialog.Outer control={control}>
|
||||
<Dialog.Handle />
|
||||
<MutedWordsInner control={control} />
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordsInner({}: {control: Dialog.DialogOuterProps['control']}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {
|
||||
isLoading: isPreferencesLoading,
|
||||
data: preferences,
|
||||
error: preferencesError,
|
||||
} = usePreferencesQuery()
|
||||
const {isPending, mutateAsync: addMutedWord} = useUpsertMutedWordsMutation()
|
||||
const [field, setField] = React.useState('')
|
||||
const [options, setOptions] = React.useState(['content'])
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
const submit = React.useCallback(async () => {
|
||||
const sanitizedValue = sanitizeMutedWordValue(field)
|
||||
const targets = ['tag', options.includes('content') && 'content'].filter(
|
||||
Boolean,
|
||||
) as AppBskyActorDefs.MutedWord['targets']
|
||||
|
||||
if (!sanitizedValue || !targets.length) {
|
||||
setField('')
|
||||
setError(_(msg`Please enter a valid word, tag, or phrase to mute`))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// send raw value and rely on SDK as sanitization source of truth
|
||||
await addMutedWord([{value: field, targets}])
|
||||
setField('')
|
||||
} catch (e: any) {
|
||||
logger.error(`Failed to save muted word`, {message: e.message})
|
||||
setError(e.message)
|
||||
}
|
||||
}, [_, field, options, addMutedWord, setField])
|
||||
|
||||
return (
|
||||
<Dialog.ScrollableInner label={_(msg`Manage your muted words and tags`)}>
|
||||
<View onTouchStart={Keyboard.dismiss}>
|
||||
<Text
|
||||
style={[a.text_md, a.font_bold, a.pb_sm, t.atoms.text_contrast_high]}>
|
||||
<Trans>Add muted words and tags</Trans>
|
||||
</Text>
|
||||
<Text style={[a.pb_lg, a.leading_snug, t.atoms.text_contrast_medium]}>
|
||||
<Trans>
|
||||
Posts can be muted based on their text, their tags, or both.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<View style={[a.pb_lg]}>
|
||||
<Dialog.Input
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
label={_(msg`Enter a word or tag`)}
|
||||
placeholder={_(msg`Enter a word or tag`)}
|
||||
value={field}
|
||||
onChangeText={value => {
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
setField(value)
|
||||
}}
|
||||
onSubmitEditing={submit}
|
||||
/>
|
||||
|
||||
<Toggle.Group
|
||||
label={_(msg`Toggle between muted word options.`)}
|
||||
type="radio"
|
||||
values={options}
|
||||
onChange={setOptions}>
|
||||
<View
|
||||
style={[
|
||||
a.pt_sm,
|
||||
a.py_sm,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.gap_sm,
|
||||
a.flex_wrap,
|
||||
]}>
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in post text and tags`)}
|
||||
name="content"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Trans>Mute in text & tags</Trans>
|
||||
</Toggle.Label>
|
||||
</View>
|
||||
<PageText size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Toggle.Item
|
||||
label={_(msg`Mute this word in tags only`)}
|
||||
name="tag"
|
||||
style={[a.flex_1, !gtMobile && [a.w_full, a.flex_0]]}>
|
||||
<TargetToggle>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Toggle.Radio />
|
||||
<Toggle.Label>
|
||||
<Trans>Mute in tags only</Trans>
|
||||
</Toggle.Label>
|
||||
</View>
|
||||
<Hashtag size="sm" />
|
||||
</TargetToggle>
|
||||
</Toggle.Item>
|
||||
|
||||
<Button
|
||||
disabled={isPending || !field}
|
||||
label={_(msg`Add mute word for configured settings`)}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="solid"
|
||||
style={[!gtMobile && [a.w_full, a.flex_0]]}
|
||||
onPress={submit}>
|
||||
<ButtonText>
|
||||
<Trans>Add</Trans>
|
||||
</ButtonText>
|
||||
<ButtonIcon icon={isPending ? Loader : Plus} />
|
||||
</Button>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
|
||||
{error && (
|
||||
<View
|
||||
style={[
|
||||
a.mb_lg,
|
||||
a.flex_row,
|
||||
a.rounded_sm,
|
||||
a.p_md,
|
||||
a.mb_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
backgroundColor: t.palette.negative_400,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.italic,
|
||||
{color: t.palette.white},
|
||||
native({marginTop: 2}),
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text
|
||||
style={[
|
||||
a.pt_xs,
|
||||
a.text_sm,
|
||||
a.italic,
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
We recommend avoiding common words that appear in many posts,
|
||||
since it can result in no posts being shown.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View style={[a.pt_2xl]}>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.pb_md,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>Your muted words</Trans>
|
||||
</Text>
|
||||
|
||||
{isPreferencesLoading ? (
|
||||
<Loader />
|
||||
) : preferencesError || !preferences ? (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>
|
||||
We're sorry, but we weren't able to load your muted words at
|
||||
this time. Please try again.
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : preferences.mutedWords.length ? (
|
||||
[...preferences.mutedWords]
|
||||
.reverse()
|
||||
.map((word, i) => (
|
||||
<MutedWordRow
|
||||
key={word.value + i}
|
||||
word={word}
|
||||
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View
|
||||
style={[a.py_md, a.px_lg, a.rounded_md, t.atoms.bg_contrast_25]}>
|
||||
<Text style={[a.italic, t.atoms.text_contrast_high]}>
|
||||
<Trans>You haven't muted any words or tags yet</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{isNative && <View style={{height: 20}} />}
|
||||
|
||||
<Dialog.Close />
|
||||
</View>
|
||||
</Dialog.ScrollableInner>
|
||||
)
|
||||
}
|
||||
|
||||
function MutedWordRow({
|
||||
style,
|
||||
word,
|
||||
}: ViewStyleProp & {word: AppBskyActorDefs.MutedWord}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {isPending, mutateAsync: removeMutedWord} = useRemoveMutedWordMutation()
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const remove = React.useCallback(async () => {
|
||||
control.close()
|
||||
removeMutedWord(word)
|
||||
}, [removeMutedWord, word, control])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Title>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</Prompt.Title>
|
||||
<Prompt.Description>
|
||||
<Trans>
|
||||
This will delete {word.value} from your muted words. You can always
|
||||
add it back later.
|
||||
</Trans>
|
||||
</Prompt.Description>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Cancel>
|
||||
<ButtonText>
|
||||
<Trans>Nevermind</Trans>
|
||||
</ButtonText>
|
||||
</Prompt.Cancel>
|
||||
<Prompt.Action onPress={remove}>
|
||||
<ButtonText>
|
||||
<Trans>Remove</Trans>
|
||||
</ButtonText>
|
||||
</Prompt.Action>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.py_md,
|
||||
a.px_lg,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.rounded_md,
|
||||
a.gap_md,
|
||||
style,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.leading_snug,
|
||||
a.w_full,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_high,
|
||||
web({
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}),
|
||||
]}>
|
||||
{word.value}
|
||||
</Text>
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.justify_end, a.gap_sm]}>
|
||||
{word.targets.map(target => (
|
||||
<View
|
||||
key={target}
|
||||
style={[a.py_xs, a.px_sm, a.rounded_sm, t.atoms.bg_contrast_100]}>
|
||||
<Text
|
||||
style={[a.text_xs, a.font_bold, t.atoms.text_contrast_medium]}>
|
||||
{target === 'content' ? _(msg`text`) : _(msg`tag`)}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Button
|
||||
label={_(msg`Remove mute word from your list`)}
|
||||
size="tiny"
|
||||
shape="round"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
onPress={() => control.open()}
|
||||
style={[a.ml_sm]}>
|
||||
<ButtonIcon icon={isPending ? Loader : X} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TargetToggle({children}: React.PropsWithChildren<{}>) {
|
||||
const t = useTheme()
|
||||
const ctx = Toggle.useItemContext()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
a.gap_xs,
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
a.px_sm,
|
||||
gtMobile && a.px_md,
|
||||
a.rounded_sm,
|
||||
t.atoms.bg_contrast_50,
|
||||
(ctx.hovered || ctx.focused) && t.atoms.bg_contrast_100,
|
||||
ctx.selected && [
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.primary_50 : t.palette.primary_975,
|
||||
},
|
||||
],
|
||||
ctx.disabled && {
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
import React from 'react'
|
||||
import {View, Pressable} from 'react-native'
|
||||
import DateTimePicker, {
|
||||
BaseProps as DateTimePickerProps,
|
||||
} from '@react-native-community/datetimepicker'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
@@ -15,6 +12,8 @@ import {
|
||||
localizeDate,
|
||||
toSimpleDateString,
|
||||
} from '#/components/forms/DateField/utils'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
@@ -38,20 +37,20 @@ export function DateField({
|
||||
const {chromeFocus, chromeError, chromeErrorHover} =
|
||||
TextField.useSharedInputStyles()
|
||||
|
||||
const onChangeInternal = React.useCallback<
|
||||
Required<DateTimePickerProps>['onChange']
|
||||
>(
|
||||
(_event, date) => {
|
||||
const onChangeInternal = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false)
|
||||
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
},
|
||||
[onChangeDate, setOpen],
|
||||
)
|
||||
|
||||
const onCancel = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<Pressable
|
||||
@@ -89,18 +88,18 @@ export function DateField({
|
||||
</Pressable>
|
||||
|
||||
{open && (
|
||||
<DateTimePicker
|
||||
<DatePicker
|
||||
modal={isAndroid}
|
||||
open={isAndroid}
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onConfirm={onChangeInternal}
|
||||
onCancel={onCancel}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
testID={`${testID}-datepicker`}
|
||||
mode="date"
|
||||
timeZoneName={'Etc/UTC'}
|
||||
display="spinner"
|
||||
// @ts-ignore applies in iOS only -prf
|
||||
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
|
||||
value={new Date(value)}
|
||||
onChange={onChangeInternal}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import DateTimePicker, {
|
||||
DateTimePickerEvent,
|
||||
} from '@react-native-community/datetimepicker'
|
||||
|
||||
import {useTheme, atoms} from '#/alf'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import {toSimpleDateString} from '#/components/forms/DateField/utils'
|
||||
import {DateFieldProps} from '#/components/forms/DateField/types'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
|
||||
export * as utils from '#/components/forms/DateField/utils'
|
||||
export const Label = TextField.Label
|
||||
@@ -28,7 +26,7 @@ export function DateField({
|
||||
const t = useTheme()
|
||||
|
||||
const onChangeInternal = React.useCallback(
|
||||
(event: DateTimePickerEvent, date: Date | undefined) => {
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
@@ -39,17 +37,15 @@ export function DateField({
|
||||
|
||||
return (
|
||||
<View style={[atoms.relative, atoms.w_full]}>
|
||||
<DateTimePicker
|
||||
<DatePicker
|
||||
theme={t.name === 'light' ? 'light' : 'dark'}
|
||||
date={new Date(value)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
testID={`${testID}-datepicker`}
|
||||
aria-label={label}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={undefined}
|
||||
testID={`${testID}-datepicker`}
|
||||
mode="date"
|
||||
timeZoneName={'Etc/UTC'}
|
||||
display="spinner"
|
||||
themeVariant={t.name === 'dark' ? 'dark' : 'light'}
|
||||
value={new Date(value)}
|
||||
onChange={onChangeInternal}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'react-native'
|
||||
|
||||
import {HITSLOP_20} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, tokens, android} from '#/alf'
|
||||
import {useTheme, atoms as a, web, android} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {Props as SVGIconProps} from '#/components/icons/common'
|
||||
@@ -72,7 +72,7 @@ export function Root({children, isInvalid = false}: RootProps) {
|
||||
return (
|
||||
<Context.Provider value={context}>
|
||||
<View
|
||||
style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
|
||||
style={[a.flex_row, a.align_center, a.relative, a.flex_1, a.px_md]}
|
||||
{...web({
|
||||
onClick: () => inputRef.current?.focus(),
|
||||
onMouseOver: onHoverIn,
|
||||
@@ -110,7 +110,7 @@ export function useSharedInputStyles() {
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor: tokens.color.red_500,
|
||||
borderColor: t.palette.negative_500,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {HITSLOP_10} from 'lib/constants'
|
||||
import {useTheme, atoms as a, web, native, flatten, ViewStyleProp} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {CheckThick_Stroke2_Corner0_Rounded as Checkmark} from '#/components/icons/Check'
|
||||
|
||||
export type ItemState = {
|
||||
name: string
|
||||
@@ -300,7 +301,7 @@ export function createSharedToggleStyles({
|
||||
if (isInvalid) {
|
||||
base.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_975,
|
||||
borderColor:
|
||||
t.name === 'light' ? t.palette.negative_300 : t.palette.negative_800,
|
||||
})
|
||||
@@ -309,7 +310,7 @@ export function createSharedToggleStyles({
|
||||
baseHover.push({
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.negative_25 : t.palette.negative_900,
|
||||
borderColor: t.palette.negative_500,
|
||||
borderColor: t.palette.negative_600,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -331,15 +332,14 @@ export function createSharedToggleStyles({
|
||||
export function Checkbox() {
|
||||
const t = useTheme()
|
||||
const {selected, hovered, focused, disabled, isInvalid} = useItemContext()
|
||||
const {baseStyles, baseHoverStyles, indicatorStyles} =
|
||||
createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
const {baseStyles, baseHoverStyles} = createSharedToggleStyles({
|
||||
theme: t,
|
||||
hovered,
|
||||
focused,
|
||||
selected,
|
||||
disabled,
|
||||
isInvalid,
|
||||
})
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -355,21 +355,7 @@ export function Checkbox() {
|
||||
baseStyles,
|
||||
hovered || focused ? baseHoverStyles : {},
|
||||
]}>
|
||||
{selected ? (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_2xs,
|
||||
{height: 12, width: 12},
|
||||
selected
|
||||
? {
|
||||
backgroundColor: t.palette.primary_500,
|
||||
}
|
||||
: {},
|
||||
indicatorStyles,
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{selected ? <Checkmark size="xs" fill={t.palette.primary_500} /> : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
|
||||
export const Check_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
|
||||
export const CheckThick_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Clipboard_Stroke2_Corner2_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Group3_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M17 16H21.1456C20.8246 11.4468 17.7199 9.48509 15.0001 10.1147M10 4C10 5.65685 8.65685 7 7 7C5.34315 7 4 5.65685 4 4C4 2.34315 5.34315 1 7 1C8.65685 1 10 2.34315 10 4ZM18.5 4.5C18.5 5.88071 17.3807 7 16 7C14.6193 7 13.5 5.88071 13.5 4.5C13.5 3.11929 14.6193 2 16 2C17.3807 2 18.5 3.11929 18.5 4.5ZM1 17H13C12.3421 7.66667 1.65792 7.66667 1 17Z',
|
||||
path: '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',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const MagnifyingGlass2_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Mute_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const PageText_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Person_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: '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',
|
||||
})
|
||||
@@ -0,0 +1,692 @@
|
||||
import {describe, it, expect} from '@jest/globals'
|
||||
import {RichText} from '@atproto/api'
|
||||
|
||||
import {hasMutedWord} from '../moderatePost_wrapped'
|
||||
|
||||
describe(`hasMutedWord`, () => {
|
||||
describe(`tags`, () => {
|
||||
it(`match: outline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'outlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: content target matches inline tag`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post #inlineTag`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: ['outlineTag'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: only tag targets`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'inlineTag', targets: ['tag']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`early exits`, () => {
|
||||
it(`match: single character 希`, () => {
|
||||
/**
|
||||
* @see https://bsky.app/profile/mukuuji.bsky.social/post/3klji4fvsdk2c
|
||||
*/
|
||||
const rt = new RichText({
|
||||
text: `改善希望です`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: '希', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: long muted word, short post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `hey`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'politics', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: exact text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`general content`, () => {
|
||||
it(`match: word within post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `This is a post about javascript`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'javascript', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: partial word`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your brain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'ai', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: multiline`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Use your\n\tbrain, Eric`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'brain', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: :)`, () => {
|
||||
const rt = new RichText({
|
||||
text: `So happy :)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `:)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`punctuation semi-fuzzy`, () => {
|
||||
describe(`yay!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, yay!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: yay!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: yay`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'yay', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`y!ppee!!`, () => {
|
||||
const rt = new RichText({
|
||||
text: `We're federating, y!ppee!!`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: y!ppee`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
// single exclamation point, source has double
|
||||
it(`no match: y!ppee!`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'y!ppee!', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`Why so S@assy?`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Why so S@assy?`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: S@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'S@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: s@assy`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 's@assy', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`New York Times`, () => {
|
||||
const rt = new RichText({
|
||||
text: `New York Times`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// case insensitive
|
||||
it(`match: new york times`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'new york times', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`!command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot !command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: !command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: command`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: !command`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Idk maybe a bot command`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `!command`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`e/acc`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm e/acc pilled`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: e/acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `e/acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: acc`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `acc`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`super-bad`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I'm super-bad`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: super-bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super-bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: super bad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `super bad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: superbad`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `superbad`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`idk_what_this_would_be`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Weird post with idk_what_this_would_be`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: idk what this would be`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk what this would be`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`no match: idk what this would be for`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [
|
||||
{value: `idk what this would be for`, targets: ['content']},
|
||||
],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`match: idk`, () => {
|
||||
// extra word
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: idkwhatthiswouldbe`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `idkwhatthiswouldbe`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`parentheses`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with context(iykyk)`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: context(iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: context`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `context`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: iykyk`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `iykyk`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: (iykyk)`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `(iykyk)`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe(`🦋`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Post with 🦋`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: 🦋`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: `🦋`, targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`phrases`, () => {
|
||||
describe(`I like turtles, or how I learned to stop worrying and love the internet.`, () => {
|
||||
const rt = new RichText({
|
||||
text: `I like turtles, or how I learned to stop worrying and love the internet.`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
it(`match: stop worrying`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'stop worrying', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`match: turtles, or how`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'turtles, or how', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`languages without spaces`, () => {
|
||||
// I love turtles, or how I learned to stop worrying and love the internet
|
||||
describe(`私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`, () => {
|
||||
const rt = new RichText({
|
||||
text: `私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか`,
|
||||
})
|
||||
rt.detectFacetsWithoutResolution()
|
||||
|
||||
// internet
|
||||
it(`match: インターネット`, () => {
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'インターネット', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
languages: ['ja'],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe(`doesn't mute own post`, () => {
|
||||
it(`does mute if it isn't own post`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: false,
|
||||
})
|
||||
|
||||
expect(match).toBe(true)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in text`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['content']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
|
||||
it(`doesn't mute own post when muted word is in tags`, () => {
|
||||
const rt = new RichText({
|
||||
text: `Mute #words!`,
|
||||
})
|
||||
|
||||
const match = hasMutedWord({
|
||||
mutedWords: [{value: 'words', targets: ['tags']}],
|
||||
text: rt.text,
|
||||
facets: rt.facets,
|
||||
outlineTags: [],
|
||||
isOwnPost: true,
|
||||
})
|
||||
|
||||
expect(match).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -104,18 +104,18 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
|
||||
// add image embed if present
|
||||
if (opts.images?.length) {
|
||||
logger.info(`Uploading images`, {
|
||||
logger.debug(`Uploading images`, {
|
||||
count: opts.images.length,
|
||||
})
|
||||
|
||||
const images: AppBskyEmbedImages.Image[] = []
|
||||
for (const image of opts.images) {
|
||||
opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
|
||||
logger.info(`Compressing image`)
|
||||
logger.debug(`Compressing image`)
|
||||
await image.compress()
|
||||
const path = image.compressed?.path ?? image.path
|
||||
const {width, height} = image.compressed || image
|
||||
logger.info(`Uploading image`)
|
||||
logger.debug(`Uploading image`)
|
||||
const res = await uploadBlob(agent, path, 'image/jpeg')
|
||||
images.push({
|
||||
image: res.data.blob,
|
||||
|
||||
+13
-90
@@ -3,9 +3,8 @@ import {Insets, Platform} from 'react-native'
|
||||
export const LOCAL_DEV_SERVICE =
|
||||
Platform.OS === 'android' ? 'http://10.0.2.2:2583' : 'http://localhost:2583'
|
||||
export const STAGING_SERVICE = 'https://staging.bsky.dev'
|
||||
export const PROD_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = PROD_SERVICE
|
||||
|
||||
export const BSKY_SERVICE = 'https://bsky.social'
|
||||
export const DEFAULT_SERVICE = BSKY_SERVICE
|
||||
const HELP_DESK_LANG = 'en-us'
|
||||
export const HELP_DESK_URL = `https://blueskyweb.zendesk.com/hc/${HELP_DESK_LANG}`
|
||||
|
||||
@@ -36,92 +35,12 @@ export const MAX_GRAPHEME_LENGTH = 300
|
||||
// but increasing limit per user feedback
|
||||
export const MAX_ALT_TEXT = 1000
|
||||
|
||||
export function IS_LOCAL_DEV(url: string) {
|
||||
return url.includes('localhost')
|
||||
export function IS_PROD_SERVICE(url?: string) {
|
||||
return url && url !== STAGING_SERVICE && url !== LOCAL_DEV_SERVICE
|
||||
}
|
||||
|
||||
export function IS_STAGING(url: string) {
|
||||
return url.startsWith('https://staging.bsky.dev')
|
||||
}
|
||||
|
||||
export function IS_PROD(url: string) {
|
||||
// NOTE
|
||||
// until open federation, "production" is defined as the main server
|
||||
// this definition will not work once federation is enabled!
|
||||
// -prf
|
||||
return (
|
||||
url.startsWith('https://bsky.social') ||
|
||||
url.startsWith('https://api.bsky.app') ||
|
||||
/bsky\.network\/?$/.test(url)
|
||||
)
|
||||
}
|
||||
|
||||
export const PROD_TEAM_HANDLES = [
|
||||
'jay.bsky.social',
|
||||
'pfrazee.com',
|
||||
'divy.zone',
|
||||
'dholms.xyz',
|
||||
'why.bsky.world',
|
||||
'iamrosewang.bsky.social',
|
||||
]
|
||||
export const STAGING_TEAM_HANDLES = [
|
||||
'arcalinea.staging.bsky.dev',
|
||||
'paul.staging.bsky.dev',
|
||||
'paul2.staging.bsky.dev',
|
||||
]
|
||||
export const DEV_TEAM_HANDLES = ['alice.test', 'bob.test', 'carla.test']
|
||||
|
||||
export function TEAM_HANDLES(serviceUrl: string) {
|
||||
if (serviceUrl.includes('localhost')) {
|
||||
return DEV_TEAM_HANDLES
|
||||
} else if (serviceUrl.includes('staging')) {
|
||||
return STAGING_TEAM_HANDLES
|
||||
} else {
|
||||
return PROD_TEAM_HANDLES
|
||||
}
|
||||
}
|
||||
|
||||
export const STAGING_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:wqzurwm3kmaig6e6hnc2gqwo/app.bsky.feed.generator/${rkey}`
|
||||
export const PROD_DEFAULT_FEED = (rkey: string) =>
|
||||
`at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/${rkey}`
|
||||
export async function DEFAULT_FEEDS(
|
||||
serviceUrl: string,
|
||||
resolveHandle: (name: string) => Promise<string>,
|
||||
) {
|
||||
// TODO: remove this when the test suite no longer relies on it
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
// local dev
|
||||
const aliceDid = await resolveHandle('alice.test')
|
||||
return {
|
||||
pinned: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
saved: [
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs`,
|
||||
`at://${aliceDid}/app.bsky.feed.generator/alice-favs2`,
|
||||
],
|
||||
}
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
// staging
|
||||
return {
|
||||
pinned: [STAGING_DEFAULT_FEED('whats-hot')],
|
||||
saved: [
|
||||
STAGING_DEFAULT_FEED('bsky-team'),
|
||||
STAGING_DEFAULT_FEED('with-friends'),
|
||||
STAGING_DEFAULT_FEED('whats-hot'),
|
||||
STAGING_DEFAULT_FEED('hot-classic'),
|
||||
],
|
||||
}
|
||||
} else {
|
||||
// production
|
||||
return {
|
||||
pinned: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
saved: [PROD_DEFAULT_FEED('whats-hot')],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const POST_IMG_MAX = {
|
||||
width: 2000,
|
||||
@@ -135,13 +54,11 @@ export const STAGING_LINK_META_PROXY =
|
||||
export const PROD_LINK_META_PROXY = 'https://cardyb.bsky.app/v1/extract?url='
|
||||
|
||||
export function LINK_META_PROXY(serviceUrl: string) {
|
||||
if (IS_LOCAL_DEV(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else if (IS_STAGING(serviceUrl)) {
|
||||
return STAGING_LINK_META_PROXY
|
||||
} else {
|
||||
if (IS_PROD_SERVICE(serviceUrl)) {
|
||||
return PROD_LINK_META_PROXY
|
||||
}
|
||||
|
||||
return STAGING_LINK_META_PROXY
|
||||
}
|
||||
|
||||
export const STATUS_PAGE_URL = 'https://status.bsky.app/'
|
||||
@@ -158,3 +75,9 @@ export const HITSLOP_20 = createHitslop(20)
|
||||
export const HITSLOP_30 = createHitslop(30)
|
||||
export const BACK_HITSLOP = HITSLOP_30
|
||||
export const MAX_POST_LINES = 25
|
||||
|
||||
export const BSKY_FEED_OWNER_DIDS = [
|
||||
'did:plc:z72i7hdynmk6r22z27h6tvur',
|
||||
'did:plc:vpkhqolt662uhesyj6nxm7ys',
|
||||
'did:plc:q6gjnaw2blty4crticxkmujt',
|
||||
]
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import {CountryCode} from 'libphonenumber-js'
|
||||
|
||||
// ISO 3166-1 alpha-2 codes
|
||||
|
||||
export interface CountryCodeMap {
|
||||
code2: CountryCode
|
||||
name: string
|
||||
}
|
||||
|
||||
export const COUNTRY_CODES: CountryCodeMap[] = [
|
||||
{code2: 'AF', name: 'Afghanistan (+93)'},
|
||||
{code2: 'AX', name: 'Åland Islands (+358)'},
|
||||
{code2: 'AL', name: 'Albania (+355)'},
|
||||
{code2: 'DZ', name: 'Algeria (+213)'},
|
||||
{code2: 'AS', name: 'American Samoa (+1)'},
|
||||
{code2: 'AD', name: 'Andorra (+376)'},
|
||||
{code2: 'AO', name: 'Angola (+244)'},
|
||||
{code2: 'AI', name: 'Anguilla (+1)'},
|
||||
{code2: 'AG', name: 'Antigua and Barbuda (+1)'},
|
||||
{code2: 'AR', name: 'Argentina (+54)'},
|
||||
{code2: 'AM', name: 'Armenia (+374)'},
|
||||
{code2: 'AW', name: 'Aruba (+297)'},
|
||||
{code2: 'AU', name: 'Australia (+61)'},
|
||||
{code2: 'AT', name: 'Austria (+43)'},
|
||||
{code2: 'AZ', name: 'Azerbaijan (+994)'},
|
||||
{code2: 'BS', name: 'Bahamas (+1)'},
|
||||
{code2: 'BH', name: 'Bahrain (+973)'},
|
||||
{code2: 'BD', name: 'Bangladesh (+880)'},
|
||||
{code2: 'BB', name: 'Barbados (+1)'},
|
||||
{code2: 'BY', name: 'Belarus (+375)'},
|
||||
{code2: 'BE', name: 'Belgium (+32)'},
|
||||
{code2: 'BZ', name: 'Belize (+501)'},
|
||||
{code2: 'BJ', name: 'Benin (+229)'},
|
||||
{code2: 'BM', name: 'Bermuda (+1)'},
|
||||
{code2: 'BT', name: 'Bhutan (+975)'},
|
||||
{code2: 'BO', name: 'Bolivia (Plurinational State of) (+591)'},
|
||||
{code2: 'BQ', name: 'Bonaire, Sint Eustatius and Saba (+599)'},
|
||||
{code2: 'BA', name: 'Bosnia and Herzegovina (+387)'},
|
||||
{code2: 'BW', name: 'Botswana (+267)'},
|
||||
{code2: 'BR', name: 'Brazil (+55)'},
|
||||
{code2: 'IO', name: 'British Indian Ocean Territory (+246)'},
|
||||
{code2: 'BN', name: 'Brunei Darussalam (+673)'},
|
||||
{code2: 'BG', name: 'Bulgaria (+359)'},
|
||||
{code2: 'BF', name: 'Burkina Faso (+226)'},
|
||||
{code2: 'BI', name: 'Burundi (+257)'},
|
||||
{code2: 'CV', name: 'Cabo Verde (+238)'},
|
||||
{code2: 'KH', name: 'Cambodia (+855)'},
|
||||
{code2: 'CM', name: 'Cameroon (+237)'},
|
||||
{code2: 'CA', name: 'Canada (+1)'},
|
||||
{code2: 'KY', name: 'Cayman Islands (+1)'},
|
||||
{code2: 'CF', name: 'Central African Republic (+236)'},
|
||||
{code2: 'TD', name: 'Chad (+235)'},
|
||||
{code2: 'CL', name: 'Chile (+56)'},
|
||||
{code2: 'CN', name: 'China (+86)'},
|
||||
{code2: 'CX', name: 'Christmas Island (+61)'},
|
||||
{code2: 'CC', name: 'Cocos (Keeling) Islands (+61)'},
|
||||
{code2: 'CO', name: 'Colombia (+57)'},
|
||||
{code2: 'KM', name: 'Comoros (+269)'},
|
||||
{code2: 'CG', name: 'Congo (+242)'},
|
||||
{code2: 'CD', name: 'Congo, Democratic Republic of the (+243)'},
|
||||
{code2: 'CK', name: 'Cook Islands (+682)'},
|
||||
{code2: 'CR', name: 'Costa Rica (+506)'},
|
||||
{code2: 'CI', name: "Côte d'Ivoire (+225)"},
|
||||
{code2: 'HR', name: 'Croatia (+385)'},
|
||||
{code2: 'CU', name: 'Cuba (+53)'},
|
||||
{code2: 'CW', name: 'Curaçao (+599)'},
|
||||
{code2: 'CY', name: 'Cyprus (+357)'},
|
||||
{code2: 'CZ', name: 'Czechia (+420)'},
|
||||
{code2: 'DK', name: 'Denmark (+45)'},
|
||||
{code2: 'DJ', name: 'Djibouti (+253)'},
|
||||
{code2: 'DM', name: 'Dominica (+1)'},
|
||||
{code2: 'DO', name: 'Dominican Republic (+1)'},
|
||||
{code2: 'EC', name: 'Ecuador (+593)'},
|
||||
{code2: 'EG', name: 'Egypt (+20)'},
|
||||
{code2: 'SV', name: 'El Salvador (+503)'},
|
||||
{code2: 'GQ', name: 'Equatorial Guinea (+240)'},
|
||||
{code2: 'ER', name: 'Eritrea (+291)'},
|
||||
{code2: 'EE', name: 'Estonia (+372)'},
|
||||
{code2: 'SZ', name: 'Eswatini (+268)'},
|
||||
{code2: 'ET', name: 'Ethiopia (+251)'},
|
||||
{code2: 'FK', name: 'Falkland Islands (Malvinas) (+500)'},
|
||||
{code2: 'FO', name: 'Faroe Islands (+298)'},
|
||||
{code2: 'FJ', name: 'Fiji (+679)'},
|
||||
{code2: 'FI', name: 'Finland (+358)'},
|
||||
{code2: 'FR', name: 'France (+33)'},
|
||||
{code2: 'GF', name: 'French Guiana (+594)'},
|
||||
{code2: 'PF', name: 'French Polynesia (+689)'},
|
||||
{code2: 'GA', name: 'Gabon (+241)'},
|
||||
{code2: 'GM', name: 'Gambia (+220)'},
|
||||
{code2: 'GE', name: 'Georgia (+995)'},
|
||||
{code2: 'DE', name: 'Germany (+49)'},
|
||||
{code2: 'GH', name: 'Ghana (+233)'},
|
||||
{code2: 'GI', name: 'Gibraltar (+350)'},
|
||||
{code2: 'GR', name: 'Greece (+30)'},
|
||||
{code2: 'GL', name: 'Greenland (+299)'},
|
||||
{code2: 'GD', name: 'Grenada (+1)'},
|
||||
{code2: 'GP', name: 'Guadeloupe (+590)'},
|
||||
{code2: 'GU', name: 'Guam (+1)'},
|
||||
{code2: 'GT', name: 'Guatemala (+502)'},
|
||||
{code2: 'GG', name: 'Guernsey (+44)'},
|
||||
{code2: 'GN', name: 'Guinea (+224)'},
|
||||
{code2: 'GW', name: 'Guinea-Bissau (+245)'},
|
||||
{code2: 'GY', name: 'Guyana (+592)'},
|
||||
{code2: 'HT', name: 'Haiti (+509)'},
|
||||
{code2: 'VA', name: 'Holy See (+39)'},
|
||||
{code2: 'HN', name: 'Honduras (+504)'},
|
||||
{code2: 'HK', name: 'Hong Kong (+852)'},
|
||||
{code2: 'HU', name: 'Hungary (+36)'},
|
||||
{code2: 'IS', name: 'Iceland (+354)'},
|
||||
{code2: 'IN', name: 'India (+91)'},
|
||||
{code2: 'ID', name: 'Indonesia (+62)'},
|
||||
{code2: 'IR', name: 'Iran (Islamic Republic of) (+98)'},
|
||||
{code2: 'IQ', name: 'Iraq (+964)'},
|
||||
{code2: 'IE', name: 'Ireland (+353)'},
|
||||
{code2: 'IM', name: 'Isle of Man (+44)'},
|
||||
{code2: 'IL', name: 'Israel (+972)'},
|
||||
{code2: 'IT', name: 'Italy (+39)'},
|
||||
{code2: 'JM', name: 'Jamaica (+1)'},
|
||||
{code2: 'JP', name: 'Japan (+81)'},
|
||||
{code2: 'JE', name: 'Jersey (+44)'},
|
||||
{code2: 'JO', name: 'Jordan (+962)'},
|
||||
{code2: 'KZ', name: 'Kazakhstan (+7)'},
|
||||
{code2: 'KE', name: 'Kenya (+254)'},
|
||||
{code2: 'KI', name: 'Kiribati (+686)'},
|
||||
{code2: 'KP', name: "Korea (Democratic People's Republic of) (+850)"},
|
||||
{code2: 'KR', name: 'Korea, Republic of (+82)'},
|
||||
{code2: 'KW', name: 'Kuwait (+965)'},
|
||||
{code2: 'KG', name: 'Kyrgyzstan (+996)'},
|
||||
{code2: 'LA', name: "Lao People's Democratic Republic (+856)"},
|
||||
{code2: 'LV', name: 'Latvia (+371)'},
|
||||
{code2: 'LB', name: 'Lebanon (+961)'},
|
||||
{code2: 'LS', name: 'Lesotho (+266)'},
|
||||
{code2: 'LR', name: 'Liberia (+231)'},
|
||||
{code2: 'LY', name: 'Libya (+218)'},
|
||||
{code2: 'LI', name: 'Liechtenstein (+423)'},
|
||||
{code2: 'LT', name: 'Lithuania (+370)'},
|
||||
{code2: 'LU', name: 'Luxembourg (+352)'},
|
||||
{code2: 'MO', name: 'Macao (+853)'},
|
||||
{code2: 'MG', name: 'Madagascar (+261)'},
|
||||
{code2: 'MW', name: 'Malawi (+265)'},
|
||||
{code2: 'MY', name: 'Malaysia (+60)'},
|
||||
{code2: 'MV', name: 'Maldives (+960)'},
|
||||
{code2: 'ML', name: 'Mali (+223)'},
|
||||
{code2: 'MT', name: 'Malta (+356)'},
|
||||
{code2: 'MH', name: 'Marshall Islands (+692)'},
|
||||
{code2: 'MQ', name: 'Martinique (+596)'},
|
||||
{code2: 'MR', name: 'Mauritania (+222)'},
|
||||
{code2: 'MU', name: 'Mauritius (+230)'},
|
||||
{code2: 'YT', name: 'Mayotte (+262)'},
|
||||
{code2: 'MX', name: 'Mexico (+52)'},
|
||||
{code2: 'FM', name: 'Micronesia (Federated States of) (+691)'},
|
||||
{code2: 'MD', name: 'Moldova, Republic of (+373)'},
|
||||
{code2: 'MC', name: 'Monaco (+377)'},
|
||||
{code2: 'MN', name: 'Mongolia (+976)'},
|
||||
{code2: 'ME', name: 'Montenegro (+382)'},
|
||||
{code2: 'MS', name: 'Montserrat (+1)'},
|
||||
{code2: 'MA', name: 'Morocco (+212)'},
|
||||
{code2: 'MZ', name: 'Mozambique (+258)'},
|
||||
{code2: 'MM', name: 'Myanmar (+95)'},
|
||||
{code2: 'NA', name: 'Namibia (+264)'},
|
||||
{code2: 'NR', name: 'Nauru (+674)'},
|
||||
{code2: 'NP', name: 'Nepal (+977)'},
|
||||
{code2: 'NL', name: 'Netherlands, Kingdom of the (+31)'},
|
||||
{code2: 'NC', name: 'New Caledonia (+687)'},
|
||||
{code2: 'NZ', name: 'New Zealand (+64)'},
|
||||
{code2: 'NI', name: 'Nicaragua (+505)'},
|
||||
{code2: 'NE', name: 'Niger (+227)'},
|
||||
{code2: 'NG', name: 'Nigeria (+234)'},
|
||||
{code2: 'NU', name: 'Niue (+683)'},
|
||||
{code2: 'NF', name: 'Norfolk Island (+672)'},
|
||||
{code2: 'MK', name: 'North Macedonia (+389)'},
|
||||
{code2: 'MP', name: 'Northern Mariana Islands (+1)'},
|
||||
{code2: 'NO', name: 'Norway (+47)'},
|
||||
{code2: 'OM', name: 'Oman (+968)'},
|
||||
{code2: 'PK', name: 'Pakistan (+92)'},
|
||||
{code2: 'PW', name: 'Palau (+680)'},
|
||||
{code2: 'PS', name: 'Palestine, State of (+970)'},
|
||||
{code2: 'PA', name: 'Panama (+507)'},
|
||||
{code2: 'PG', name: 'Papua New Guinea (+675)'},
|
||||
{code2: 'PY', name: 'Paraguay (+595)'},
|
||||
{code2: 'PE', name: 'Peru (+51)'},
|
||||
{code2: 'PH', name: 'Philippines (+63)'},
|
||||
{code2: 'PL', name: 'Poland (+48)'},
|
||||
{code2: 'PT', name: 'Portugal (+351)'},
|
||||
{code2: 'PR', name: 'Puerto Rico (+1)'},
|
||||
{code2: 'QA', name: 'Qatar (+974)'},
|
||||
{code2: 'RE', name: 'Réunion (+262)'},
|
||||
{code2: 'RO', name: 'Romania (+40)'},
|
||||
{code2: 'RU', name: 'Russian Federation (+7)'},
|
||||
{code2: 'RW', name: 'Rwanda (+250)'},
|
||||
{code2: 'BL', name: 'Saint Barthélemy (+590)'},
|
||||
{code2: 'SH', name: 'Saint Helena, Ascension and Tristan da Cunha (+290)'},
|
||||
{code2: 'KN', name: 'Saint Kitts and Nevis (+1)'},
|
||||
{code2: 'LC', name: 'Saint Lucia (+1)'},
|
||||
{code2: 'MF', name: 'Saint Martin (French part) (+590)'},
|
||||
{code2: 'PM', name: 'Saint Pierre and Miquelon (+508)'},
|
||||
{code2: 'VC', name: 'Saint Vincent and the Grenadines (+1)'},
|
||||
{code2: 'WS', name: 'Samoa (+685)'},
|
||||
{code2: 'SM', name: 'San Marino (+378)'},
|
||||
{code2: 'ST', name: 'Sao Tome and Principe (+239)'},
|
||||
{code2: 'SA', name: 'Saudi Arabia (+966)'},
|
||||
{code2: 'SN', name: 'Senegal (+221)'},
|
||||
{code2: 'RS', name: 'Serbia (+381)'},
|
||||
{code2: 'SC', name: 'Seychelles (+248)'},
|
||||
{code2: 'SL', name: 'Sierra Leone (+232)'},
|
||||
{code2: 'SG', name: 'Singapore (+65)'},
|
||||
{code2: 'SX', name: 'Sint Maarten (Dutch part) (+1)'},
|
||||
{code2: 'SK', name: 'Slovakia (+421)'},
|
||||
{code2: 'SI', name: 'Slovenia (+386)'},
|
||||
{code2: 'SB', name: 'Solomon Islands (+677)'},
|
||||
{code2: 'SO', name: 'Somalia (+252)'},
|
||||
{code2: 'ZA', name: 'South Africa (+27)'},
|
||||
{code2: 'SS', name: 'South Sudan (+211)'},
|
||||
{code2: 'ES', name: 'Spain (+34)'},
|
||||
{code2: 'LK', name: 'Sri Lanka (+94)'},
|
||||
{code2: 'SD', name: 'Sudan (+249)'},
|
||||
{code2: 'SR', name: 'Suriname (+597)'},
|
||||
{code2: 'SJ', name: 'Svalbard and Jan Mayen (+47)'},
|
||||
{code2: 'SE', name: 'Sweden (+46)'},
|
||||
{code2: 'CH', name: 'Switzerland (+41)'},
|
||||
{code2: 'SY', name: 'Syrian Arab Republic (+963)'},
|
||||
{code2: 'TW', name: 'Taiwan (+886)'},
|
||||
{code2: 'TJ', name: 'Tajikistan (+992)'},
|
||||
{code2: 'TZ', name: 'Tanzania, United Republic of (+255)'},
|
||||
{code2: 'TH', name: 'Thailand (+66)'},
|
||||
{code2: 'TL', name: 'Timor-Leste (+670)'},
|
||||
{code2: 'TG', name: 'Togo (+228)'},
|
||||
{code2: 'TK', name: 'Tokelau (+690)'},
|
||||
{code2: 'TO', name: 'Tonga (+676)'},
|
||||
{code2: 'TT', name: 'Trinidad and Tobago (+1)'},
|
||||
{code2: 'TN', name: 'Tunisia (+216)'},
|
||||
{code2: 'TR', name: 'Türkiye (+90)'},
|
||||
{code2: 'TM', name: 'Turkmenistan (+993)'},
|
||||
{code2: 'TC', name: 'Turks and Caicos Islands (+1)'},
|
||||
{code2: 'TV', name: 'Tuvalu (+688)'},
|
||||
{code2: 'UG', name: 'Uganda (+256)'},
|
||||
{code2: 'UA', name: 'Ukraine (+380)'},
|
||||
{code2: 'AE', name: 'United Arab Emirates (+971)'},
|
||||
{
|
||||
code2: 'GB',
|
||||
name: 'United Kingdom of Great Britain and Northern Ireland (+44)',
|
||||
},
|
||||
{code2: 'US', name: 'United States of America (+1)'},
|
||||
{code2: 'UY', name: 'Uruguay (+598)'},
|
||||
{code2: 'UZ', name: 'Uzbekistan (+998)'},
|
||||
{code2: 'VU', name: 'Vanuatu (+678)'},
|
||||
{code2: 'VE', name: 'Venezuela (Bolivarian Republic of) (+58)'},
|
||||
{code2: 'VN', name: 'Viet Nam (+84)'},
|
||||
{code2: 'VG', name: 'Virgin Islands (British) (+1)'},
|
||||
{code2: 'VI', name: 'Virgin Islands (U.S.) (+1)'},
|
||||
{code2: 'WF', name: 'Wallis and Futuna (+681)'},
|
||||
{code2: 'EH', name: 'Western Sahara (+212)'},
|
||||
{code2: 'YE', name: 'Yemen (+967)'},
|
||||
{code2: 'ZM', name: 'Zambia (+260)'},
|
||||
{code2: 'ZW', name: 'Zimbabwe (+263)'},
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import * as Linking from 'expo-linking'
|
||||
import {isNative} from 'platform/detection'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {useSession} from 'state/session'
|
||||
import {useCloseAllActiveElements} from 'state/util'
|
||||
|
||||
type IntentType = 'compose'
|
||||
|
||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||
|
||||
export function useIntentHandler() {
|
||||
const incomingUrl = Linking.useURL()
|
||||
const composeIntent = useComposeIntent()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleIncomingURL = (url: string) => {
|
||||
// We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three
|
||||
// slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care
|
||||
// of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first
|
||||
// path parameter is in pathname rather than in hostname.
|
||||
if (url.startsWith('bluesky://') && !url.startsWith('bluesky:///')) {
|
||||
url = url.replace('bluesky://', 'bluesky:///')
|
||||
}
|
||||
|
||||
const urlp = new URL(url)
|
||||
const [_, intent, intentType] = urlp.pathname.split('/')
|
||||
|
||||
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
|
||||
// intent check. On web, we have to check the first part of the path since we have an actual hostname
|
||||
const isIntent = intent === 'intent'
|
||||
const params = urlp.searchParams
|
||||
|
||||
if (!isIntent) return
|
||||
|
||||
switch (intentType as IntentType) {
|
||||
case 'compose': {
|
||||
composeIntent({
|
||||
text: params.get('text'),
|
||||
imageUrisStr: params.get('imageUris'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||
}, [incomingUrl, composeIntent])
|
||||
}
|
||||
|
||||
function useComposeIntent() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
return React.useCallback(
|
||||
({
|
||||
text,
|
||||
imageUrisStr,
|
||||
}: {
|
||||
text: string | null
|
||||
imageUrisStr: string | null // unused for right now, will be used later with intents
|
||||
}) => {
|
||||
if (!hasSession) return
|
||||
|
||||
closeAllActiveElements()
|
||||
|
||||
const imageUris = imageUrisStr
|
||||
?.split(',')
|
||||
.filter(part => {
|
||||
// For some security, we're going to filter out any image uri that is external. We don't want someone to
|
||||
// be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
|
||||
// and we load that image
|
||||
if (part.includes('https://') || part.includes('http://')) {
|
||||
return false
|
||||
}
|
||||
// We also should just filter out cases that don't have all the info we need
|
||||
return VALID_IMAGE_REGEX.test(part)
|
||||
})
|
||||
.map(part => {
|
||||
const [uri, width, height] = part.split('|')
|
||||
return {uri, width: Number(width), height: Number(height)}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
openComposer({
|
||||
text: text ?? undefined,
|
||||
imageUris: isNative ? imageUris : undefined,
|
||||
})
|
||||
}, 500)
|
||||
},
|
||||
[hasSession, closeAllActiveElements, openComposer],
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export interface LinkMeta {
|
||||
export async function getLinkMeta(
|
||||
agent: BskyAgent,
|
||||
url: string,
|
||||
timeout = 5e3,
|
||||
timeout = 15e3,
|
||||
): Promise<LinkMeta> {
|
||||
if (isBskyAppUrl(url)) {
|
||||
return extractBskyMeta(agent, url)
|
||||
|
||||
@@ -2,19 +2,152 @@ import {
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
moderatePost,
|
||||
AppBskyActorDefs,
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedExternal,
|
||||
} from '@atproto/api'
|
||||
|
||||
type ModeratePost = typeof moderatePost
|
||||
type Options = Parameters<ModeratePost>[1] & {
|
||||
hiddenPosts?: string[]
|
||||
mutedWords?: AppBskyActorDefs.MutedWord[]
|
||||
}
|
||||
|
||||
const REGEX = {
|
||||
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
|
||||
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
|
||||
SEPARATORS: /[\/\-\–\—\(\)\[\]\_]+/g,
|
||||
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
|
||||
}
|
||||
|
||||
/**
|
||||
* List of 2-letter lang codes for languages that either don't use spaces, or
|
||||
* don't use spaces in a way conducive to word-based filtering.
|
||||
*
|
||||
* For these, we use a simple `String.includes` to check for a match.
|
||||
*/
|
||||
const LANGUAGE_EXCEPTIONS = [
|
||||
'ja', // Japanese
|
||||
'zh', // Chinese
|
||||
'ko', // Korean
|
||||
'th', // Thai
|
||||
'vi', // Vietnamese
|
||||
]
|
||||
|
||||
export function hasMutedWord({
|
||||
mutedWords,
|
||||
text,
|
||||
facets,
|
||||
outlineTags,
|
||||
languages,
|
||||
isOwnPost,
|
||||
}: {
|
||||
mutedWords: AppBskyActorDefs.MutedWord[]
|
||||
text: string
|
||||
facets?: AppBskyRichtextFacet.Main[]
|
||||
outlineTags?: string[]
|
||||
languages?: string[]
|
||||
isOwnPost: boolean
|
||||
}) {
|
||||
if (isOwnPost) return false
|
||||
|
||||
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
|
||||
const tags = ([] as string[])
|
||||
.concat(outlineTags || [])
|
||||
.concat(
|
||||
facets
|
||||
?.filter(facet => {
|
||||
return facet.features.find(feature =>
|
||||
AppBskyRichtextFacet.isTag(feature),
|
||||
)
|
||||
})
|
||||
.map(t => t.features[0].tag as string) || [],
|
||||
)
|
||||
.map(t => t.toLowerCase())
|
||||
|
||||
for (const mute of mutedWords) {
|
||||
const mutedWord = mute.value.toLowerCase()
|
||||
const postText = text.toLowerCase()
|
||||
|
||||
// `content` applies to tags as well
|
||||
if (tags.includes(mutedWord)) return true
|
||||
// rest of the checks are for `content` only
|
||||
if (!mute.targets.includes('content')) continue
|
||||
// single character or other exception, has to use includes
|
||||
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
|
||||
return true
|
||||
// too long
|
||||
if (mutedWord.length > postText.length) continue
|
||||
// exact match
|
||||
if (mutedWord === postText) return true
|
||||
// any muted phrase with space or punctuation
|
||||
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
|
||||
return true
|
||||
|
||||
// check individual character groups
|
||||
const words = postText.split(REGEX.WORD_BOUNDARY)
|
||||
for (const word of words) {
|
||||
if (word === mutedWord) return true
|
||||
|
||||
// compare word without leading/trailing punctuation, but allow internal
|
||||
// punctuation (such as `s@ssy`)
|
||||
const wordTrimmedPunctuation = word.replace(
|
||||
REGEX.LEADING_TRAILING_PUNCTUATION,
|
||||
'',
|
||||
)
|
||||
|
||||
if (mutedWord === wordTrimmedPunctuation) return true
|
||||
if (mutedWord.length > wordTrimmedPunctuation.length) continue
|
||||
|
||||
// handle hyphenated, slash separated words, etc
|
||||
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
|
||||
// check against full normalized phrase
|
||||
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
const mutedWordNormalizedSeparators = mutedWord.replace(
|
||||
REGEX.SEPARATORS,
|
||||
' ',
|
||||
)
|
||||
// hyphenated (or other sep) to spaced words
|
||||
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
|
||||
return true
|
||||
|
||||
/* Disabled for now e.g. `super-cool` to `supercool`
|
||||
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
|
||||
REGEX.WORD_BOUNDARY,
|
||||
'',
|
||||
)
|
||||
const mutedWordNormalizedCompressed =
|
||||
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
|
||||
// hyphenated (or other sep) to non-hyphenated contiguous word
|
||||
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
|
||||
return true
|
||||
*/
|
||||
|
||||
// then individual parts of separated phrases/words
|
||||
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
|
||||
for (const wp of wordParts) {
|
||||
// still retain internal punctuation
|
||||
if (wp === mutedWord) return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function moderatePost_wrapped(
|
||||
subject: Parameters<ModeratePost>[0],
|
||||
opts: Options,
|
||||
) {
|
||||
const {hiddenPosts = [], ...options} = opts
|
||||
const {hiddenPosts = [], mutedWords = [], ...options} = opts
|
||||
const moderations = moderatePost(subject, options)
|
||||
const isOwnPost = subject.author.did === opts.userDid
|
||||
|
||||
if (hiddenPosts.includes(subject.uri)) {
|
||||
moderations.content.filter = true
|
||||
@@ -29,8 +162,53 @@ export function moderatePost_wrapped(
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyFeedPost.isRecord(subject.record)) {
|
||||
let muted = hasMutedWord({
|
||||
mutedWords,
|
||||
text: subject.record.text,
|
||||
facets: subject.record.facets || [],
|
||||
outlineTags: subject.record.tags || [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (
|
||||
subject.record.embed &&
|
||||
AppBskyEmbedImages.isMain(subject.record.embed)
|
||||
) {
|
||||
for (const image of subject.record.embed.images) {
|
||||
muted =
|
||||
muted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: subject.record.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (muted) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'muted-word',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (subject.embed) {
|
||||
let embedHidden = false
|
||||
let embedMuted = false
|
||||
let externalMuted = false
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
|
||||
}
|
||||
@@ -40,6 +218,139 @@ export function moderatePost_wrapped(
|
||||
) {
|
||||
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
|
||||
const embeddedPost = subject.embed.record.value
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: embeddedPost.text,
|
||||
facets: embeddedPost.facets,
|
||||
outlineTags: embeddedPost.tags,
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
|
||||
for (const image of embeddedPost.embed.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: embeddedPost.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
|
||||
const {external} = embeddedPost.embed
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
|
||||
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
|
||||
const {external} = embeddedPost.embed.media
|
||||
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
|
||||
for (const image of embeddedPost.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
|
||||
? embeddedPost.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (AppBskyEmbedExternal.isView(subject.embed)) {
|
||||
const {external} = subject.embed
|
||||
|
||||
externalMuted =
|
||||
externalMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: external.title + ' ' + external.description,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
|
||||
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
|
||||
) {
|
||||
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
|
||||
const post = subject.embed.record.record.value
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: post.text,
|
||||
facets: post.facets,
|
||||
outlineTags: post.tags,
|
||||
languages: post.langs,
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
|
||||
if (AppBskyEmbedImages.isView(subject.embed.media)) {
|
||||
for (const image of subject.embed.media.images) {
|
||||
embedMuted =
|
||||
embedMuted ||
|
||||
hasMutedWord({
|
||||
mutedWords,
|
||||
text: image.alt,
|
||||
facets: [],
|
||||
outlineTags: [],
|
||||
languages: AppBskyFeedPost.isRecord(subject.record)
|
||||
? subject.record.langs
|
||||
: [],
|
||||
isOwnPost,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (embedHidden) {
|
||||
moderations.embed.filter = true
|
||||
moderations.embed.blur = true
|
||||
@@ -51,6 +362,17 @@ export function moderatePost_wrapped(
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
} else if (externalMuted || embedMuted) {
|
||||
moderations.content.filter = true
|
||||
moderations.content.blur = true
|
||||
if (!moderations.content.cause) {
|
||||
moderations.content.cause = {
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
type: 'muted-word',
|
||||
source: {type: 'user'},
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ export function describeModerationCause(
|
||||
description: 'You have hidden this post',
|
||||
}
|
||||
}
|
||||
// @ts-ignore Temporary extension to the moderation system -prf
|
||||
if (cause.type === 'muted-word') {
|
||||
return {
|
||||
name: 'Post hidden by muted word',
|
||||
description: `You've chosen to hide a word or tag within this post.`,
|
||||
}
|
||||
}
|
||||
return cause.labelDef.strings[context].en
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user