Merge remote-tracking branch 'origin/main' into app-2273

* origin/main: (38 commits)
  Mount message dialogs once at the list level (#10435)
  Fix alt text selection in lightbox on Android (and iOS) (#10698)
  Mention default `<Text>` style in CLAUDE.md (#10705)
  Use square icons for labellers in Automation Label preview card (#10701)
  [ALF] Set text to `leading_snug` by default (#10627)
  Nightly source-language update
  Transpile `@atproto/api` package for older browser support (#10700)
  Add iOS peek long-press context menu for image embeds (#10300)
  Revert "[ALG-96] Don't fall back to search for onboarding user suggestions (#10680)" (#10699)
  fix blur on image alt text background (#10658)
  Create screen for managing chat requests (#10635)
  Don't show Suggested heading if there are no suggestions (#10695)
  Prefer opengraph image, fall back to Standard Site coverImage (#10694)
  Create logged-out view for group chat invites (#10598)
  Bump actions/create-github-app-token from 1 to 3 (#10691)
  Bump marocchino/sticky-pull-request-comment from 2 to 3 (#10692)
  Bump slackapi/slack-github-action from 2.1.1 to 3.0.3 (#10693)
  ci: add dependabot config with 7-day cooldown (#10475)
  Remove image upload flags (#10687)
  [ALG-96] Don't fall back to search for onboarding user suggestions (#10680)
  ...

# Conflicts:
#	src/components/Lightbox/chrome/Footer.tsx
This commit is contained in:
Eric Bailey
2026-06-03 11:30:59 -05:00
111 changed files with 4500 additions and 1667 deletions
+1
View File
@@ -0,0 +1 @@
alt-blur@3076a2b5a593b0d7b37198b5aebfe3a222afd52d
+44
View File
@@ -0,0 +1,44 @@
version: 2
# Dependabot auto-update config.
#
# Cooldown (7 days) is the point of this config: it delays version-update
# PRs until a newly-published version has aged. Supply-chain attacks like
# the tanstack Shai-Hulud compromise (2026-05-11) live minutes-to-hours
# before the registry yanks them; a 7-day cooldown keeps poisoned
# versions out of our lockfiles.
#
# Security updates bypass cooldown and continue to flow immediately. See:
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/dependabot-options-reference#cooldown
#
# Auto-merge is deliberately NOT enabled. Every dependabot PR gets human
# review.
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
production:
dependency-type: production
update-types: [minor, patch]
development:
dependency-type: development
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
cooldown:
default-days: 7
open-pull-requests-limit: 3
groups:
actions:
patterns: ["*"]
update-types: [minor, patch]
+4 -4
View File
@@ -47,7 +47,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -128,7 +128,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -138,7 +138,7 @@ jobs:
- name: 🔔 Notify Slack of Testflight Build
if: ${{ inputs.profile != 'production' }}
uses: slackapi/slack-github-action@v2.1.1
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -174,7 +174,7 @@ jobs:
- name: 🔔 Notify Slack of Production APK Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+2 -2
View File
@@ -47,7 +47,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: 🔨 Setup Expo CLI
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -155,7 +155,7 @@ jobs:
- name: 🔔 Notify Slack of Production Build
if: ${{ inputs.profile == 'production' }}
uses: slackapi/slack-github-action@v2.1.1
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
@@ -87,7 +87,7 @@ jobs:
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
eas-version: '19.0.5'
@@ -180,7 +180,7 @@ jobs:
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -327,7 +327,7 @@ jobs:
cache: pnpm
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -386,7 +386,7 @@ jobs:
path: build.apk
- name: 🔔 Notify Slack
uses: slackapi/slack-github-action@v2.1.1
uses: slackapi/slack-github-action@v3.0.3
with:
webhook: ${{ secrets.SLACK_CLIENT_ALERT_WEBHOOK }}
webhook-type: incoming-webhook
+72 -11
View File
@@ -6,33 +6,42 @@ on:
branches:
- main
concurrency:
group: "${{ github.workflow }}-${{ github.head_ref || github.ref }}"
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
# These jobs only check out the repo and run checks, so read access to the
# repo contents is all the GITHUB_TOKEN needs.
permissions:
contents: read
jobs:
linting:
name: Run linters
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
job: [lint, prettier]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- name: Verify Node version pins match .nvmrc
- name: Verify Node version pins match package.json
run: |
set -euo pipefail
expected=$(tr -d '[:space:]' < .nvmrc)
expected=$(node -p "require('./package.json').engines.node.replace(/[^0-9.]/g, '')")
rc=0
check() {
if [ "$2" != "$expected" ]; then
echo "::error file=$1::Node version mismatch: $1 pins '$2' but .nvmrc is '$expected'"
echo "::error file=$1::Node version mismatch: $1 pins '$2' but package.json is '$expected'"
rc=1
fi
}
# FROM node:X.Y.Z service runtime images
# FROM node:X.Y.Z - service runtime images
for f in Dockerfile.bskylink Dockerfile.bskyogcard; do
v=$(grep -oE 'FROM node:[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
done
# ENV NODE_VERSION=X.Y.Z Go images that nvm-install Node for the JS build stage
# ENV NODE_VERSION=X.Y.Z - Go images that nvm-install Node for the JS build stage
for f in Dockerfile.embedr; do
v=$(grep -oE 'NODE_VERSION=[0-9]+\.[0-9]+\.[0-9]+' "$f" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
check "$f" "$v"
@@ -53,10 +62,42 @@ jobs:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Lint check
run: pnpm lint
- name: Prettier check
run: pnpm prettier --check .
- name: Lint checks
run: pnpm ${{ matrix.job }}
# Aggregates the matrix results into a single stable check name so branch
# protection can require "Run linters" regardless of how many matrix jobs run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
linting-summary:
name: Run linters
if: always()
needs: [linting]
runs-on: ubuntu-latest
steps:
- name: Require linting to have succeeded
env:
RESULT: ${{ needs.linting.result }}
run: |
echo "linting result: $RESULT"
test "$RESULT" = "success"
typechecking:
name: Run typecheck
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@v5
- uses: pnpm/action-setup@v6
- name: Install node
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: pnpm install
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Check & compile i18n
run: pnpm intl:build
- name: Type check
@@ -64,6 +105,10 @@ jobs:
testing:
name: Run tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Check out Git repository
uses: actions/checkout@v5
@@ -83,4 +128,20 @@ jobs:
run: pnpm intl:build
- name: Run tests
run: |
NODE_ENV=test pnpm test --forceExit
NODE_ENV=test pnpm test --forceExit --shard=${{ matrix.shard }}/${{ strategy.job-total }}
# Aggregates the sharded test results into a single stable check name so branch
# protection can require "Run tests" regardless of how many shards run.
# The result is asserted in `run` (not `if`) so a malformed expression can
# never silently skip the check and report a false pass.
testing-summary:
name: Run tests
if: always()
needs: [testing]
runs-on: ubuntu-latest
steps:
- name: Require testing to have succeeded
env:
RESULT: ${{ needs.testing.result }}
run: |
echo "testing result: $RESULT"
test "$RESULT" = "success"
+4 -4
View File
@@ -94,7 +94,7 @@ jobs:
core.setOutput('head-ref', pr.data.head.ref);
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
number: ${{ github.event.issue.number }}
@@ -141,7 +141,7 @@ jobs:
run: pnpm typecheck
- name: 🔨 Setup EAS
uses: expo/expo-github-action@main
uses: expo/expo-github-action@v9
with:
eas-version: '19.0.5'
packager: 'pnpm --allow-build=dtrace-provider'
@@ -181,7 +181,7 @@ jobs:
RUNTIME_VERSION:
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
with:
@@ -198,7 +198,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
if: failure()
with:
header: pull-request-eas-build-${{ steps.pr-info.outputs.head-sha }}
+3 -3
View File
@@ -96,7 +96,7 @@ jobs:
excluded_assets: "(.+).chunk.js|(.+).js.map|(.+).json|(.+).png|(.+).svg|(.+).webp|(.+).jpg|(.+).ico"
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
with:
header: bundle-diff
message: |
@@ -133,7 +133,7 @@ jobs:
profile: pull-request
- name: 💬 Drop a comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
if: ${{ steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
@@ -151,7 +151,7 @@ jobs:
*Generated by [PR labeler](https://github.com/expo/expo/actions/workflows/pr-labeler.yml) 🤖*
- name: 💬 Delete comment
uses: marocchino/sticky-pull-request-comment@v2
uses: marocchino/sticky-pull-request-comment@v3
if: ${{ !steps.fingerprint.outputs.includes-changes }}
with:
header: fingerprint-diff
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
fetch-depth: 0
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@v1
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.SYNC_INTERNAL_APP_ID }}
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
+7 -3
View File
@@ -206,7 +206,7 @@ function MyComponent() {
return (
<View style={[a.flex_row, a.gap_md, a.p_lg, t.atoms.bg]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text]}>Hello</Text>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_high]}>Hello</Text>
</View>
)
}
@@ -414,12 +414,16 @@ import {Text, H1, H2, P} from '#/components/Typography'
<H1 style={[a.text_xl, a.font_bold]}>Heading</H1>
<P>Paragraph text with default styling.</P>
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>Custom text</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>Custom text</Text>
// For text with emoji, add the emoji prop
// For text with emoji, add the emoji prop. User-generated text (e.g. display names)
// will almost certainly contain emoji, so only omit it when the text is static and
// does not contain an emoji
<Text emoji>Hello! 👋</Text>
```
The `Text` component's default style is `[a.text_sm, a.leading_snug, t.atoms.text]`.
### TextField
```tsx
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.1.3 && \
npm install --global pnpm@11.5.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+6
View File
@@ -46,6 +46,12 @@ func run(args []string) {
Required: false,
EnvVars: []string{"OGCARD_HOST"},
},
&cli.StringFlag{
Name: "chat-host",
Usage: "scheme, hostname, and port of chat appview",
Required: false,
EnvVars: []string{"CHAT_HOST"},
},
&cli.StringFlag{
Name: "http-address",
Usage: "Specify the local IP/port to bind to",
+63 -6
View File
@@ -17,11 +17,13 @@ import (
"net/url"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
appbsky "github.com/bluesky-social/indigo/api/bsky"
chat "github.com/bluesky-social/indigo/api/chat"
"github.com/bluesky-social/indigo/atproto/syntax"
"github.com/bluesky-social/indigo/util/cliutil"
"github.com/bluesky-social/indigo/xrpc"
@@ -37,10 +39,11 @@ import (
)
type Server struct {
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
cfg *Config
echo *echo.Echo
httpd *http.Server
xrpcc *xrpc.Client
chatXrpcc *xrpc.Client
cfg *Config
ipccClient http.Client
@@ -54,6 +57,7 @@ type Config struct {
debug bool
httpAddress string
appviewHost string
chatHost string
ogcardHost string
linkHost string
ipccHost string
@@ -64,6 +68,7 @@ func serve(cctx *cli.Context) error {
debug := cctx.Bool("debug")
httpAddress := cctx.String("http-address")
appviewHost := cctx.String("appview-host")
chatHost := cctx.String("chat-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
@@ -83,6 +88,15 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
// optional client for the chat appview, used by /c/<code> for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
Client: cliutil.NewHttpClient(),
Host: chatHost,
}
}
// httpd
var (
httpTimeout = 2 * time.Minute
@@ -106,12 +120,14 @@ func serve(cctx *cli.Context) error {
// server
//
server := &Server{
echo: e,
xrpcc: xrpcc,
echo: e,
xrpcc: xrpcc,
chatXrpcc: chatXrpcc,
cfg: &Config{
debug: debug,
httpAddress: httpAddress,
appviewHost: appviewHost,
chatHost: chatHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
@@ -323,6 +339,7 @@ func serve(cctx *cli.Context) error {
e.GET("/messages/inbox", server.WebGeneric)
e.GET("/messages/:conversation", server.WebGeneric)
e.GET("/messages/:conversation/settings", server.WebGeneric)
e.GET("/messages/:conversation/requests", server.WebGeneric)
// profile endpoints; only first populates info
e.GET("/profile/:handleOrDID", server.WebProfile)
@@ -349,6 +366,9 @@ func serve(cctx *cli.Context) error {
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
e.GET("/c/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGeneric)
@@ -644,6 +664,43 @@ func (srv *Server) WebStarterPack(c echo.Context) error {
return c.Render(http.StatusOK, "starterpack.html", data)
}
// chatInviteCodeRe is a permissive sanity check on the join code so we don't
// proxy obviously-bad input to the chat appview. Codes are opaque per the
// lexicon, but in practice URL-safe and short.
var chatInviteCodeRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
func (srv *Server) WebChatInvite(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
code := c.Param("code")
if !chatInviteCodeRe.MatchString(code) {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
if srv.chatXrpcc == nil {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
out, err := chat.GroupGetJoinLinkPreviews(ctx, srv.chatXrpcc, []string{code})
if err != nil {
log.Errorf("failed to fetch chat invite preview for code=%s: %v", code, err)
return c.Render(http.StatusOK, "chatinvite.html", data)
}
if len(out.JoinLinkPreviews) == 0 {
return c.Render(http.StatusOK, "chatinvite.html", data)
}
preview := out.JoinLinkPreviews[0]
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
// bskyogcard registers this route as /chat-invite/:code, not /c/:code.
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
}
return c.Render(http.StatusOK, "chatinvite.html", data)
}
func (srv *Server) WebProfile(c echo.Context) error {
ctx := c.Request().Context()
data := srv.NewTemplateContext()
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26
require (
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0
github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1
+2 -4
View File
@@ -2,10 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8 h1:heYuihJZQZMNij7+dKIEfApG10xrGnDV2rtKpvFyYYU=
github.com/bluesky-social/indigo v0.0.0-20260527160159-3b7634c713b8/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814 h1:UZK0qmB7LAe2IZY6RASlsE0Mm1Rjasddd2T5zWWvT54=
github.com/bluesky-social/indigo v0.0.0-20260528134852-3e368bd18814/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+25
View File
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block html_head_extra -%}
{%- if requestURI %}
<meta property="og:url" content="{{ requestURI }}">
{% endif -%}
{%- if imgThumbUrl %}
<meta property="og:image" content="{{ imgThumbUrl }}">
<meta property="twitter:image" content="{{ imgThumbUrl }}">
{%- else -%}
<meta property="og:image" content="https://bsky.app/static/social-card-default-gradient.png" />
<meta property="twitter:image" content="https://bsky.app/static/social-card-default-gradient.png" />
{% endif -%}
<meta name="twitter:card" content="summary_large_image" />
{%- if title %}
<meta property="og:title" content="{{ title }}" />
<meta name="twitter:title" content="{{ title }}" />
{%- else -%}
<meta property="og:title" content="Bluesky" />
<meta name="twitter:title" content="Bluesky" />
{% endif -%}
<meta name="description" content="Join this group chat on Bluesky" />
<meta name="og:description" content="Join this group chat on Bluesky" />
<meta name="twitter:description" content="Join this group chat on Bluesky" />
{%- endblock %}
+2 -12
View File
@@ -76,7 +76,7 @@
},
"src/components/Post/Embed/ImageEmbed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
"count": 2
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx": {
@@ -169,7 +169,7 @@
},
"src/components/images/AutoSizedImage.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
"count": 2
}
},
"src/components/images/Gallery/index.tsx": {
@@ -317,16 +317,6 @@
"count": 1
}
},
"src/locale/i18n.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/platform/polyfills.web.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"src/screens/Bookmarks/index.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -41,11 +41,14 @@ class BottomSheetView(
private val screenHeight: Float =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.VANILLA_ICE_CREAM) {
// API 35+: edge-to-edge is mandatory, heightPixels is the full display
context.resources.displayMetrics.heightPixels.toFloat()
context.resources.displayMetrics.heightPixels
.toFloat()
} else if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// API 30-34: heightPixels may exclude nav bar, use currentWindowMetrics
val wm = context.getSystemService(Context.WINDOW_SERVICE) as android.view.WindowManager
wm.currentWindowMetrics.bounds.height().toFloat()
wm.currentWindowMetrics.bounds
.height()
.toFloat()
} else {
// API < 30: currentWindowMetrics not available, use getRealSize
// which includes system bars (heightPixels may exclude them)
@@ -166,6 +169,7 @@ class BottomSheetView(
when {
// Full height sheets
contentHeight >= screenHeight -> 0.99f
else -> this.clampRatio(this.getTargetHeight() / screenHeight)
}
@@ -271,8 +275,9 @@ class BottomSheetView(
}
// Apply deferred layout update after gesture completes
if (newState != BottomSheetBehavior.STATE_DRAGGING &&
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate) {
newState != BottomSheetBehavior.STATE_SETTLING &&
pendingLayoutUpdate
) {
pendingLayoutUpdate = false
updateLayout()
}
@@ -292,7 +297,6 @@ class BottomSheetView(
if (!fullHeight) {
this.startObservingContentHeight()
}
}
fun updateLayout() {
@@ -365,17 +369,18 @@ class BottomSheetView(
val innerViewGroup = this.innerView as? ViewGroup ?: return
val listener = OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
val listener =
OnLayoutChangeListener { _, _, top, _, bottom, _, _, oldTop, oldBottom ->
val newHeight = bottom - top
val oldHeight = oldBottom - oldTop
if (newHeight != oldHeight) {
val contentHeight = getContentHeight()
if (contentHeight != lastObservedContentHeight && contentHeight > 0 && (isOpen || isOpening) && !isClosing) {
lastObservedContentHeight = contentHeight
updateLayout()
}
}
}
}
val children = mutableListOf<View>()
for (i in 0 until innerViewGroup.childCount) {
+10 -9
View File
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.1.3",
"version": "11.5.0",
"onFail": "warn"
},
"runtime": {
@@ -89,10 +89,11 @@
"make-deploy-bundle": "bash scripts/bundleUpdate.sh",
"generate-webpack-stats-file": "EXPO_PUBLIC_GENERATE_STATS=1 pnpm build-web",
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 pnpm build-web",
"icons:optimize": "svgo -f ./assets/icons"
"icons:optimize": "svgo -f ./assets/icons",
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.6",
"@atproto/api": "0.20.8",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
@@ -101,6 +102,7 @@
"@bsky.app/expo-image-crop-tool": "^0.5.1",
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
"@bsky.app/expo-translate-text": "^0.2.9",
"@bsky.app/peek-menu": "^0.2.4",
"@bsky.app/react-native-mmkv": "2.12.5",
"@bsky.app/sift": "^0.3.8",
"@bsky.app/tapper": "^0.5.7",
@@ -112,16 +114,15 @@
"@expo/webpack-config": "^19.0.1",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-displaynames": "^6.8.13",
"@formatjs/intl-locale": "^4.2.13",
"@formatjs/intl-numberformat": "^8.15.6",
"@formatjs/intl-pluralrules": "^5.4.6",
"@formatjs/intl-displaynames": "^7.3.9",
"@formatjs/intl-locale": "^5.3.8",
"@formatjs/intl-numberformat": "^9.3.10",
"@formatjs/intl-pluralrules": "^6.3.9",
"@growthbook/growthbook": "^1.6.5",
"@growthbook/growthbook-react": "^1.6.5",
"@ipld/dag-cbor": "^9.2.7",
"@lingui/core": "^5.9.2",
"@lingui/react": "^5.9.2",
"@miblanchard/react-native-slider": "^2.6.0",
"@mozzius/expo-dynamic-app-icon": "^1.8.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.15.5",
@@ -150,7 +151,7 @@
"babel-plugin-transform-remove-console": "^6.9.4",
"bcp-47": "^2.1.0",
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"date-fns": "^4.4.0",
"email-validator": "^2.0.4",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
+36
View File
@@ -13,3 +13,39 @@ index 47c4d15f6b10bbd77858cfff425cda9a618735b9..afe138d22d566244482498a7be0e14b8
// Check for Content-Type
val skipContentTypes = listOf(
"text/event-stream", // Server Sent Events
diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm
index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..99d0d140eddf95a8db7beb57c61dfcb12c1424b4 100644
--- a/ios/Core/ExpoBridgeModule.mm
+++ b/ios/Core/ExpoBridgeModule.mm
@@ -7,6 +7,9 @@
// The runtime executor is included as of React Native 0.74 in bridgeless mode.
#if __has_include(<ReactCommon/RCTRuntimeExecutor.h>)
#import <ReactCommon/RCTRuntimeExecutor.h>
+#else // React Native <0.74
+// dispatchBlock:queue: is declared in RCTBridge+Private.h, not the public header.
+#import <React/RCTBridge+Private.h>
#endif // React Native >=0.74
@implementation ExpoBridgeModule
@@ -46,7 +49,20 @@ - (void)setBridge:(RCTBridge *)bridge
_appContext.reactBridge = bridge;
#if !__has_include(<ReactCommon/RCTRuntimeExecutor.h>)
- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge];
+ // Hop the runtime install (and the prepareRuntime() chain it triggers via
+ // _runtime.didSet) onto RCTJSThread. The original line ran synchronously
+ // on whatever thread called setBridge: - typically the main thread - and
+ // raced JSIExecutor::initializeRuntime() on the JS thread, corrupting
+ // Hermes' Hades GC (HadesGC::writeBarrierSlow EXC_BAD_ACCESS).
+ __weak EXAppContext *weakAppContext = _appContext;
+ __weak RCTBridge *weakBridge = bridge;
+ [bridge dispatchBlock:^{
+ EXAppContext *strongAppContext = weakAppContext;
+ RCTBridge *strongBridge = weakBridge;
+ if (strongAppContext != nil && strongBridge != nil && strongAppContext._runtime == nil) {
+ strongAppContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:strongBridge];
+ }
+ } queue:RCTJSThread];
#endif // React Native <0.74
}
+24 -1
View File
@@ -1,3 +1,26 @@
## expo-modules-core Patch
This patch fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools
This patch contains two unrelated fixes:
### Android: bitdrift interceptor
Fixes an issue where bitdrift's API stream gets blocked by the Expo interceptor used to power the devtools.
### iOS: Hermes startup race in `ExpoBridgeModule.setBridge:`
On the legacy bridge (old architecture, where `RCTRuntimeExecutor.h` is
absent), `setBridge:` installed the Expo runtime synchronously on whatever
thread called it - typically the main thread, since RN's lazy module-load
path ignores `+requiresMainQueueSetup`. The `_runtime.didSet` then ran
`prepareRuntime()` (JSI mutations) on the main thread while the JS thread was
concurrently inside `JSIExecutor::initializeRuntime()`. Two threads mutating
the same Hermes runtime corrupted Hades GC, producing intermittent
`EXC_BAD_ACCESS` launch crashes (e.g. `HadesGC::writeBarrierSlow`,
`prepareRuntime` / `bindNativePerformanceNow`).
The fix hops the runtime install onto `RCTJSThread` so all JSI mutation is
serialized on the JS thread. This backports the upstream fix discussed in
expo/expo#45374; the racy `ExpoBridgeModule` is removed entirely in SDK 55+
(expo/expo#44351), so this patch can be dropped on that upgrade.
Refs: expo/expo#43003, expo/expo#45374, expo/expo#44351
@@ -1,5 +1,5 @@
diff --git a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da84061fd7f77c 100644
index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1a01093e7909973cef5268f999158df389e77634 100644
--- a/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
+++ b/src/components/KeyboardChatScrollView/useExtraContentPadding/index.ts
@@ -1,8 +1,6 @@
@@ -19,7 +19,7 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
inverted,
keyboardLiftBehavior,
freeze,
@@ -62,20 +59,14 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
@@ -62,20 +59,23 @@ function useExtraContentPadding(options: UseExtraContentPaddingOptions): void {
(target: number) => {
"worklet";
@@ -37,6 +37,15 @@ index 0f6d7c67a307885310ab184fdf9e7a5c7b296825..1e0bdd5b1d3b1eceb47bfb0050da8406
+ // otherwise the native ScrollView clamps contentOffset to the old
+ // contentInset range (iOS Fabric) or the old contentInsetBottom (Android).
+ requestAnimationFrame(() => {
+ // The ScrollView can detach between scheduling this frame and now (e.g.
+ // navigating away from the screen, or the list re-creating its scroll
+ // component). Once detached, scrollViewRef() resolves to null, and on
+ // Paper reanimated's scrollTo hands that null to the native
+ // _scrollToPaper HostFunction, which throws "Value is null, expected a
+ // number". Re-check the ref now that the frame has arrived.
+ if (scrollViewRef() == null) {
+ return;
+ }
scrollTo(scrollViewRef, 0, target, false);
- }
+ });
+116 -136
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.1.3
version: 11.1.3
specifier: 11.5.0
version: 11.5.0
pnpm:
specifier: 11.1.3
version: 11.1.3
specifier: 11.5.0
version: 11.5.0
packages:
'@pnpm/exe@11.1.3':
resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==}
'@pnpm/exe@11.5.0':
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
hasBin: true
'@pnpm/linux-arm64@11.1.3':
resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==}
'@pnpm/linux-arm64@11.5.0':
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.1.3':
resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==}
'@pnpm/linux-x64@11.5.0':
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.1.3':
resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==}
'@pnpm/linuxstatic-arm64@11.5.0':
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.1.3':
resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==}
'@pnpm/linuxstatic-x64@11.5.0':
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.1.3':
resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==}
'@pnpm/macos-arm64@11.5.0':
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.1.3':
resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==}
'@pnpm/win-arm64@11.5.0':
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.1.3':
resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==}
'@pnpm/win-x64@11.5.0':
resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.1.3:
resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==}
pnpm@11.5.0:
resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.1.3':
'@pnpm/exe@11.5.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.1.3
'@pnpm/linux-x64': 11.1.3
'@pnpm/linuxstatic-arm64': 11.1.3
'@pnpm/linuxstatic-x64': 11.1.3
'@pnpm/macos-arm64': 11.1.3
'@pnpm/win-arm64': 11.1.3
'@pnpm/win-x64': 11.1.3
'@pnpm/linux-arm64': 11.5.0
'@pnpm/linux-x64': 11.5.0
'@pnpm/linuxstatic-arm64': 11.5.0
'@pnpm/linuxstatic-x64': 11.5.0
'@pnpm/macos-arm64': 11.5.0
'@pnpm/win-arm64': 11.5.0
'@pnpm/win-x64': 11.5.0
'@pnpm/linux-arm64@11.1.3':
'@pnpm/linux-arm64@11.5.0':
optional: true
'@pnpm/linux-x64@11.1.3':
'@pnpm/linux-x64@11.5.0':
optional: true
'@pnpm/linuxstatic-arm64@11.1.3':
'@pnpm/linuxstatic-arm64@11.5.0':
optional: true
'@pnpm/linuxstatic-x64@11.1.3':
'@pnpm/linuxstatic-x64@11.5.0':
optional: true
'@pnpm/macos-arm64@11.1.3':
'@pnpm/macos-arm64@11.5.0':
optional: true
'@pnpm/win-arm64@11.1.3':
'@pnpm/win-arm64@11.5.0':
optional: true
'@pnpm/win-x64@11.1.3':
'@pnpm/win-x64@11.5.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.1.3: {}
pnpm@11.5.0: {}
---
lockfileVersion: '9.0'
@@ -222,13 +222,13 @@ patchedDependencies:
expo-image-picker@17.0.11: 47b28f6ddb8bcaa6483f8714c82daaa0001122f2b0dac6c05d19e98a61a6ceab
expo-image@3.0.11: 6d46ffac33426c5285777da5bf5f88c68c8ce27290595dfa65f38c478930c2c4
expo-media-library@18.2.1: 2b84c17999bb0c977eaef7fa8ac297f7074d91c94fb63726b42d2a277b26b39a
expo-modules-core@3.0.30: 86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace
expo-modules-core@3.0.30: 952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e
expo-notifications@0.32.17: a45a8dcf3d8c4b5df4ee0e62bc79b755fcf9d28ca51a6f685b85830bf4afc2e5
expo-updates@29.0.17: 04f28cb005b770e9ae8f0065eab96e43cbb1e58107f5f6ad1bdd18f6deb66487
react-native-compressor@1.13.0: 58379dfaace6ced8590cb341c77f2ca8099dfa8f7df6297032ec51de767a9925
react-native-date-picker@5.0.13: f2a6697da7a7ca79b4f39efda142eee1b82db6de3aa5010ad4bd59df41fe2ce1
react-native-drawer-layout@4.2.3: 74f2c043cc22ab87054f219e7c7373a509b779b18bfa79d27e7d051d73355130
react-native-keyboard-controller@1.21.8: 642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe
react-native-keyboard-controller@1.21.8: 2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1
react-native-pager-view@6.8.0: 0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108
react-native-reanimated@3.19.1: 97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6
react-native-svg@15.12.1: 31401fa6ff01498773afb51a7b066821c471eb3e71b0764a10017938beed49e9
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.6
version: 0.20.6
specifier: 0.20.8
version: 0.20.8
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -268,6 +268,9 @@ importers:
'@bsky.app/expo-translate-text':
specifier: ^0.2.9
version: 0.2.9(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/peek-menu':
specifier: ^0.2.4
version: 0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@bsky.app/react-native-mmkv':
specifier: 2.12.5
version: 2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -302,17 +305,17 @@ importers:
specifier: ^2.0.8
version: 2.1.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@formatjs/intl-displaynames':
specifier: ^6.8.13
version: 6.8.13
specifier: ^7.3.9
version: 7.3.9
'@formatjs/intl-locale':
specifier: ^4.2.13
version: 4.2.13
specifier: ^5.3.8
version: 5.3.8
'@formatjs/intl-numberformat':
specifier: ^8.15.6
version: 8.15.6
specifier: ^9.3.10
version: 9.3.10
'@formatjs/intl-pluralrules':
specifier: ^5.4.6
version: 5.4.6
specifier: ^6.3.9
version: 6.3.9
'@growthbook/growthbook':
specifier: ^1.6.5
version: 1.6.5
@@ -328,9 +331,6 @@ importers:
'@lingui/react':
specifier: ^5.9.2
version: 5.9.5(@lingui/babel-plugin-lingui-macro@5.9.5(typescript@6.0.3))(react@19.1.0)
'@miblanchard/react-native-slider':
specifier: ^2.6.0
version: 2.6.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@mozzius/expo-dynamic-app-icon':
specifier: ^1.8.0
version: 1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -416,8 +416,8 @@ importers:
specifier: ^2.0.3
version: 2.0.3
date-fns:
specifier: ^2.30.0
version: 2.30.0
specifier: ^4.4.0
version: 4.4.0
email-validator:
specifier: ^2.0.4
version: 2.0.4
@@ -633,7 +633,7 @@ importers:
version: 2.28.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-keyboard-controller:
specifier: ^1.21.8
version: 1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
version: 1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-pager-view:
specifier: 6.8.0
version: 6.8.0(patch_hash=0979d6fe1e2fa43b2784cc0b5e0ff0117d53b53423e85ee5855eefdc41a00108)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
'@atproto/api@0.20.8':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1652,6 +1652,13 @@ packages:
react: '*'
react-native: '*'
'@bsky.app/peek-menu@0.2.4':
resolution: {integrity: sha512-3E5FwgCXMU6baye3NWoBKih3SCh6s8AgAtxN523YBHZGC5TxpjGaBsjFi765y31nKmTll3QH7x4wtDgYqVvCQg==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
'@bsky.app/react-native-mmkv@2.12.5':
resolution: {integrity: sha512-3vUz1nQY1DiKIPAWRkpp5ZGxH5f2G6Ui0UuQuEYjYv81xx1qFcSzS9KQ2sHcOKYdkOM9amWV2Q8TQCxt1lrAHg==}
peerDependencies:
@@ -2065,33 +2072,32 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/bigdecimal@0.2.5':
resolution: {integrity: sha512-2XTKNrZRaCUyXK2976wfutqxMBuPO/S/zbJnQdysLI2Zy5mWPVNVEkE6tsTcSVWSE7DgO88t8DtBy+uf3I8bxg==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/fast-memoize@3.1.5':
resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==}
'@formatjs/intl-displaynames@6.8.13':
resolution: {integrity: sha512-VbY7BdYJX5eURVKLk2grndUQtnbCLNbcJId/Sb/PsX7fWXiqWvg7qt/mecVHRzqoSEoGCQToKDxzpJj8RC0s3g==}
'@formatjs/intl-displaynames@7.3.9':
resolution: {integrity: sha512-UELvrHS171ElQW9iMCGqMR/HmUYQPYkYSz1A/SSkQtJ62ZH7aUWC1QnXPxL0CInQmxa/MgC7oE402XBoTe5nTQ==}
'@formatjs/intl-enumerator@1.8.12':
resolution: {integrity: sha512-rT2hnyQ0q6RDG1jISMH+MP6wlHQLUC81C0BeYEzabJb48S4tU2HGCPUxsOqMa3vDAUmpnz9Kw5vYTD5hTkOVgg==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
'@formatjs/intl-getcanonicallocales@3.2.9':
resolution: {integrity: sha512-b1dLzhFSeccG5X1PY2KodVFRGd0p6tBPc7pRBJaW6A7+fMeZnwHOAJLkMsGNDyvM9XailkkstE4jzmNa1GVh5w==}
'@formatjs/intl-getcanonicallocales@2.5.6':
resolution: {integrity: sha512-CnBbc4St61RL06gDXlCZG08Gt41uiySgsdZNBExh8/c0FBONJCrAlQ9FsyalUdq9ze0nCeknJtMmO8JnB9xHgQ==}
'@formatjs/intl-locale@5.3.8':
resolution: {integrity: sha512-pJgqQlg4zFoyCiVMXlKPBWpY7fAxvsUiShNmB/l8BW2ZwmFMefMWCrDzgocSH9WR+Z3pkihfjh1XbalCZhXc1Q==}
'@formatjs/intl-locale@4.2.13':
resolution: {integrity: sha512-Qcs0mP9JhEPCinPfpZ5JKzOa+VYDg94TQSFZozYuqG8R+hEGUTKyoyMqB5xi5QZT/hlB0CSgeNc/0fWMGtTFXA==}
'@formatjs/intl-localematcher@0.8.9':
resolution: {integrity: sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@formatjs/intl-numberformat@9.3.10':
resolution: {integrity: sha512-wYboXVXZyTvuMBgwE0AxcXYxV8LM0IkH+lfCoBlRVNjCwNzdHVbRcUxDRHE+HbFkQ6/zWFkyguvIP5y3PAt+jw==}
'@formatjs/intl-numberformat@8.15.6':
resolution: {integrity: sha512-htynTNKm5WOnbR521tNSMkWzX3yO6Z77qjOxvRujh5/A/UBKeoNElyuKCJltizdx3X33QNWQZC4aWjLKcccyeQ==}
'@formatjs/intl-pluralrules@6.3.9':
resolution: {integrity: sha512-0405uU1npNs5/N04tfIQ3pme96Xb51tEkCBTBKy8HMjAsssbhnK70gf8v3ogj1WqHf7WFRoFZu8clQTsvwBQNg==}
'@formatjs/intl-pluralrules@5.4.6':
resolution: {integrity: sha512-2HlOq+c7KsSps829SJ3B5987coX5mzKx9NbPcNwQ07eq8FBHgB3HfMoxt5HvLsdk4oQwCjAEnocbtd+wVwZ2Kg==}
'@formatjs/intl-supportedvaluesof@2.3.7':
resolution: {integrity: sha512-ZTXEKijV5H08z19odngZPsXlmGndfukHuVnjd9qyER77XSk/yJwDMiSjoJYY5C5WxFs2UN4s/EMtu75e6Tp3ug==}
'@gorhom/portal@1.0.14':
resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==}
@@ -2321,12 +2327,6 @@ packages:
'@messageformat/parser@5.1.1':
resolution: {integrity: sha512-3p0YRGCcTUCYvBKLIxtDDyrJ0YijGIwrTRu1DT8gIviIDZru8H23+FkY6MJBzM1n9n20CiM4VeDYuBsrrwnLjg==}
'@miblanchard/react-native-slider@2.6.0':
resolution: {integrity: sha512-o7hk/f/8vkqh6QNR5L52m+ws846fQeD/qNCC9CCSRdBqjq66KiCgbxzlhRzKM/gbtxcvMYMIEEJ1yes5cr6I3A==}
peerDependencies:
react: '>=16.8'
react-native: '>=0.59'
'@mozzius/expo-dynamic-app-icon@1.8.1':
resolution: {integrity: sha512-JWNY9gw06s+q54b2SqWf6BEo7IYuJCtjJDtca+wTo6kP5dH/wYK85JdLrMbdy+cwOMScEY85uU6Mjn0wkWTLnw==}
peerDependencies:
@@ -4789,13 +4789,12 @@ packages:
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'}
date-fns@2.30.0:
resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==}
engines: {node: '>=0.11'}
date-fns@3.6.0:
resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==}
date-fns@4.4.0:
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
@@ -9494,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.6':
'@atproto/api@0.20.8':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
@@ -10472,6 +10471,12 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/peek-menu@0.2.4(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@bsky.app/react-native-mmkv@2.12.5(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
@@ -11055,56 +11060,38 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/bigdecimal@0.2.5': {}
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/fast-memoize@3.1.5': {}
'@formatjs/intl-displaynames@6.8.13':
'@formatjs/intl-displaynames@7.3.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
tslib: 2.8.1
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-enumerator@1.8.12':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-getcanonicallocales@3.2.9': {}
'@formatjs/intl-getcanonicallocales@2.5.6':
'@formatjs/intl-locale@5.3.8':
dependencies:
tslib: 2.8.1
'@formatjs/intl-getcanonicallocales': 3.2.9
'@formatjs/intl-supportedvaluesof': 2.3.7
'@formatjs/intl-locale@4.2.13':
'@formatjs/intl-localematcher@0.8.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-enumerator': 1.8.12
'@formatjs/intl-getcanonicallocales': 2.5.6
tslib: 2.8.1
'@formatjs/fast-memoize': 3.1.5
'@formatjs/intl-localematcher@0.6.2':
'@formatjs/intl-numberformat@9.3.10':
dependencies:
tslib: 2.8.1
'@formatjs/bigdecimal': 0.2.5
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-numberformat@8.15.6':
'@formatjs/intl-pluralrules@6.3.9':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/bigdecimal': 0.2.5
'@formatjs/intl-localematcher': 0.8.9
'@formatjs/intl-pluralrules@5.4.6':
'@formatjs/intl-supportedvaluesof@2.3.7':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize': 3.1.5
'@gorhom/portal@1.0.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
@@ -11462,11 +11449,6 @@ snapshots:
dependencies:
moo: 0.5.3
'@miblanchard/react-native-slider@2.6.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
'@mozzius/expo-dynamic-app-icon@1.8.1(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
'@expo/image-utils': 0.8.12
@@ -14235,12 +14217,10 @@ snapshots:
es-errors: 1.3.0
is-data-view: 1.0.2
date-fns@2.30.0:
dependencies:
'@babel/runtime': 7.29.2
date-fns@3.6.0: {}
date-fns@4.4.0: {}
debounce@1.2.1: {}
debug@2.6.9:
@@ -15033,7 +15013,7 @@ snapshots:
require-from-string: 2.0.2
resolve-from: 5.0.0
expo-modules-core@3.0.30(patch_hash=86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
expo-modules-core@3.0.30(patch_hash=952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
invariant: 2.2.4
react: 19.1.0
@@ -15169,7 +15149,7 @@ snapshots:
expo-font: 14.0.11(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-keep-awake: 15.0.8(expo@54.0.34(@babel/core@7.29.0)(react-native-webview@13.15.0(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react@19.1.0)
expo-modules-autolinking: 3.0.25
expo-modules-core: 3.0.30(patch_hash=86e57bb33bc6c3f7021e948e099c2b5378772147b92a82a16e2e90ddcb857ace)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo-modules-core: 3.0.30(patch_hash=952fb1c8cb6dc8a0d8ef4e1114942a341d89b025e011f1acfff788929997346e)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
pretty-format: 29.7.0
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
@@ -17926,7 +17906,7 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
react-native-keyboard-controller@1.21.8(patch_hash=642d128c971380a1858f7a938dd715d6eb3bbc260c93188b65580d92e2226afe)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
react-native-keyboard-controller@1.21.8(patch_hash=2f6791837622fb391c3e41fc044b91ddedbe503da14da86165ee0a73924b05f1)(react-native-reanimated@3.19.1(patch_hash=97a1967f37a4213fbab59e1fd59a1bf859b5efc79af5e1b528a47fe488e6c5d6)(@babel/core@7.29.0)(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
react: 19.1.0
react-native: 0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
+6 -6
View File
@@ -47,18 +47,18 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Shell} from '#/view/shell'
import {atoms as a, ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -113,7 +113,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
const hasCheckedLanding = useLandingEntry()
// init
useEffect(() => {
@@ -146,7 +146,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider>
<Fragment
// Resets the entire tree below when it changes:
@@ -246,12 +246,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<BottomSheetProvider>
<StarterPackProvider>
<LandingProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</LandingProvider>
</BottomSheetProvider>
</PortalProvider>
</LightboxStateProvider>
+6 -6
View File
@@ -40,17 +40,17 @@ import {
import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell'
import {Provider as ComposerProvider} from '#/state/shell/composer'
import {Provider as LandingProvider} from '#/state/shell/landing'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as OnboardingProvider} from '#/state/shell/onboarding'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {useLandingEntry} from '#/components/hooks/useLandingEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as LightboxStateProvider} from '#/components/Lightbox/state'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
@@ -92,7 +92,7 @@ function InnerApp() {
const {resumeSession} = useSessionApi()
const theme = useColorModeTheme()
const {t: l} = useLingui()
const hasCheckedReferrer = useStarterPackEntry()
const hasCheckedLanding = useLandingEntry()
// init
useEffect(() => {
@@ -125,7 +125,7 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<ContextMenuProvider>
<Splash isReady={isReady && hasCheckedReferrer}>
<Splash isReady={isReady && hasCheckedLanding}>
<VideoVolumeProvider>
<ActiveVideoProvider>
<Fragment
@@ -224,9 +224,9 @@ function App() {
<DialogStateProvider>
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<LandingProvider>
<InnerApp />
</StarterPackProvider>
</LandingProvider>
</PortalProvider>
</LightboxStateProvider>
</DialogStateProvider>
+20
View File
@@ -44,6 +44,7 @@ import {
type State,
} from '#/lib/routes/types'
import {bskyTitle} from '#/lib/strings/headings'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useUnreadNotifications} from '#/state/queries/notifications/unread'
import {useSession} from '#/state/session'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -81,6 +82,7 @@ import {MessagesScreen} from '#/screens/Messages/ChatList'
import {MessagesConversationScreen} from '#/screens/Messages/Conversation'
import {MessagesConversationSettingsScreen} from '#/screens/Messages/ConversationSettings'
import {MessagesInboxScreen} from '#/screens/Messages/Inbox'
import {MessagesJoinRequestsScreen} from '#/screens/Messages/JoinRequests'
import {MessagesSettingsScreen} from '#/screens/Messages/Settings'
import {ModerationScreen} from '#/screens/Moderation'
import {Screen as ModerationVerificationSettings} from '#/screens/Moderation/VerificationSettings'
@@ -487,6 +489,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesConversationSettingsScreen}
options={{title: title(msg`Group chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesJoinRequests"
getComponent={() => MessagesJoinRequestsScreen}
options={{title: title(msg`Requests to join`), requireAuth: true}}
/>
<Stack.Screen
name="MessagesSettings"
getComponent={() => MessagesSettingsScreen}
@@ -797,6 +804,19 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
// without a leading slash, so normalize before matching.
const normalizedPath = path.startsWith('/') ? path : `/${path}`
if (CHAT_INVITE_CODE_REGEX.test(normalizedPath.split('?')[0])) {
if (IS_NATIVE) {
return buildStateObject('HomeTab', 'Home', params)
}
return buildStateObject('Flat', 'Home', params)
}
if (IS_NATIVE) {
if (name === 'Search') {
return buildStateObject('SearchTab', 'Search', params)
-3
View File
@@ -9,14 +9,11 @@ export enum Features {
ImportContactsOnboardingDisable = 'import_contacts:onboarding:disable',
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
ImageUploadsHighResolution = 'image_uploads:high_resolution',
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
GroupChatsHasBeenReleased = 'group_chats:has_been_released',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
LargeVideoUploads = 'large_video_uploads:enable',
AATest = 'aa-test',
}
+16 -10
View File
@@ -28,23 +28,29 @@ type Layout = {
border?: boolean
}
type Props = {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
size?: number
moderationOpts?: ModerationOpts
}
export function AvatarBubbles({
animate = false,
profiles: allProfiles,
self = false,
size = 120,
moderationOpts,
}: Props) {
}: {
animate?: boolean
profiles: bsky.profile.AnyProfileView[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
* Set this to `true` for cases where every passed profile should appear,
* e.g. an invite preview where the owner is meaningful regardless of viewer.
*/
self?: boolean
size?: number
moderationOpts?: ModerationOpts
}) {
const {currentAccount} = useSession()
const profiles =
allProfiles.length > 2
? allProfiles.filter(p => p.did !== currentAccount?.did)
!self && allProfiles.length > 2
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
+3 -3
View File
@@ -757,11 +757,11 @@ export function useSharedButtonTextStyles() {
}
if (size === 'large') {
baseStyles.push(a.text_md, a.leading_snug, a.font_medium)
baseStyles.push(a.text_md, a.font_medium)
} else if (size === 'small') {
baseStyles.push(a.text_sm, a.leading_snug, a.font_medium)
baseStyles.push(a.text_sm, a.font_medium)
} else if (size === 'tiny') {
baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold)
baseStyles.push(a.text_xs, a.font_semi_bold)
}
return flatten(baseStyles)
+5 -1
View File
@@ -230,7 +230,11 @@ function LightboxGallery({
style={[
a.px_4xl,
a.py_2xl,
{backgroundColor: 'rgba(0, 0, 0, 0.45)'},
{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
// @ts-expect-error web only
backdropFilter: 'blur(16px)',
},
delayedFadeInAnim,
]}>
<Pressable
+63 -40
View File
@@ -1,18 +1,13 @@
import {useRef} from 'react'
import {
LayoutAnimation,
Pressable,
ScrollView,
StyleSheet,
View,
} from 'react-native'
import {LayoutAnimation, ScrollView, StyleSheet, View} from 'react-native'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {BlurView} from 'expo-blur'
import {useLingui} from '@lingui/react/macro'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, platform, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
type Props = {
@@ -46,37 +41,66 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
{paddingBottom: insets.bottom + 8},
]}>
<View style={[a.mx_md, styles.altWrap]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Expand alt text`}
accessibilityHint=""
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}>
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
</ScrollView>
<BlurView
intensity={16}
tint="dark"
style={[
// Tint kept over the blur so dense, text-heavy images stay
// readable. On Android the blur falls back to a flat overlay, so
// bump the opacity to keep the contrast the real blur provides
// elsewhere.
platform({
ios: {backgroundColor: 'rgba(0, 0, 0, 0.5)'},
android: {backgroundColor: 'rgba(0, 0, 0, 0.7)'},
}),
]}>
<ScrollView
style={{maxHeight}}
scrollEnabled={isAltExpanded}
onMomentumScrollBegin={() => {
isMomentumScrolling.current = true
}}
onMomentumScrollEnd={() => {
isMomentumScrolling.current = false
}}
contentContainerStyle={[a.px_md, a.py_sm]}>
<View
accessibilityRole="button"
accessibilityLabel={l`Expand alt text`}
accessibilityHint="">
{/*
* The press handlers must live on the Text itself, not on a
* wrapping Pressable. Text selection is driven by the platform's
* native text view long-press (RN Text on Android, UITextView on
* iOS). A parent touchable consumes that long-press before the
* selectable Text can begin a selection - on Android this prevents
* selection entirely. Keeping onPress/onLongPress on the Text lets
* the same native node own both the tap-to-expand and the
* long-press-to-select. The empty onLongPress is intentional: it
* reserves the long-press for the OS selection gesture instead of
* firing the expand toggle. RN exposes no API to arbitrate tap vs
* native selection on a single node, so this is the supported
* workaround, and it behaves consistently on both platforms.
*/}
<Text
emoji
selectable
style={[a.text_sm, {color: t.palette.white}]}
numberOfLines={isAltExpanded ? undefined : 3}
onPress={() => {
if (isMomentumScrolling.current) return
LayoutAnimation.configureNext({
duration: 450,
update: {type: 'spring', springDamping: 1},
})
onToggleAltExpanded()
}}
onLongPress={() => {}}>
{altText}
</Text>
</View>
</ScrollView>
</BlurView>
</View>
</View>
)
@@ -84,7 +108,6 @@ export function Footer({altText, isAltExpanded, onToggleAltExpanded}: Props) {
const styles = StyleSheet.create({
altWrap: {
backgroundColor: 'rgba(0, 0, 0, 0.45)',
borderRadius: 12,
overflow: 'hidden',
},
+10
View File
@@ -12,6 +12,7 @@ import {
} from '@react-navigation/native'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {useGroupChatJoinIntent} from '#/lib/hooks/useIntentHandler'
import {useNavigationDeduped} from '#/lib/hooks/useNavigationDeduped'
import {useOpenLink} from '#/lib/hooks/useOpenLink'
import {type AllNavigatorParams, type RouteParams} from '#/lib/routes/types'
@@ -19,6 +20,7 @@ import {shareUrl} from '#/lib/sharing'
import {
convertBskyAppUrlIfNeeded,
createProxiedUrl,
getChatInviteCodeFromUrl,
isBskyDownloadUrl,
isExternalUrl,
linkRequiresWarning,
@@ -130,6 +132,7 @@ export function useLink({
const {closeModal} = useModalControls()
const {linkWarningDialogControl} = useGlobalDialogsControlContext()
const openLink = useOpenLink()
const groupChatJoinIntent = useGroupChatJoinIntent()
const onPress = useCallback(
(e: GestureResponderEvent) => {
@@ -148,6 +151,12 @@ export function useLink({
e.preventDefault()
}
const chatInviteCode = getChatInviteCodeFromUrl(href)
if (chatInviteCode) {
groupChatJoinIntent(chatInviteCode, href)
return
}
if (requiresWarning) {
linkWarningDialogControl.open({
displayText,
@@ -228,6 +237,7 @@ export function useLink({
overridePresentation,
shouldProxy,
linkWarningDialogControl,
groupChatJoinIntent,
],
)
+60 -10
View File
@@ -1,11 +1,16 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyFeedDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {isGifEmbed} from '#/lib/strings/embed-player'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as PeekMenu from '#/components/PeekMenu'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import * as bsky from '#/types/bsky'
@@ -16,9 +21,11 @@ import * as bsky from '#/types/bsky'
export function Embed({
embed,
style,
peekable = false,
}: {
embed: AppBskyFeedDefs.PostView['embed']
style?: StyleProp<ViewStyle>
peekable?: boolean
}) {
const e = bsky.post.parseEmbed(embed)
@@ -27,13 +34,17 @@ export function Embed({
if (e.type === 'images') {
return (
<Outer style={style}>
{e.view.images.map(image => (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
))}
{e.view.images.map(image =>
peekable ? (
<PeekableImageItem key={image.thumb} image={image} />
) : (
<ImageItem
key={image.thumb}
thumbnail={image.thumb}
alt={image.alt}
/>
),
)}
</Outer>
)
} else if (e.type === 'link') {
@@ -156,6 +167,45 @@ export function VideoItem({
)
}
function PeekableImageItem({image}: {image: AppBskyEmbedImages.ViewImage}) {
const {t: l} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
return (
<PeekMenu.Root style={[a.flex_1, {maxWidth: 100}]}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: image.fullsize,
thumbUri: image.thumb,
aspectRatio: aspect && aspect > 0 ? aspect : 1,
}}
borderRadius={tokens.borderRadius.xs}>
<ImageItem thumbnail={image.thumb} alt={image.alt} />
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem
id="save"
onSelect={() => void saveImage(image.fullsize)}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem
id="share"
onSelect={() => void shareImageModal({uri: image.fullsize})}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
const styles = StyleSheet.create({
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
+1
View File
@@ -0,0 +1 @@
export * from '@bsky.app/peek-menu'
@@ -0,0 +1,79 @@
import {type ReactNode} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip'
import {useSaveImageToMediaLibrary} from '#/lib/media/save-image'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {Download_Stroke2_Corner0_Rounded as DownloadIcon} from '#/components/icons/Download'
import * as PeekMenu from '#/components/PeekMenu'
import {IS_IOS} from '#/env'
/**
* Wraps an image embed with the iOS peek-and-menu interaction. On non-iOS
* platforms this renders children unchanged.
*
* The aspect ratio is consumed by the native side to size the preview
* viewController correctly which is what makes the lift animation clean
* for portrait/panorama images.
*/
export function ImageContextMenu({
fullsizeUri,
thumbUri,
aspectRatio,
borderRadius,
onPreviewPress,
style,
children,
}: {
fullsizeUri: string
/** Thumbnail URL. Used as an instant placeholder in the native preview
* while the fullsize loads, so there's no black flash on first peek. */
thumbUri?: string
/** width / height; defaults to 1 if missing. */
aspectRatio: number | undefined
borderRadius?: number
onPreviewPress?: () => void
style?: StyleProp<ViewStyle>
children: ReactNode
}) {
const {t: l} = useLingui()
const saveImage = useSaveImageToMediaLibrary()
if (!IS_IOS) {
return children
}
const handleSave = () => {
void saveImage(fullsizeUri)
}
const handleShare = () => {
void shareImageModal({uri: fullsizeUri})
}
return (
<PeekMenu.Root style={style}>
<PeekMenu.Trigger
preview={{
type: 'image',
uri: fullsizeUri,
thumbUri,
aspectRatio: aspectRatio && aspectRatio > 0 ? aspectRatio : 1,
}}
borderRadius={borderRadius}
onPreviewPress={onPreviewPress}>
{children}
</PeekMenu.Trigger>
<PeekMenu.Menu>
<PeekMenu.MenuItem id="save" onSelect={handleSave}>
<PeekMenu.MenuItemIcon icon={DownloadIcon} />
<PeekMenu.MenuItemText>{l`Save image`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
<PeekMenu.MenuItem id="share" onSelect={handleShare}>
<PeekMenu.MenuItemIcon icon={ShareIcon} />
<PeekMenu.MenuItemText>{l`Share`}</PeekMenu.MenuItemText>
</PeekMenu.MenuItem>
</PeekMenu.Menu>
</PeekMenu.Root>
)
}
+48 -16
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react'
import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
@@ -8,6 +9,7 @@ import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {useLightboxControls} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
@@ -24,6 +26,11 @@ export function ImageEmbed({
const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef<AnimatedRef<any> | null>(null)
const singleDimsRef = useRef<Dimensions | null>(null)
if (images.length > 0) {
const items = images.map(img => ({
uri: img.fullsize,
@@ -59,24 +66,49 @@ export function ImageEmbed({
if (images.length === 1) {
const image = images[0]
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
const openFromSingle = () => {
if (singleContainerRef.current) {
onPress(0, [singleContainerRef.current], [singleDimsRef.current])
}
}
return (
<View style={[a.mt_sm, rest.style]}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onPress={(containerRef, dims) => onPress(0, [containerRef], [dims])}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openFromSingle}>
<AutoSizedImage
crop={
rest.viewContext === PostEmbedViewContext.ThreadHighlighted
? 'none'
: rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
? 'square'
: 'constrained'
}
image={image}
onContainerRef={ref => {
singleContainerRef.current = ref
}}
onDimsChange={dims => {
singleDimsRef.current = dims
}}
onPress={(containerRef, dims) =>
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={
rest.viewContext ===
PostEmbedViewContext.FeedEmbedRecordWithMedia
}
/>
</ImageContextMenu>
</View>
)
}
-3
View File
@@ -11,7 +11,6 @@ import {useLingui} from '@lingui/react/macro'
import {CountWheel} from '#/lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon'
import {useHaptics} from '#/lib/haptics'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {type Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
@@ -93,7 +92,6 @@ let PostControls = ({
const requireAuth = useRequireAuth()
const {sendInteraction} = useFeedFeedbackContext()
const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
@@ -116,7 +114,6 @@ let PostControls = ({
try {
setHasLikeIconBeenToggled(true)
if (!post.viewer?.like) {
playHaptic('Light')
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
+3 -1
View File
@@ -224,6 +224,7 @@ export function Basic({
cancelButtonCta,
confirmButtonCta,
onConfirm,
onClose,
confirmButtonColor,
showCancel = true,
}: React.PropsWithChildren<{
@@ -240,11 +241,12 @@ export function Basic({
* should NOT close the dialog as a side effect of this method.
*/
onConfirm: (e: GestureResponderEvent) => void
onClose?: () => void
confirmButtonColor?: ButtonColor
showCancel?: boolean
}>) {
return (
<Outer control={control} testID="confirmModal">
<Outer control={control} testID="confirmModal" onClose={onClose}>
<Content>
<TitleText>{title}</TitleText>
{description && <DescriptionText>{description}</DescriptionText>}
+1 -1
View File
@@ -66,7 +66,7 @@ export function RichText({
}
}, [value])
const plainStyles = [a.leading_snug, style]
const plainStyles = style
const interactiveStyles = [plainStyles, interactiveStyle]
const {text, facets} = richText
+1
View File
@@ -31,6 +31,7 @@ export function Text({
[
a.text_sm,
t.atoms.text,
a.leading_snug,
web(numberOfLines === 1 && numberOfLinesClippingFix),
style,
],
+1 -1
View File
@@ -219,7 +219,7 @@ export function AddMembersFlow({
}
}
if (searchText === '') {
if (searchText === '' && _items.length > 0) {
_items.unshift({
type: 'label',
key: 'suggested',
+6 -1
View File
@@ -33,14 +33,19 @@ export const AfterReportDialog = memo(function BlockOrDeleteDialogInner({
control,
params,
currentScreen,
onClose,
}: {
control: Dialog.DialogControlProps
params: ReportDialogParams
currentScreen: 'list' | 'conversation'
onClose?: () => void
}): React.ReactNode {
const {t: l} = useLingui()
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Outer
control={control}
onClose={onClose}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={l`Would you like to block this user and/or delete this conversation?`}
+1
View File
@@ -316,6 +316,7 @@ export function InitiateChatFlow({
if (
searchText === '' &&
_items.length > 0 &&
(chatState === ChatState.NEW_CHAT ||
chatState === ChatState.NEW_GROUP_CHAT)
) {
+64 -112
View File
@@ -1,5 +1,5 @@
import {memo, useCallback} from 'react'
import {LayoutAnimation, Platform} from 'react-native'
import {Platform} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
type ChatBskyConvoDefs,
@@ -7,26 +7,21 @@ import {
RichText,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo'
import {useLanguagePrefs} from '#/state/preferences'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
import * as ContextMenu from '#/components/ContextMenu'
import {type TriggerProps} from '#/components/ContextMenu/types'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag'
import {Language_Stroke2_Corner2_Rounded as LanguageIcon} from '#/components/icons/Language'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
@@ -48,11 +43,8 @@ export let MessageContextMenu = ({
const {t: l, i18n} = useLingui()
const ax = useAnalytics()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const blockOrDeleteControl = usePromptControl()
const {openDeleteMessage, openReportMessage} = useMessageDialogs()
const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate()
@@ -93,14 +85,6 @@ export let MessageContextMenu = ({
})
}, [ax, langPrefs.primaryLanguage, message.text, translate])
const onDelete = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(message.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, message.id])
const onEmojiSelect = useCallback(
(emoji: string) => {
if (
@@ -128,104 +112,72 @@ export let MessageContextMenu = ({
const sender = senderProfile
return (
<>
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
<ContextMenu.Root>
{IS_NATIVE && reactionsAvailable && (
<ContextMenu.AuxiliaryView
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<EmojiReactionPicker
message={message}
onEmojiSelect={onEmojiSelect}
/>
</ContextMenu.AuxiliaryView>
)}
<ContextMenu.Trigger
label={l`Message options`}
contentLabel={l`Message from @${
sender?.handle ?? 'unknown' // should always be defined
}: ${message.text}`}>
{children}
</ContextMenu.Trigger>
<ContextMenu.Outer
align={isFromSelf ? 'right' : 'left'}
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
{message.text.length > 0 && (
<>
<ContextMenu.Item
testID="messageDropdownTranslateBtn"
label={l`Translate`}
onPress={onPressTranslateMessage}>
<ContextMenu.ItemIcon icon={LanguageIcon} position="left" />
<ContextMenu.ItemText>{l`Translate`}</ContextMenu.ItemText>
</ContextMenu.Item>
<ContextMenu.Item
testID="messageDropdownCopyBtn"
label={l`Copy message text`}
onPress={onCopyMessage}>
<ContextMenu.ItemIcon icon={ClipboardIcon} position="left" />
<ContextMenu.ItemText>
{l`Copy message text`}
</ContextMenu.ItemText>
</ContextMenu.Item>
</>
)}
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => openDeleteMessage(message)}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownDeleteBtn"
label={l`Delete message for me`}
onPress={() => deleteControl.open()}>
<ContextMenu.ItemIcon icon={TrashIcon} position="left" />
<ContextMenu.ItemText>{l`Delete for me`}</ContextMenu.ItemText>
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => openReportMessage(message, senderProfile)}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
{!isFromSelf && (
<ContextMenu.Item
destructive
testID="messageDropdownReportBtn"
label={l`Report message`}
onPress={() => reportControl.open()}>
<ContextMenu.ItemIcon icon={FlagIcon} position="left" />
<ContextMenu.ItemText>{l`Report`}</ContextMenu.ItemText>
</ContextMenu.Item>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
<ReportDialog
control={reportControl}
subject={{
view: 'message',
convoId: convo.convo.view.id,
message,
}}
onAfterSubmit={() => {
if (sender) {
unstableCacheProfileView(queryClient, sender)
}
blockOrDeleteControl.open()
}}
/>
<AfterReportDialog
control={blockOrDeleteControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: message.sender.did,
}}
/>
<Prompt.Basic
control={deleteControl}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onDelete}
/>
</>
)}
</ContextMenu.Outer>
</ContextMenu.Root>
)
}
MessageContextMenu = memo(MessageContextMenu)
+8 -39
View File
@@ -40,8 +40,8 @@ import {useSession} from '#/state/session'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
@@ -49,7 +49,7 @@ import {RichText} from '#/components/RichText'
import {Text} from '#/components/Typography'
import {DateDivider} from './DateDivider'
import {MessageItemEmbed} from './MessageItemEmbed'
import {ReactionsDialog} from './ReactionsDialog'
import {groupReactions} from './ReactionsDialog'
import {CLUSTERED_MESSAGE_THRESHOLD_MS, MESSAGE_GAP_THRESHOLD_MS} from './util'
const AVATAR_SIZE = 28
@@ -118,7 +118,7 @@ let MessageItem = ({
const {message} = item
const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const reactionsControl = useDialogControl()
const {openReactions} = useMessageDialogs()
const isPending = item.type === 'pending-message'
@@ -243,34 +243,10 @@ let MessageItem = ({
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
)
const groupedReactions = useMemo(() => {
const reactions = message.reactions ?? []
const grouped = new Map<
string,
{
key: string
value: string
senders: ChatBskyConvoDefs.ReactionViewSender[]
count: number
}
>()
for (const reaction of reactions) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}, [message.reactions])
const groupedReactions = useMemo(
() => groupReactions(message.reactions),
[message.reactions],
)
const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
@@ -336,7 +312,7 @@ let MessageItem = ({
transform: [{translateY: -8}],
},
]}
onPress={isGroupChat ? reactionsControl.open : undefined}>
onPress={isGroupChat ? () => openReactions(message) : undefined}>
{groupedReactions.map(group => (
<Animated.View
entering={native(ZoomIn.springify(200).delay(400))}
@@ -377,13 +353,6 @@ let MessageItem = ({
</Pressable>
</View>
) : null}
<ReactionsDialog
control={reactionsControl}
relatedProfiles={relatedProfiles}
message={message}
reactions={message.reactions}
groupedReactions={groupedReactions}
/>
</LayoutAnimationConfig>
)
+175
View File
@@ -0,0 +1,175 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react'
import {LayoutAnimation} from 'react-native'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {useConvoActive} from '#/state/messages/convo'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useDialogControl} from '#/components/Dialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {ReactionsDialog} from '#/components/dms/ReactionsDialog'
import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import {usePromptControl} from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
type MessageDialogsContextType = {
openDeleteMessage: (message: ChatBskyConvoDefs.MessageView) => void
openReportMessage: (
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => void
openReactions: (message: ChatBskyConvoDefs.MessageView) => void
}
const Context = createContext<MessageDialogsContextType | null>(null)
export function useMessageDialogs() {
const ctx = useContext(Context)
if (!ctx) {
throw new Error('useMessageDialogs must be used within a MessageOverlays')
}
return ctx
}
export function MessageOverlays({children}: {children: React.ReactNode}) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const convo = useConvoActive()
const deleteControl = usePromptControl()
const reportControl = usePromptControl()
const afterReportControl = usePromptControl()
const reactionsControl = useDialogControl()
const [deleteTarget, setDeleteTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reportTarget, setReportTarget] = useState<{
message: ChatBskyConvoDefs.MessageView
senderProfile: bsky.profile.AnyProfileView | undefined
} | null>(null)
const [afterReportTarget, setAfterReportTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const [reactionsTarget, setReactionsTarget] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setDeleteTarget(message)
deleteControl.open()
},
[deleteControl],
)
const openReportMessage = useCallback(
(
message: ChatBskyConvoDefs.MessageView,
senderProfile: bsky.profile.AnyProfileView | undefined,
) => {
setReportTarget({message, senderProfile})
reportControl.open()
},
[reportControl],
)
const openReactions = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setReactionsTarget(message)
},
[],
)
// These dialogs are conditionally mounted, so we can't open them in the same
// tick that we set their targets - the control refs aren't attached yet. Open
// in an effect after the dialog has mounted.
useEffect(() => {
if (reactionsTarget) {
reactionsControl.open()
}
}, [reactionsTarget, reactionsControl])
useEffect(() => {
if (afterReportTarget) {
afterReportControl.open()
}
}, [afterReportTarget, afterReportControl])
const onConfirmDelete = useCallback(() => {
if (!deleteTarget) return
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
convo
.deleteMessage(deleteTarget.id)
.then(() => Toast.show(l({message: 'Message deleted', context: 'toast'})))
.catch(() => Toast.show(l`Failed to delete message`))
}, [l, convo, deleteTarget])
const onAfterReportSubmit = useCallback(() => {
if (!reportTarget) return
if (reportTarget.senderProfile) {
unstableCacheProfileView(queryClient, reportTarget.senderProfile)
}
setAfterReportTarget(reportTarget.message)
}, [queryClient, reportTarget])
const ctx = useMemo<MessageDialogsContextType>(
() => ({openDeleteMessage, openReportMessage, openReactions}),
[openDeleteMessage, openReportMessage, openReactions],
)
const reportSubject = reportTarget
? ({
view: 'message',
convoId: convo.convo.view.id,
message: reportTarget.message,
} as const)
: undefined
return (
<Context.Provider value={ctx}>
{children}
<ReportDialog
control={reportControl}
subject={reportSubject}
onAfterSubmit={onAfterReportSubmit}
onClose={() => setReportTarget(null)}
/>
{afterReportTarget && (
<AfterReportDialog
control={afterReportControl}
currentScreen="conversation"
params={{
convoId: convo.convo.view.id,
did: afterReportTarget.sender.did,
}}
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsTarget && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsTarget}
onClose={() => setReactionsTarget(null)}
/>
)}
<Prompt.Basic
control={deleteControl}
title={l`Delete message`}
description={l`Are you sure you want to delete this message? The message will be deleted for you, but not for the other participants.`}
confirmButtonCta={l`Delete`}
confirmButtonColor="negative"
onConfirm={onConfirmDelete}
onClose={() => setDeleteTarget(null)}
/>
</Context.Provider>
)
}
+32 -6
View File
@@ -1,4 +1,4 @@
import {useRef, useState} from 'react'
import {useMemo, useRef, useState} from 'react'
import {
LayoutAnimation,
Pressable,
@@ -37,14 +37,12 @@ export function ReactionsDialog({
control,
relatedProfiles,
message,
reactions,
groupedReactions,
onClose,
}: {
control: Dialog.DialogControlProps
relatedProfiles: Map<string, ChatBskyActorDefs.ProfileViewBasic>
message: ChatBskyConvoDefs.MessageView
reactions?: ChatBskyConvoDefs.ReactionView[]
groupedReactions?: Reaction[]
onClose?: () => void
}) {
const {t: l} = useLingui()
@@ -54,6 +52,9 @@ export function ReactionsDialog({
const [selected, setSelected] = useState('all')
const reactions = message.reactions
const groupedReactions = useMemo(() => groupReactions(reactions), [reactions])
const filteredReactions = reactions?.filter(
r => selected === 'all' || r.value === selected,
)
@@ -78,7 +79,10 @@ export function ReactionsDialog({
return (
<Dialog.Outer
control={control}
onClose={() => setSelected('all')}
onClose={() => {
setSelected('all')
onClose?.()
}}
nativeOptions={{
preventExpansion: true,
minHeight: screenHeight / 2,
@@ -388,3 +392,25 @@ function ReactionTab({
</Pressable>
)
}
export function groupReactions(
reactions: ChatBskyConvoDefs.ReactionView[] | undefined,
): Reaction[] {
const grouped = new Map<string, Reaction>()
for (const reaction of reactions ?? []) {
if (!reaction) continue
const existing = grouped.get(reaction.value)
if (existing) {
existing.senders.push(reaction.sender)
existing.count++
} else {
grouped.set(reaction.value, {
key: reaction.value,
value: reaction.value,
senders: [reaction.sender],
count: 1,
})
}
}
return Array.from(grouped.values())
}
+7 -3
View File
@@ -212,9 +212,12 @@ export function Item({
)
const highlightStyle = highlightRow
? selected
? [a.rounded_full, a.p_md, {backgroundColor: t.palette.primary_50}]
: [a.rounded_full, a.p_md]
? [
a.rounded_full,
a.p_md,
hovered && t.atoms.bg_contrast_25,
selected && {backgroundColor: t.palette.primary_50},
]
: null
return (
@@ -588,6 +591,7 @@ export function RadioWithLabel({
style={[
a.font_medium,
a.flex_1,
a.text_md,
a.leading_tight,
selected ? t.atoms.text : t.atoms.text_contrast_high,
]}>
@@ -1,22 +1,45 @@
import {useEffect, useState} from 'react'
import * as Linking from 'expo-linking'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {
createStarterPackLinkFromAndroidReferrer,
httpStarterPackUriToAtUri,
} from '#/lib/strings/starter-pack'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {useHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {
useSetActiveLanding,
useSetActiveStarterPack,
} from '#/state/shell/landing'
import {IS_ANDROID} from '#/env'
import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
export function useStarterPackEntry() {
export function useLandingEntry() {
const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack()
const setActiveLanding = useSetActiveLanding()
const hasCheckedForStarterPack = useHasCheckedForStarterPack()
useEffect(() => {
if (ready) return
// Check for group chat invite link from the initial deep link URL
const linkingUrl = Linking.getLinkingURL()
if (linkingUrl) {
const urlp = parseLinkingUrl(linkingUrl)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
setActiveLanding({
type: 'groupchat',
uri: linkingUrl,
code: chatInviteMatch[1],
})
setReady(true)
return
}
}
// On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
// let's just ensure we never check again after the first time.
if (hasCheckedForStarterPack) {
@@ -29,7 +52,8 @@ export function useStarterPackEntry() {
setReady(true)
}, 500)
;(async () => {
void (async () => {
// Check for starter pack
let uri: string | null | undefined
if (IS_ANDROID) {
@@ -58,7 +82,7 @@ export function useStarterPackEntry() {
return () => {
clearTimeout(timeout)
}
}, [ready, setActiveStarterPack, hasCheckedForStarterPack])
}, [ready, setActiveStarterPack, setActiveLanding, hasCheckedForStarterPack])
return ready
}
@@ -1,25 +1,27 @@
import {useEffect, useState} from 'react'
import {httpStarterPackUriToAtUri} from '#/lib/strings/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {useSetActiveStarterPack} from '#/state/shell/landing'
export function useStarterPackEntry() {
export function useLandingEntry() {
const [ready, setReady] = useState(false)
const setActiveStarterPack = useSetActiveStarterPack()
useEffect(() => {
const href = window.location.href
const atUri = httpStarterPackUriToAtUri(href)
const url = new URL(href)
// Check for starter pack
const atUri = httpStarterPackUriToAtUri(href)
if (atUri) {
const url = new URL(href)
// Determines if an App Clip is loading this landing page
const isClip = url.searchParams.get('clip') === 'true'
setActiveStarterPack({
uri: atUri,
isClip,
})
setReady(true)
return
}
setReady(true)
+25 -5
View File
@@ -1,8 +1,20 @@
import {forwardRef} from 'react'
import {
forwardRef,
type ForwardRefExoticComponent,
type RefAttributes,
} from 'react'
import Svg, {Path} from 'react-native-svg'
import {type Props, useCommonSVGProps} from '#/components/icons/common'
export type IconWithSvgMeta = ForwardRefExoticComponent<
Props & RefAttributes<Svg>
> & {
svgPaths: string[]
svgViewBox: string
svgStrokeWidth: number
}
export const IconTemplate_Stroke2_Corner0_Rounded = forwardRef(
function LogoImpl(props: Props, ref) {
const {fill, size, style, ...rest} = useCommonSVGProps(props)
@@ -41,7 +53,7 @@ export function createSinglePathSVG({
strokeLinecap?: 'butt' | 'round' | 'square'
strokeLinejoin?: 'miter' | 'round' | 'bevel'
}) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
const hasStroke = strokeWidth > 0
@@ -68,7 +80,11 @@ export function createSinglePathSVG({
/>
</Svg>
)
})
}) as IconWithSvgMeta
Icon.svgPaths = [path]
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = strokeWidth
return Icon
}
export function createMultiPathSVG({
@@ -78,7 +94,7 @@ export function createMultiPathSVG({
paths: string[]
viewBox?: string
}) {
return forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const Icon = forwardRef<Svg, Props>(function LogoImpl(props, ref) {
const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
return (
@@ -102,5 +118,9 @@ export function createMultiPathSVG({
))}
</Svg>
)
})
}) as IconWithSvgMeta
Icon.svgPaths = paths
Icon.svgViewBox = viewBox || '0 0 24 24'
Icon.svgStrokeWidth = 0
return Icon
}
+15 -2
View File
@@ -1,4 +1,4 @@
import {useMemo, useRef} from 'react'
import {useEffect, useMemo, useRef} from 'react'
import {type DimensionValue, Pressable, View} from 'react-native'
import Animated, {
type AnimatedRef,
@@ -69,6 +69,8 @@ export function AutoSizedImage({
onPress,
onLongPress,
onPressIn,
onContainerRef,
onDimsChange,
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
@@ -79,6 +81,11 @@ export function AutoSizedImage({
) => void
onLongPress?: () => void
onPressIn?: () => void
/** Fires once with the internal container ref so a parent can drive its
* own lightbox-return animation without waiting for an `onPress`. */
onContainerRef?: (ref: AnimatedRef<any>) => void
/** Fires when the underlying image reports its natural dimensions. */
onDimsChange?: (dims: Dimensions) => void
}) {
const t = useTheme()
const {_} = useLingui()
@@ -86,6 +93,10 @@ export function AutoSizedImage({
const containerRef = useAnimatedRef()
const fetchedDimsRef = useRef<{width: number; height: number} | null>(null)
useEffect(() => {
onContainerRef?.(containerRef)
}, [containerRef, onContainerRef])
let aspectRatio: number | undefined
const dims = image.aspectRatio
if (dims) {
@@ -122,10 +133,12 @@ export function AutoSizedImage({
accessibilityHint=""
onLoad={e => {
if (!isContain) {
fetchedDimsRef.current = {
const dims = {
width: e.source.width,
height: e.source.height,
}
fetchedDimsRef.current = dims
onDimsChange?.(dims)
}
}}
loading="lazy"
+141 -131
View File
@@ -24,7 +24,7 @@ import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
@@ -36,6 +36,7 @@ import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandle
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
@@ -264,6 +265,21 @@ export function Gallery({
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
const openLightboxAtIndex = onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
return (
<GalleryImage
hideBadges={hideBadges}
@@ -288,24 +304,9 @@ export function Gallery({
onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims)
}}
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onPreviewPress={openLightboxAtIndex}
/>
)
}}
@@ -378,6 +379,7 @@ function GalleryImage({
onThumbDims,
onPress,
onPressIn,
onPreviewPress,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
@@ -391,6 +393,7 @@ function GalleryImage({
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
onPreviewPress?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
@@ -416,124 +419,131 @@ function GalleryImage({
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={({pressed}) => [
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web([
{
cursor: 'inherit',
outline: 0,
border: 0,
},
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspectRatio}
borderRadius={tokens.borderRadius.md}
onPreviewPress={onPreviewPress}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
style={({pressed}) => [
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web([
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
cursor: 'inherit',
outline: 0,
border: 0,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
a.transition_transform,
{transitionDuration: '200ms'},
pressed && {transform: [{scale: 0.99}]},
]),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
}
/>
</Pressable>
/>
</Pressable>
</ImageContextMenu>
</Animated.View>
)
}
+56 -38
View File
@@ -9,8 +9,9 @@ import {Trans} from '@lingui/react/macro'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
@@ -52,46 +53,63 @@ export function GalleryItem({
const hasAlt = !!image.alt
const hideBadges =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
? image.aspectRatio.width / image.aspectRatio.height
: undefined
// The tap handler and the peek-commit handler do the same thing: open the
// lightbox with this cell's ref + dims so the lightbox's return animation
// can target the original thumbnail.
const openLightboxAtIndex = onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
return (
<View style={a.flex_1} ref={containerRefs[index]} collapsable={false}>
<Pressable
onPress={
onPress
? () => onPress(index, containerRefs, thumbDimsRef.current.slice())
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
<ImageContextMenu
fullsizeUri={image.fullsize}
thumbUri={image.thumb}
aspectRatio={aspect}
borderRadius={tokens.borderRadius.md}
onPreviewPress={openLightboxAtIndex}
style={a.flex_1}>
<Pressable
onPress={openLightboxAtIndex}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
style={[
a.flex_1,
a.overflow_hidden,
t.atoms.bg_contrast_25,
imageStyle,
]}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[a.flex_1]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
onLoad={e => {
thumbDimsRef.current[index] = {
width: e.source.width,
height: e.source.height,
}
}}
loading="lazy"
/>
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? (
<View
accessible={false}
@@ -0,0 +1,432 @@
import {View} from 'react-native'
import {
ChatBskyGroupRequestJoin,
ChatBskyGroupWithdrawJoinRequest,
moderateProfile,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useRequestJoinGroupChat} from '#/state/queries/messages/request-join-group-chat'
import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
import {useSession} from '#/state/session'
import {atoms as a, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {
Button,
type ButtonColor,
ButtonIcon,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {InlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {ProfileBadges} from '../ProfileBadges'
export function GroupChatJoinDialog() {
const {groupChatJoinDialogControl, groupChatJoinState} = useIntentDialogs()
return (
<Dialog.Outer
control={groupChatJoinDialogControl}
nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<GroupChatJoinDialogInner code={groupChatJoinState?.code} />
</Dialog.Outer>
)
}
function GroupChatJoinDialogInner({code}: {code?: string}) {
const {t: l} = useLingui()
return (
<Dialog.ScrollableInner
label={l`Join group chat`}
style={[web({maxWidth: 400, borderRadius: 36})]}>
<View style={[a.gap_2xl, a.align_center]}>
<GroupChatJoinDialogContent code={code} />
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function GroupChatJoinDialogContent({code}: {code?: string}) {
const t = useTheme()
const {t: l} = useLingui()
const {groupChatJoinDialogControl: control} = useIntentDialogs()
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession,
})
const {mutate: joinGroupChat, isPending: isJoinPending} =
useRequestJoinGroupChat({
onSuccess: data => {
switch (data.status) {
case 'pending':
control.close(() => {
Toast.show(
l`Access requested! The group owner will review your request.`,
)
})
break
case 'joined': {
if (data.convo && data.convo.id) {
control.close(() => {
Toast.show(l`Successfully joined the group chat!`)
navigation.navigate('MessagesConversation', {
conversation: data.convo!.id,
})
})
} else {
logger.warn('Request to join group chat returned no convo ID', {
status: data.status,
convoId: data.convo?.id,
})
}
break
}
}
},
onError: error => {
let errorMessage = l`Failed to join the group chat. Please try again.`
if (isNetworkError(error)) {
errorMessage = l`There was a problem with your internet connection, please try again`
} else if (error instanceof ChatBskyGroupRequestJoin.ConvoLockedError) {
errorMessage = l`This conversation is locked.`
} else if (
error instanceof ChatBskyGroupRequestJoin.FollowRequiredError
) {
errorMessage = l`Only followers can join this group chat.`
} else if (error instanceof ChatBskyGroupRequestJoin.InvalidCodeError) {
errorMessage = l`Invalid group chat code.`
} else if (
error instanceof ChatBskyGroupRequestJoin.LinkDisabledError
) {
errorMessage = l`This invite link has been disabled.`
} else if (
error instanceof ChatBskyGroupRequestJoin.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
} else if (error instanceof ChatBskyGroupRequestJoin.UserKickedError) {
errorMessage = l`You have been removed from this group.`
}
Toast.show(errorMessage)
},
})
const {mutate: withdrawRequest, isPending: isWithdrawPending} =
useWithdrawJoinGroupChatRequest({
onSuccess: () => {
control.close(() => {
Toast.show(l`Join request rescinded.`)
})
},
onError: error => {
let errorMessage = l`Failed to rescind your request. Please try again.`
if (isNetworkError(error)) {
errorMessage = l`There was a problem with your internet connection, please try again`
} else if (
error instanceof
ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError
) {
errorMessage = l`Invalid rescind request.`
}
Toast.show(errorMessage)
},
})
const {
state: interacted,
onIn: onInteract,
onOut: onInteractOut,
} = useInteractionState()
const handleJoin = () => {
if (!code) return
joinGroupChat({code})
}
const handleWithdraw = () => {
if (!convoId) return
withdrawRequest({convoId})
}
// Fallback if the prefetch exceeds the timeout
if (isLoading || !data || !moderationOpts) {
return (
<View style={[a.p_2xl]}>
<Loader size="xl" />
</View>
)
}
if (error) {
return (
<>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text
style={[a.text_center, a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>This invite link is invalid</Trans>
</Text>
<Button
label={l`Close this dialog`}
accessibilityHint={l`Close this dialog`}
onPress={() => control.close()}
color="primary"
size="large"
style={[a.w_full]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
</>
)
}
const joinLinkPreview = data.joinLinkPreviews[0]
if (!joinLinkPreview) {
return (
<>
<View style={[a.py_lg, a.align_center]}>
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
<WarningIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.font_medium,
t.atoms.text_contrast_high,
]}>
<Trans>Chat invite link no longer available</Trans>
</Text>
</View>
</View>
<Button
testID="joinButton"
onPress={() => control.close()}
label={l`Close this dialog`}
accessibilityHint={l`Close this dialog`}
size="large"
color="secondary"
style={[a.w_full]}>
<ButtonText>{l`Close`}</ButtonText>
</Button>
</>
)
}
const convoId = joinLinkPreview.convo?.id
const isFollowing = joinLinkPreview.owner.viewer?.following ?? false
const hasRequested = !convoId && joinLinkPreview.viewer?.requestedAt != null
let canJoin = true
let ButtonIconImage = isJoinPending || isWithdrawPending ? Loader : JoinIcon
let buttonText = joinLinkPreview.requireApproval
? l`Request to join`
: l`Join`
let buttonColor: ButtonColor = 'primary'
if (joinLinkPreview.enabledStatus !== 'enabled') {
canJoin = false
ButtonIconImage = WarningIcon
buttonText = l`Chat invite link no longer available`
buttonColor = 'secondary'
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`This chat is full`
buttonColor = 'secondary'
} else if (joinLinkPreview.joinRule === 'followedByOwner' && !isFollowing) {
canJoin = false
ButtonIconImage = HandIcon
buttonText = l`Only people the chat owner follows can join`
buttonColor = 'secondary'
} else if (hasRequested) {
ButtonIconImage = XIcon
buttonText = l`Rescind request`
buttonColor = 'secondary'
}
return (
<>
<View style={[a.py_lg, a.align_center]}>
<AvatarBubbles
profiles={[
joinLinkPreview.owner,
...Array(joinLinkPreview.memberCount - 1).fill(undefined),
]}
self
size={135}
/>
<View style={[a.gap_sm, a.align_center, a.mt_lg]}>
<View>
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.font_medium,
t.atoms.text_contrast_high,
]}>
<Trans>Group chat</Trans>
</Text>
<Text
style={[a.text_center, a.text_3xl, a.font_bold, t.atoms.text]}>
{joinLinkPreview.name}
</Text>
</View>
<View style={[a.flex_row, a.align_center]}>
<Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
<Trans comment="The number of active group chat members out of the total number allowed.">
{joinLinkPreview.memberCount}/{joinLinkPreview.memberLimit}{' '}
members
</Trans>
</Text>
<View style={[a.flex_row, a.ml_md]}>
<PersonGroupIcon
size="xs"
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
/>
</View>
<Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
{joinLinkPreview.joinRule === 'followedByOwner'
? l`Followers can join`
: l`Anyone can join`}
</Text>
</View>
<View>
<View
style={[a.flex_row, a.gap_xs, a.align_center, a.justify_center]}>
<Text
style={[
a.mb_2xs,
a.text_center,
a.text_sm,
a.leading_snug,
a.font_semi_bold,
t.atoms.text,
]}>
By{' '}
<InlineLinkText
label={`@${joinLinkPreview.owner.handle}`}
to={makeProfileLink(joinLinkPreview.owner)}
style={[
a.mb_2xs,
a.text_sm,
a.font_semi_bold,
t.atoms.text,
interacted && {
...web({
outline: 0,
textDecorationLine: 'underline',
textDecorationColor: t.palette.contrast_1000,
}),
},
]}
{...web({
onMouseEnter: () => {
onInteract()
},
onMouseLeave: () => {
onInteractOut()
},
})}>
{createSanitizedDisplayName(
joinLinkPreview.owner,
true,
moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
'displayName',
),
)}
</InlineLinkText>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
size="sm"
style={{marginTop: -3}}
/>
</View>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{sanitizeHandle(joinLinkPreview.owner.handle, '@')}
</Text>
</View>
</View>
</View>
{convoId ? (
<Button
testID="openButton"
onPress={() => {
control.close(() => {
navigation.navigate('MessagesConversation', {
conversation: convoId,
})
})
}}
label={l`Open group chat`}
accessibilityHint={l`Open this group chat`}
size="large"
color="primary"
disabled={!code}
style={[a.w_full]}>
<ButtonText>Open chat</ButtonText>
<ButtonIcon icon={ArrowRightIcon} />
</Button>
) : (
<Button
testID="joinButton"
onPress={hasRequested ? handleWithdraw : handleJoin}
label={
joinLinkPreview.requireApproval
? l`Request access to group chat`
: l`Join group chat`
}
accessibilityHint={
joinLinkPreview.requireApproval
? l`Request access to join this group chat`
: l`Join this group chat`
}
size="large"
color={buttonColor}
disabled={isJoinPending || isWithdrawPending || !code || !canJoin}
style={[a.w_full]}>
<ButtonIcon icon={ButtonIconImage} />
<ButtonText>{buttonText}</ButtonText>
</Button>
)}
</>
)
}
+69 -2
View File
@@ -1,13 +1,30 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {
useActiveGroupChatJoinRequest,
useSetActiveLanding,
} from '#/state/shell/landing'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {GroupChatJoinDialog} from '#/components/intents/GroupChatJoinDialog'
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
interface Context {
verifyEmailDialogControl: DialogControlProps
verifyEmailState: {code: string} | undefined
setVerifyEmailState: (state: {code: string} | undefined) => void
groupChatJoinDialogControl: DialogControlProps
groupChatJoinState: {code: string} | undefined
setGroupChatJoinState: (state: {code: string} | undefined) => void
}
const Context = createContext({} as Context)
@@ -19,20 +36,70 @@ export function Provider({children}: {children: React.ReactNode}) {
const [verifyEmailState, setVerifyEmailState] = useState<
{code: string} | undefined
>()
const groupChatJoinDialogControl = Dialog.useDialogControl()
const [groupChatJoinState, setGroupChatJoinState] = useState<
{code: string} | undefined
>()
const {hasSession} = useSession()
const groupChatLanding = useActiveGroupChatJoinRequest()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
const landingHandledRef = useRef(false)
useEffect(() => {
if (hasSession && groupChatLanding && !landingHandledRef.current) {
landingHandledRef.current = true
const code = groupChatLanding.code
setActiveLanding(undefined)
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
setGroupChatJoinState({code})
groupChatJoinDialogControl.open()
})
}
if (!groupChatLanding) {
landingHandledRef.current = false
}
}, [
hasSession,
groupChatLanding,
setActiveLanding,
setGroupChatJoinState,
prefetchJoinLinkPreviews,
groupChatJoinDialogControl,
])
const value = useMemo(
() => ({
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
}),
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
[
verifyEmailDialogControl,
verifyEmailState,
setVerifyEmailState,
groupChatJoinDialogControl,
groupChatJoinState,
setGroupChatJoinState,
],
)
return (
<Context.Provider value={value}>
{children}
<VerifyEmailIntentDialog />
<GroupChatJoinDialog />
</Context.Provider>
)
}
@@ -75,9 +75,11 @@ export function ReportDialog(
() => (props.subject ? parseReportSubject(props.subject) : undefined),
[props.subject],
)
const propsOnClose = props.onClose
const onClose = useCallback(() => {
ax.metric('reportDialog:close', {})
}, [ax])
propsOnClose?.()
}, [ax, propsOnClose])
return (
<Dialog.Outer control={props.control} onClose={onClose}>
<Dialog.Handle />
@@ -88,4 +88,8 @@ export type ReportDialogProps = {
* Called if the report was successfully submitted.
*/
onAfterSubmit?: () => void
/**
* Called after the dialog finishes closing.
*/
onClose?: () => void
}
+2 -21
View File
@@ -51,16 +51,10 @@ interface PostOpts {
langs?: string[]
}
type FeatureFlags = {
highResolutionImages?: boolean
increasedBlobSizeLimit?: boolean
}
export async function post(
agent: BskyAgent,
queryClient: QueryClient,
opts: PostOpts,
featureFlags?: FeatureFlags,
) {
const thread = opts.thread
opts.onStateChange?.(t`Processing...`)
@@ -97,7 +91,6 @@ export async function post(
queryClient,
draft,
opts.onStateChange,
featureFlags,
)
let labels: $Typed<ComAtprotoLabelDefs.SelfLabels> | undefined
if (draft.labels.length) {
@@ -257,7 +250,6 @@ async function resolveEmbed(
queryClient: QueryClient,
draft: PostDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedVideo.Main>
@@ -268,13 +260,7 @@ async function resolveEmbed(
> {
if (draft.embed.quote) {
const [resolvedMedia, resolvedQuote] = await Promise.all([
resolveMedia(
agent,
queryClient,
draft.embed,
onStateChange,
featureFlags,
),
resolveMedia(agent, queryClient, draft.embed, onStateChange),
resolveRecord(agent, queryClient, draft.embed.quote.uri),
])
if (resolvedMedia) {
@@ -297,7 +283,6 @@ async function resolveEmbed(
queryClient,
draft.embed,
onStateChange,
featureFlags,
)
if (resolvedMedia) {
return resolvedMedia
@@ -323,7 +308,6 @@ async function resolveMedia(
queryClient: QueryClient,
embedDraft: EmbedDraft,
onStateChange: ((state: string) => void) | undefined,
featureFlags?: FeatureFlags,
): Promise<
| $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main>
@@ -339,10 +323,7 @@ async function resolveMedia(
const images: AppBskyEmbedImages.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing image #${i}`)
const {path, width, height, mime} = await compressImage(image, {
highResolution: featureFlags?.highResolutionImages,
increasedBlobSizeLimit: featureFlags?.increasedBlobSizeLimit,
})
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
+4
View File
@@ -246,6 +246,10 @@ async function resolveExternal(
title: result.title ?? '',
description: result.description ?? '',
thumb: result.image ? await imageToThumb(result.image) : undefined,
/*
* New fields from Standard Site integration. Other fields are derived from
* opengraph/oembed as before.
*/
associatedRefs: result.associatedRefs,
view: result.view,
}
+3 -2
View File
@@ -13,6 +13,7 @@ export const PUBLIC_BSKY_SERVICE = 'https://public.api.bsky.app'
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}`
export const CHAT_SERVICE = 'https://api.bsky.chat'
export const EMBED_SERVICE = 'https://embed.bsky.app'
export const EMBED_SCRIPT = `${EMBED_SERVICE}/static/embed.js`
export const BSKY_DOWNLOAD_URL = 'https://bsky.app/download'
@@ -188,8 +189,8 @@ export const VIDEO_MAX_DURATION_MS = 3 * 60 * 1000 // 3 minutes in milliseconds
* Maximum size of a video in megabytes, _not_ mebibytes. Backend uses
* ISO megabytes.
*/
export const VIDEO_MAX_SIZE_REDUCED = 1000 * 1000 * 100 // 100mb
export const VIDEO_MAX_SIZE = 3000 * 1000 * 100 // 300mb
export const VIDEO_MAX_SIZE_MB = 300
export const VIDEO_MAX_SIZE = VIDEO_MAX_SIZE_MB * 1000 * 1000 // 300mb
export const SUPPORTED_MIME_TYPES = [
'video/mp4',
+51
View File
@@ -5,7 +5,11 @@ import * as WebBrowser from 'expo-web-browser'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
import {parseLinkingUrl} from '#/lib/parseLinkingUrl'
import {CHAT_INVITE_CODE_REGEX} from '#/lib/strings/url-helpers'
import {usePrefetchJoinLinkPreviews} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import {useAnalytics} from '#/analytics'
@@ -25,6 +29,7 @@ export function useIntentHandler() {
const ax = useAnalytics()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
const groupChatJoinIntent = useGroupChatJoinIntent()
const {currentAccount} = useSession()
const {tryApplyUpdate} = useApplyPullRequestOTAUpdate()
@@ -44,6 +49,11 @@ export function useIntentHandler() {
})
}
const urlp = parseLinkingUrl(url)
const chatInviteMatch = urlp.pathname.match(CHAT_INVITE_CODE_REGEX)
if (chatInviteMatch) {
groupChatJoinIntent(chatInviteMatch[1], url)
return
}
const [, intent, intentType] = urlp.pathname.split('/')
// On native, our links look like bluesky://intent/SomeIntent, so we have to check the hostname for the
@@ -99,6 +109,7 @@ export function useIntentHandler() {
ax,
composeIntent,
verifyEmailIntent,
groupChatJoinIntent,
currentAccount,
tryApplyUpdate,
])
@@ -162,6 +173,46 @@ export function useComposeIntent() {
)
}
export function useGroupChatJoinIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {hasSession} = useSession()
const {groupChatJoinDialogControl: control, setGroupChatJoinState: setState} =
useIntentDialogs()
const {requestSwitchToAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const prefetchJoinLinkPreviews = usePrefetchJoinLinkPreviews()
return useCallback(
(code: string, uri?: string) => {
closeAllActiveElements()
if (hasSession) {
setState({code})
const prefetch = prefetchJoinLinkPreviews({
codes: [code],
hasSession: true,
})
void Promise.race([
prefetch,
new Promise(res => setTimeout(res, 200)),
]).finally(() => {
control.open()
})
} else {
setActiveLanding({type: 'groupchat', uri: uri ?? '', code})
requestSwitchToAccount({requestedAccount: 'groupchat'})
}
},
[
closeAllActiveElements,
hasSession,
control,
setState,
prefetchJoinLinkPreviews,
requestSwitchToAccount,
setActiveLanding,
],
)
}
function useVerifyEmailIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
-1
View File
@@ -12,7 +12,6 @@ export async function compressVideo(
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
TEMP_enableLargeVideoUploads?: boolean
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
+3 -9
View File
@@ -1,28 +1,22 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {VIDEO_MAX_SIZE, VIDEO_MAX_SIZE_REDUCED} from '#/lib/constants'
import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
asset: ImagePickerAsset,
opts?: {
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
TEMP_enableLargeVideoUploads?: number
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
if (
blob.size >
(opts?.TEMP_enableLargeVideoUploads
? VIDEO_MAX_SIZE
: VIDEO_MAX_SIZE_REDUCED)
) {
if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
+1
View File
@@ -64,6 +64,7 @@ export type CommonNavigatorParams = {
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesConversationSettings: {conversation: string}
MessagesJoinRequests: {conversation: string}
MessagesSettings: undefined
MessagesInbox: undefined
NotificationsActivityList: {posts: string}
+22
View File
@@ -178,6 +178,28 @@ export function isBskyStarterPackUrl(url: string): boolean {
return false
}
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
if (isBskyAppUrl(url)) {
try {
pathname = new URL(url).pathname
} catch {
return undefined
}
} else if (url.startsWith('/')) {
pathname = url.split('?')[0].split('#')[0]
} else {
return undefined
}
return pathname.match(CHAT_INVITE_CODE_REGEX)?.[1]
}
export function isBskyChatInviteUrl(url: string): boolean {
return getChatInviteCodeFromUrl(url) !== undefined
}
export function isBskyDownloadUrl(url: string): boolean {
if (isExternalUrl(url)) {
return false
+207 -206
View File
@@ -1,16 +1,16 @@
// Don't remove -force from these because detection is VERY slow on low-end Android.
// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577
import '@formatjs/intl-locale/polyfill-force'
import '@formatjs/intl-pluralrules/polyfill-force'
import '@formatjs/intl-numberformat/polyfill-force'
import '@formatjs/intl-displaynames/polyfill-force'
import '@formatjs/intl-pluralrules/locale-data/en'
import '@formatjs/intl-numberformat/locale-data/en'
import '@formatjs/intl-displaynames/locale-data/en'
import '@formatjs/intl-locale/polyfill-force.js'
import '@formatjs/intl-pluralrules/polyfill-force.js'
import '@formatjs/intl-numberformat/polyfill-force.js'
import '@formatjs/intl-displaynames/polyfill-force.js'
import '@formatjs/intl-pluralrules/locale-data/en.js'
import '@formatjs/intl-numberformat/locale-data/en.js'
import '@formatjs/intl-displaynames/locale-data/en.js'
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -64,399 +64,398 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.an: {
i18n.loadAndActivate({locale, messages: messagesAn})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/an'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/an.js'),
import('@formatjs/intl-numberformat/locale-data/an.js'),
import('@formatjs/intl-displaynames/locale-data/an.js'),
])
return dateLocale
}
case AppLanguage.ast: {
i18n.loadAndActivate({locale, messages: messagesAst})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/ast'),
import('@formatjs/intl-numberformat/locale-data/ast'),
import('@formatjs/intl-displaynames/locale-data/ast'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/ast.js'),
import('@formatjs/intl-numberformat/locale-data/ast.js'),
import('@formatjs/intl-displaynames/locale-data/ast.js'),
])
return dateLocale
}
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ca'),
import('@formatjs/intl-pluralrules/locale-data/ca'),
import('@formatjs/intl-numberformat/locale-data/ca'),
import('@formatjs/intl-displaynames/locale-data/ca'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ca').then(m => m.ca),
import('@formatjs/intl-pluralrules/locale-data/ca.js'),
import('@formatjs/intl-numberformat/locale-data/ca.js'),
import('@formatjs/intl-displaynames/locale-data/ca.js'),
])
return dateLocale
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/cy'),
import('@formatjs/intl-pluralrules/locale-data/cy'),
import('@formatjs/intl-numberformat/locale-data/cy'),
import('@formatjs/intl-displaynames/locale-data/cy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/cy').then(m => m.cy),
import('@formatjs/intl-pluralrules/locale-data/cy.js'),
import('@formatjs/intl-numberformat/locale-data/cy.js'),
import('@formatjs/intl-displaynames/locale-data/cy.js'),
])
return dateLocale
}
case AppLanguage.da: {
i18n.loadAndActivate({locale, messages: messagesDa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/da'),
import('@formatjs/intl-pluralrules/locale-data/da'),
import('@formatjs/intl-numberformat/locale-data/da'),
import('@formatjs/intl-displaynames/locale-data/da'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/da').then(m => m.da),
import('@formatjs/intl-pluralrules/locale-data/da.js'),
import('@formatjs/intl-numberformat/locale-data/da.js'),
import('@formatjs/intl-displaynames/locale-data/da.js'),
])
return dateLocale
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/de'),
import('@formatjs/intl-pluralrules/locale-data/de'),
import('@formatjs/intl-numberformat/locale-data/de'),
import('@formatjs/intl-displaynames/locale-data/de'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/de').then(m => m.de),
import('@formatjs/intl-pluralrules/locale-data/de.js'),
import('@formatjs/intl-numberformat/locale-data/de.js'),
import('@formatjs/intl-displaynames/locale-data/de.js'),
])
return dateLocale
}
case AppLanguage.el: {
i18n.loadAndActivate({locale, messages: messagesEl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/el'),
import('@formatjs/intl-pluralrules/locale-data/el'),
import('@formatjs/intl-numberformat/locale-data/el'),
import('@formatjs/intl-displaynames/locale-data/el'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/el').then(m => m.el),
import('@formatjs/intl-pluralrules/locale-data/el.js'),
import('@formatjs/intl-numberformat/locale-data/el.js'),
import('@formatjs/intl-displaynames/locale-data/el.js'),
])
return dateLocale
}
case AppLanguage.en_GB: {
i18n.loadAndActivate({locale, messages: messagesEn_GB})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/en-GB'),
import('@formatjs/intl-pluralrules/locale-data/en'),
import('@formatjs/intl-numberformat/locale-data/en-GB'),
import('@formatjs/intl-displaynames/locale-data/en-GB'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/en-GB').then(m => m.enGB),
import('@formatjs/intl-pluralrules/locale-data/en.js'),
import('@formatjs/intl-numberformat/locale-data/en-GB.js'),
import('@formatjs/intl-displaynames/locale-data/en-GB.js'),
])
return dateLocale
}
case AppLanguage.eo: {
i18n.loadAndActivate({locale, messages: messagesEo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eo'),
import('@formatjs/intl-pluralrules/locale-data/eo'),
import('@formatjs/intl-numberformat/locale-data/eo'),
// borked, see https://github.com/bluesky-social/social-app/pull/9574
// import('@formatjs/intl-displaynames/locale-data/eo'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eo').then(m => m.eo),
import('@formatjs/intl-pluralrules/locale-data/eo.js'),
import('@formatjs/intl-numberformat/locale-data/eo.js'),
import('@formatjs/intl-displaynames/locale-data/eo.js'),
])
return dateLocale
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/es'),
import('@formatjs/intl-pluralrules/locale-data/es'),
import('@formatjs/intl-numberformat/locale-data/es'),
import('@formatjs/intl-displaynames/locale-data/es'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/es').then(m => m.es),
import('@formatjs/intl-pluralrules/locale-data/es.js'),
import('@formatjs/intl-numberformat/locale-data/es.js'),
import('@formatjs/intl-displaynames/locale-data/es.js'),
])
return dateLocale
}
case AppLanguage.eu: {
i18n.loadAndActivate({locale, messages: messagesEu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/eu'),
import('@formatjs/intl-pluralrules/locale-data/eu'),
import('@formatjs/intl-numberformat/locale-data/eu'),
import('@formatjs/intl-displaynames/locale-data/eu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/eu').then(m => m.eu),
import('@formatjs/intl-pluralrules/locale-data/eu.js'),
import('@formatjs/intl-numberformat/locale-data/eu.js'),
import('@formatjs/intl-displaynames/locale-data/eu.js'),
])
return dateLocale
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fi'),
import('@formatjs/intl-pluralrules/locale-data/fi'),
import('@formatjs/intl-numberformat/locale-data/fi'),
import('@formatjs/intl-displaynames/locale-data/fi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fi').then(m => m.fi),
import('@formatjs/intl-pluralrules/locale-data/fi.js'),
import('@formatjs/intl-numberformat/locale-data/fi.js'),
import('@formatjs/intl-displaynames/locale-data/fi.js'),
])
return dateLocale
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fr'),
import('@formatjs/intl-pluralrules/locale-data/fr'),
import('@formatjs/intl-numberformat/locale-data/fr'),
import('@formatjs/intl-displaynames/locale-data/fr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fr').then(m => m.fr),
import('@formatjs/intl-pluralrules/locale-data/fr.js'),
import('@formatjs/intl-numberformat/locale-data/fr.js'),
import('@formatjs/intl-displaynames/locale-data/fr.js'),
])
return dateLocale
}
case AppLanguage.fy: {
i18n.loadAndActivate({locale, messages: messagesFy})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/fy'),
import('@formatjs/intl-pluralrules/locale-data/fy'),
import('@formatjs/intl-numberformat/locale-data/fy'),
import('@formatjs/intl-displaynames/locale-data/fy'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/fy').then(m => m.fy),
import('@formatjs/intl-pluralrules/locale-data/fy.js'),
import('@formatjs/intl-numberformat/locale-data/fy.js'),
import('@formatjs/intl-displaynames/locale-data/fy.js'),
])
return dateLocale
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ga'),
import('@formatjs/intl-numberformat/locale-data/ga'),
import('@formatjs/intl-displaynames/locale-data/ga'),
import('@formatjs/intl-pluralrules/locale-data/ga.js'),
import('@formatjs/intl-numberformat/locale-data/ga.js'),
import('@formatjs/intl-displaynames/locale-data/ga.js'),
])
return undefined
}
case AppLanguage.gd: {
i18n.loadAndActivate({locale, messages: messagesGd})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gd'),
import('@formatjs/intl-pluralrules/locale-data/gd'),
import('@formatjs/intl-numberformat/locale-data/gd'),
import('@formatjs/intl-displaynames/locale-data/gd'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gd').then(m => m.gd),
import('@formatjs/intl-pluralrules/locale-data/gd.js'),
import('@formatjs/intl-numberformat/locale-data/gd.js'),
import('@formatjs/intl-displaynames/locale-data/gd.js'),
])
return dateLocale
}
case AppLanguage.gl: {
i18n.loadAndActivate({locale, messages: messagesGl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/gl'),
import('@formatjs/intl-pluralrules/locale-data/gl'),
import('@formatjs/intl-numberformat/locale-data/gl'),
import('@formatjs/intl-displaynames/locale-data/gl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/gl').then(m => m.gl),
import('@formatjs/intl-pluralrules/locale-data/gl.js'),
import('@formatjs/intl-numberformat/locale-data/gl.js'),
import('@formatjs/intl-displaynames/locale-data/gl.js'),
])
return dateLocale
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hi'),
import('@formatjs/intl-pluralrules/locale-data/hi'),
import('@formatjs/intl-numberformat/locale-data/hi'),
import('@formatjs/intl-displaynames/locale-data/hi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hi').then(m => m.hi),
import('@formatjs/intl-pluralrules/locale-data/hi.js'),
import('@formatjs/intl-numberformat/locale-data/hi.js'),
import('@formatjs/intl-displaynames/locale-data/hi.js'),
])
return dateLocale
}
case AppLanguage.hu: {
i18n.loadAndActivate({locale, messages: messagesHu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/hu'),
import('@formatjs/intl-pluralrules/locale-data/hu'),
import('@formatjs/intl-numberformat/locale-data/hu'),
import('@formatjs/intl-displaynames/locale-data/hu'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/hu').then(m => m.hu),
import('@formatjs/intl-pluralrules/locale-data/hu.js'),
import('@formatjs/intl-numberformat/locale-data/hu.js'),
import('@formatjs/intl-displaynames/locale-data/hu.js'),
])
return dateLocale
}
case AppLanguage.ia: {
i18n.loadAndActivate({locale, messages: messagesIa})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ia'),
import('@formatjs/intl-numberformat/locale-data/ia'),
import('@formatjs/intl-displaynames/locale-data/ia'),
import('@formatjs/intl-pluralrules/locale-data/ia.js'),
import('@formatjs/intl-numberformat/locale-data/ia.js'),
import('@formatjs/intl-displaynames/locale-data/ia.js'),
])
return undefined
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/id'),
import('@formatjs/intl-pluralrules/locale-data/id'),
import('@formatjs/intl-numberformat/locale-data/id'),
import('@formatjs/intl-displaynames/locale-data/id'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/id').then(m => m.id),
import('@formatjs/intl-pluralrules/locale-data/id.js'),
import('@formatjs/intl-numberformat/locale-data/id.js'),
import('@formatjs/intl-displaynames/locale-data/id.js'),
])
return dateLocale
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/it'),
import('@formatjs/intl-pluralrules/locale-data/it'),
import('@formatjs/intl-numberformat/locale-data/it'),
import('@formatjs/intl-displaynames/locale-data/it'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/it').then(m => m.it),
import('@formatjs/intl-pluralrules/locale-data/it.js'),
import('@formatjs/intl-numberformat/locale-data/it.js'),
import('@formatjs/intl-displaynames/locale-data/it.js'),
])
return dateLocale
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ja'),
import('@formatjs/intl-pluralrules/locale-data/ja'),
import('@formatjs/intl-numberformat/locale-data/ja'),
import('@formatjs/intl-displaynames/locale-data/ja'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ja').then(m => m.ja),
import('@formatjs/intl-pluralrules/locale-data/ja.js'),
import('@formatjs/intl-numberformat/locale-data/ja.js'),
import('@formatjs/intl-displaynames/locale-data/ja.js'),
])
return dateLocale
}
case AppLanguage.km: {
i18n.loadAndActivate({locale, messages: messagesKm})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/km'),
import('@formatjs/intl-pluralrules/locale-data/km'),
import('@formatjs/intl-numberformat/locale-data/km'),
import('@formatjs/intl-displaynames/locale-data/km'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/km').then(m => m.km),
import('@formatjs/intl-pluralrules/locale-data/km.js'),
import('@formatjs/intl-numberformat/locale-data/km.js'),
import('@formatjs/intl-displaynames/locale-data/km.js'),
])
return dateLocale
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ko'),
import('@formatjs/intl-pluralrules/locale-data/ko'),
import('@formatjs/intl-numberformat/locale-data/ko'),
import('@formatjs/intl-displaynames/locale-data/ko'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ko').then(m => m.ko),
import('@formatjs/intl-pluralrules/locale-data/ko.js'),
import('@formatjs/intl-numberformat/locale-data/ko.js'),
import('@formatjs/intl-displaynames/locale-data/ko.js'),
])
return dateLocale
}
case AppLanguage.ne: {
i18n.loadAndActivate({locale, messages: messagesNe})
await Promise.all([
import('@formatjs/intl-pluralrules/locale-data/ne'),
import('@formatjs/intl-numberformat/locale-data/ne'),
import('@formatjs/intl-displaynames/locale-data/ne'),
import('@formatjs/intl-pluralrules/locale-data/ne.js'),
import('@formatjs/intl-numberformat/locale-data/ne.js'),
import('@formatjs/intl-displaynames/locale-data/ne.js'),
])
return undefined
}
case AppLanguage.nl: {
i18n.loadAndActivate({locale, messages: messagesNl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/nl'),
import('@formatjs/intl-pluralrules/locale-data/nl'),
import('@formatjs/intl-numberformat/locale-data/nl'),
import('@formatjs/intl-displaynames/locale-data/nl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/nl').then(m => m.nl),
import('@formatjs/intl-pluralrules/locale-data/nl.js'),
import('@formatjs/intl-numberformat/locale-data/nl.js'),
import('@formatjs/intl-displaynames/locale-data/nl.js'),
])
return dateLocale
}
case AppLanguage.pl: {
i18n.loadAndActivate({locale, messages: messagesPl})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pl'),
import('@formatjs/intl-pluralrules/locale-data/pl'),
import('@formatjs/intl-numberformat/locale-data/pl'),
import('@formatjs/intl-displaynames/locale-data/pl'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pl').then(m => m.pl),
import('@formatjs/intl-pluralrules/locale-data/pl.js'),
import('@formatjs/intl-numberformat/locale-data/pl.js'),
import('@formatjs/intl-displaynames/locale-data/pl.js'),
])
return dateLocale
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt-BR'),
import('@formatjs/intl-pluralrules/locale-data/pt'),
import('@formatjs/intl-numberformat/locale-data/pt'),
import('@formatjs/intl-displaynames/locale-data/pt'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt-BR').then(m => m.ptBR),
import('@formatjs/intl-pluralrules/locale-data/pt.js'),
import('@formatjs/intl-numberformat/locale-data/pt.js'),
import('@formatjs/intl-displaynames/locale-data/pt.js'),
])
return dateLocale
}
case AppLanguage.pt_PT: {
i18n.loadAndActivate({locale, messages: messagesPt_PT})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/pt'),
import('@formatjs/intl-pluralrules/locale-data/pt-PT'),
import('@formatjs/intl-numberformat/locale-data/pt-PT'),
import('@formatjs/intl-displaynames/locale-data/pt-PT'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/pt').then(m => m.pt),
import('@formatjs/intl-pluralrules/locale-data/pt-PT.js'),
import('@formatjs/intl-numberformat/locale-data/pt-PT.js'),
import('@formatjs/intl-displaynames/locale-data/pt-PT.js'),
])
return dateLocale
}
case AppLanguage.ro: {
i18n.loadAndActivate({locale, messages: messagesRo})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ro'),
import('@formatjs/intl-pluralrules/locale-data/ro'),
import('@formatjs/intl-numberformat/locale-data/ro'),
import('@formatjs/intl-displaynames/locale-data/ro'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ro').then(m => m.ro),
import('@formatjs/intl-pluralrules/locale-data/ro.js'),
import('@formatjs/intl-numberformat/locale-data/ro.js'),
import('@formatjs/intl-displaynames/locale-data/ro.js'),
])
return dateLocale
}
case AppLanguage.ru: {
i18n.loadAndActivate({locale, messages: messagesRu})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/ru'),
import('@formatjs/intl-pluralrules/locale-data/ru'),
import('@formatjs/intl-numberformat/locale-data/ru'),
import('@formatjs/intl-displaynames/locale-data/ru'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/ru').then(m => m.ru),
import('@formatjs/intl-pluralrules/locale-data/ru.js'),
import('@formatjs/intl-numberformat/locale-data/ru.js'),
import('@formatjs/intl-displaynames/locale-data/ru.js'),
])
return dateLocale
}
case AppLanguage.sv: {
i18n.loadAndActivate({locale, messages: messagesSv})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/sv'),
import('@formatjs/intl-pluralrules/locale-data/sv'),
import('@formatjs/intl-numberformat/locale-data/sv'),
import('@formatjs/intl-displaynames/locale-data/sv'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/sv').then(m => m.sv),
import('@formatjs/intl-pluralrules/locale-data/sv.js'),
import('@formatjs/intl-numberformat/locale-data/sv.js'),
import('@formatjs/intl-displaynames/locale-data/sv.js'),
])
return dateLocale
}
case AppLanguage.th: {
i18n.loadAndActivate({locale, messages: messagesTh})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/th'),
import('@formatjs/intl-pluralrules/locale-data/th'),
import('@formatjs/intl-numberformat/locale-data/th'),
import('@formatjs/intl-displaynames/locale-data/th'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/th').then(m => m.th),
import('@formatjs/intl-pluralrules/locale-data/th.js'),
import('@formatjs/intl-numberformat/locale-data/th.js'),
import('@formatjs/intl-displaynames/locale-data/th.js'),
])
return dateLocale
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/tr'),
import('@formatjs/intl-pluralrules/locale-data/tr'),
import('@formatjs/intl-numberformat/locale-data/tr'),
import('@formatjs/intl-displaynames/locale-data/tr'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/tr').then(m => m.tr),
import('@formatjs/intl-pluralrules/locale-data/tr.js'),
import('@formatjs/intl-numberformat/locale-data/tr.js'),
import('@formatjs/intl-displaynames/locale-data/tr.js'),
])
return dateLocale
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/uk'),
import('@formatjs/intl-pluralrules/locale-data/uk'),
import('@formatjs/intl-numberformat/locale-data/uk'),
import('@formatjs/intl-displaynames/locale-data/uk'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/uk').then(m => m.uk),
import('@formatjs/intl-pluralrules/locale-data/uk.js'),
import('@formatjs/intl-numberformat/locale-data/uk.js'),
import('@formatjs/intl-displaynames/locale-data/uk.js'),
])
return dateLocale
}
case AppLanguage.vi: {
i18n.loadAndActivate({locale, messages: messagesVi})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/vi'),
import('@formatjs/intl-pluralrules/locale-data/vi'),
import('@formatjs/intl-numberformat/locale-data/vi'),
import('@formatjs/intl-displaynames/locale-data/vi'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/vi').then(m => m.vi),
import('@formatjs/intl-pluralrules/locale-data/vi.js'),
import('@formatjs/intl-numberformat/locale-data/vi.js'),
import('@formatjs/intl-displaynames/locale-data/vi.js'),
])
return dateLocale
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-CN'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-CN').then(m => m.zhCN),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh.js'),
import('@formatjs/intl-displaynames/locale-data/zh.js'),
])
return dateLocale
}
case AppLanguage.zh_HK: {
i18n.loadAndActivate({locale, messages: messagesZh_HK})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-HK'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-HK').then(m => m.zhHK),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/yue-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/yue-Hant.js'),
])
return dateLocale
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
const [{default: dateLocale}] = await Promise.all([
import('date-fns/locale/zh-TW'),
import('@formatjs/intl-pluralrules/locale-data/zh'),
import('@formatjs/intl-numberformat/locale-data/zh'),
import('@formatjs/intl-displaynames/locale-data/zh'),
const [dateLocale] = await Promise.all([
import('date-fns/locale/zh-TW').then(m => m.zhTW),
import('@formatjs/intl-pluralrules/locale-data/zh.js'),
import('@formatjs/intl-numberformat/locale-data/zh-Hant.js'),
import('@formatjs/intl-displaynames/locale-data/zh-Hant.js'),
])
return dateLocale
}
@@ -472,9 +471,11 @@ export function useLocaleLanguage() {
const [dateLocale, setDateLocale] = useState(defaultLocale)
useEffect(() => {
dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(locale => {
setDateLocale(locale ?? defaultLocale)
})
void dynamicActivate(sanitizeAppLanguageSetting(appLanguage)).then(
locale => {
setDateLocale(locale ?? defaultLocale)
},
)
}, [appLanguage])
return dateLocale
+121 -120
View File
@@ -1,6 +1,7 @@
import {useEffect, useState} from 'react'
import {i18n} from '@lingui/core'
import defaultLocale from 'date-fns/locale/en-US'
import {i18n, type Messages} from '@lingui/core'
import {type Locale} from 'date-fns/locale'
import {enUS as defaultLocale} from 'date-fns/locale/en-US'
import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {AppLanguage} from '#/locale/languages'
@@ -10,288 +11,288 @@ import {useLanguagePrefs} from '#/state/preferences'
* We do a dynamic import of just the catalog that we need
*/
export async function dynamicActivate(locale: AppLanguage) {
let mod: any
let messages: Messages
let dateLocale: Locale = defaultLocale
switch (locale) {
case AppLanguage.an: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/an/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/an/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ast: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ast/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ast/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.ca: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ca/messages`),
import('date-fns/locale/ca'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ca/messages`).then(m => m.messages),
import('date-fns/locale/ca').then(m => m.ca),
])
break
}
case AppLanguage.cy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/cy/messages`),
import('date-fns/locale/cy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages),
import('date-fns/locale/cy').then(m => m.cy),
])
break
}
case AppLanguage.da: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/da/messages`),
import('date-fns/locale/da'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/da/messages`).then(m => m.messages),
import('date-fns/locale/da').then(m => m.da),
])
break
}
case AppLanguage.de: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/de/messages`),
import('date-fns/locale/de'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/de/messages`).then(m => m.messages),
import('date-fns/locale/de').then(m => m.de),
])
break
}
case AppLanguage.el: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/el/messages`),
import('date-fns/locale/el'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/el/messages`).then(m => m.messages),
import('date-fns/locale/el').then(m => m.el),
])
break
}
case AppLanguage.en_GB: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/en-GB/messages`),
import('date-fns/locale/en-GB'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/en-GB/messages`).then(m => m.messages),
import('date-fns/locale/en-GB').then(m => m.enGB),
])
break
}
case AppLanguage.eo: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eo/messages`),
import('date-fns/locale/eo'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eo/messages`).then(m => m.messages),
import('date-fns/locale/eo').then(m => m.eo),
])
break
}
case AppLanguage.es: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/es/messages`),
import('date-fns/locale/es'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/es/messages`).then(m => m.messages),
import('date-fns/locale/es').then(m => m.es),
])
break
}
case AppLanguage.eu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/eu/messages`),
import('date-fns/locale/eu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/eu/messages`).then(m => m.messages),
import('date-fns/locale/eu').then(m => m.eu),
])
break
}
case AppLanguage.fi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fi/messages`),
import('date-fns/locale/fi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fi/messages`).then(m => m.messages),
import('date-fns/locale/fi').then(m => m.fi),
])
break
}
case AppLanguage.fr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fr/messages`),
import('date-fns/locale/fr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fr/messages`).then(m => m.messages),
import('date-fns/locale/fr').then(m => m.fr),
])
break
}
case AppLanguage.fy: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/fy/messages`),
import('date-fns/locale/fy'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/fy/messages`).then(m => m.messages),
import('date-fns/locale/fy').then(m => m.fy),
])
break
}
case AppLanguage.ga: {
mod = await import(`./locales/ga/messages`)
messages = await import(`./locales/ga/messages`).then(m => m.messages)
break
}
case AppLanguage.gd: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gd/messages`),
import('date-fns/locale/gd'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gd/messages`).then(m => m.messages),
import('date-fns/locale/gd').then(m => m.gd),
])
break
}
case AppLanguage.gl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/gl/messages`),
import('date-fns/locale/gl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/gl/messages`).then(m => m.messages),
import('date-fns/locale/gl').then(m => m.gl),
])
break
}
case AppLanguage.hi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hi/messages`),
import('date-fns/locale/hi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hi/messages`).then(m => m.messages),
import('date-fns/locale/hi').then(m => m.hi),
])
break
}
case AppLanguage.hu: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/hu/messages`),
import('date-fns/locale/hu'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/hu/messages`).then(m => m.messages),
import('date-fns/locale/hu').then(m => m.hu),
])
break
}
case AppLanguage.ia: {
mod = await import(`./locales/ia/messages`)
messages = await import(`./locales/ia/messages`).then(m => m.messages)
break
}
case AppLanguage.id: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/id/messages`),
import('date-fns/locale/id'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/id/messages`).then(m => m.messages),
import('date-fns/locale/id').then(m => m.id),
])
break
}
case AppLanguage.it: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/it/messages`),
import('date-fns/locale/it'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/it/messages`).then(m => m.messages),
import('date-fns/locale/it').then(m => m.it),
])
break
}
case AppLanguage.ja: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ja/messages`),
import('date-fns/locale/ja'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ja/messages`).then(m => m.messages),
import('date-fns/locale/ja').then(m => m.ja),
])
break
}
case AppLanguage.km: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/km/messages`),
import('date-fns/locale/km'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/km/messages`).then(m => m.messages),
import('date-fns/locale/km').then(m => m.km),
])
break
}
case AppLanguage.ko: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ko/messages`),
import('date-fns/locale/ko'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ko/messages`).then(m => m.messages),
import('date-fns/locale/ko').then(m => m.ko),
])
break
}
case AppLanguage.ne: {
mod = await import(`./locales/ne/messages`)
messages = await import(`./locales/ne/messages`).then(m => m.messages)
break
}
case AppLanguage.nl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/nl/messages`),
import('date-fns/locale/nl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/nl/messages`).then(m => m.messages),
import('date-fns/locale/nl').then(m => m.nl),
])
break
}
case AppLanguage.pl: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pl/messages`),
import('date-fns/locale/pl'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pl/messages`).then(m => m.messages),
import('date-fns/locale/pl').then(m => m.pl),
])
break
}
case AppLanguage.pt_BR: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-BR/messages`),
import('date-fns/locale/pt-BR'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-BR/messages`).then(m => m.messages),
import('date-fns/locale/pt-BR').then(m => m.ptBR),
])
break
}
case AppLanguage.pt_PT: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/pt-PT/messages`),
import('date-fns/locale/pt'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/pt-PT/messages`).then(m => m.messages),
import('date-fns/locale/pt').then(m => m.pt),
])
break
}
case AppLanguage.ro: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ro/messages`),
import('date-fns/locale/ro'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ro/messages`).then(m => m.messages),
import('date-fns/locale/ro').then(m => m.ro),
])
break
}
case AppLanguage.ru: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/ru/messages`),
import('date-fns/locale/ru'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/ru/messages`).then(m => m.messages),
import('date-fns/locale/ru').then(m => m.ru),
])
break
}
case AppLanguage.sv: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/sv/messages`),
import('date-fns/locale/sv'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/sv/messages`).then(m => m.messages),
import('date-fns/locale/sv').then(m => m.sv),
])
break
}
case AppLanguage.th: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/th/messages`),
import('date-fns/locale/th'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/th/messages`).then(m => m.messages),
import('date-fns/locale/th').then(m => m.th),
])
break
}
case AppLanguage.tr: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/tr/messages`),
import('date-fns/locale/tr'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/tr/messages`).then(m => m.messages),
import('date-fns/locale/tr').then(m => m.tr),
])
break
}
case AppLanguage.uk: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/uk/messages`),
import('date-fns/locale/uk'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/uk/messages`).then(m => m.messages),
import('date-fns/locale/uk').then(m => m.uk),
])
break
}
case AppLanguage.vi: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/vi/messages`),
import('date-fns/locale/vi'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/vi/messages`).then(m => m.messages),
import('date-fns/locale/vi').then(m => m.vi),
])
break
}
case AppLanguage.zh_CN: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-CN/messages`),
import('date-fns/locale/zh-CN'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-CN/messages`).then(m => m.messages),
import('date-fns/locale/zh-CN').then(m => m.zhCN),
])
break
}
case AppLanguage.zh_HK: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-HK/messages`),
import('date-fns/locale/zh-HK'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-HK/messages`).then(m => m.messages),
import('date-fns/locale/zh-HK').then(m => m.zhHK),
])
break
}
case AppLanguage.zh_TW: {
;[mod, {default: dateLocale}] = await Promise.all([
import(`./locales/zh-TW/messages`),
import('date-fns/locale/zh-TW'),
;[messages, dateLocale] = await Promise.all([
import(`./locales/zh-TW/messages`).then(m => m.messages),
import('date-fns/locale/zh-TW').then(m => m.zhTW),
])
break
}
default: {
mod = await import(`./locales/en/messages`)
messages = await import(`./locales/en/messages`).then(m => m.messages)
break
}
}
i18n.load(locale, mod.messages)
i18n.load(locale, messages)
i18n.activate(locale)
return dateLocale
@@ -305,7 +306,7 @@ export function useLocaleLanguage() {
const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)
document.documentElement.lang = sanitizedLanguage
dynamicActivate(sanitizedLanguage).then(locale => {
void dynamicActivate(sanitizedLanguage).then(locale => {
setDateLocale(locale)
})
}, [appLanguage])
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
import format from 'date-fns/format'
import {format} from 'date-fns/format'
import {LogLevel, type Transport} from '#/logger/types'
import {prepareMetadata} from '#/logger/util'
+2 -1
View File
@@ -25,7 +25,8 @@ if (process.env.NODE_ENV !== 'production') {
thrownErrors.add(err)
throw err
} else if (!thrownErrors.has(msgOrError)) {
return realConsoleError.apply(this, arguments as any)
// @ts-expect-error
return realConsoleError.apply(this, arguments)
}
}
}
+1
View File
@@ -74,6 +74,7 @@ export const router = new Router<AllNavigatableRoutes>({
MessagesInbox: '/messages/inbox',
MessagesConversation: '/messages/:conversation',
MessagesConversationSettings: '/messages/:conversation/settings',
MessagesJoinRequests: '/messages/:conversation/requests',
// starter packs
Start: '/start/:name/:rkey',
StarterPackEdit: '/starter-pack/edit/:rkey',
@@ -1,26 +1,31 @@
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a, useTheme} from '#/alf'
import {InlineLinkText} from '#/components/Link'
import {type ConvoWithDetails} from '#/components/dms/util'
import {createStaticClick, InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
export function MembersAndRequests({
memberCount,
memberLimit,
convo,
requestCount,
hasMoreRequests,
isOwner,
}: {
memberCount: number
memberLimit: number
convo: Extract<ConvoWithDetails, {kind: 'group'}>
requestCount: number
hasMoreRequests: boolean
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const memberCount = convo.details.memberCount
const memberLimit = convo.details.memberLimit
return (
<View style={[a.flex_row, a.justify_between, a.px_xl, a.pt_xl, a.pb_sm]}>
@@ -42,13 +47,16 @@ export function MembersAndRequests({
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{...createStaticClick(() => {
navigation.navigate('MessagesJoinRequests', {
conversation: convo.view.id,
})
})}>
{hasMoreRequests
? l({
message: `${requestCount}+ requests`,
comment:
'Displayed when there are more than 50 requests to join a group chat',
'Displayed when there are more than 20 requests to join a group chat',
})
: l({
message: plural(requestCount, {
@@ -247,8 +247,7 @@ function GroupSettings({
case 'MEMBERS_AND_REQUESTS':
return (
<MembersAndRequests
memberCount={convo.details.memberCount}
memberLimit={convo.details.memberLimit}
convo={convo}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
+305
View File
@@ -0,0 +1,305 @@
import {View} from 'react-native'
import {ImageBackground} from 'expo-image'
import {moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useActiveGroupChatJoinRequest} from '#/state/shell/landing'
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
import {LogomarkWithType} from '#/view/icons/LogomarkWithType'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonText} from '#/components/Button'
import {ChainLinkBroken_Stroke2_Corner0_Rounded as ChainLinkBrokenIcon} from '#/components/icons/ChainLink'
import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
const desktopDarkBg = require('../../../assets/images/chat-desktop-bg-dark.webp')
const desktopLightBg = require('../../../assets/images/chat-desktop-bg-light.webp')
const mobileDarkBg = require('../../../assets/images/chat-mobile-bg-dark.webp')
const mobileLightBg = require('../../../assets/images/chat-mobile-bg-light.webp')
type Props = {
setScreenState: (state: LoggedOutScreenState) => void
}
export function JoinRequest({setScreenState}: Props) {
const t = useTheme()
const {t: l} = useLingui()
const {gtMobile, gtTablet} = useBreakpoints()
const moderationOpts = useModerationOpts()
// Get code from context (logged-out only)
const contextJoinRequest = useActiveGroupChatJoinRequest()
const code = contextJoinRequest?.code
const {data, error} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
hasSession: false,
})
const isDarkMode = t.name !== 'light'
const background = gtMobile
? isDarkMode
? desktopDarkBg
: desktopLightBg
: isDarkMode
? mobileDarkBg
: mobileLightBg
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval
const requiresFollow =
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
return (
<View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}>
<ImageBackground
source={background}
style={[a.util_screen_outer, a.w_full, a.flex_1, a.justify_center]}
contentFit={gtTablet ? 'contain' : 'cover'}>
<View
style={[
a.util_screen_outer,
a.w_full,
a.justify_center,
a.align_center,
]}>
{error ? (
<Wrapper>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text
style={[
a.mb_sm,
a.text_center,
a.text_lg,
a.font_semi_bold,
t.atoms.text,
]}>
{l`This invite link has expired`}
</Text>
<ActionButtons setScreenState={setScreenState} />
</Wrapper>
) : data && moderationOpts ? (
<Wrapper>
<AvatarBubbles
profiles={[
data.joinLinkPreviews[0].owner,
...Array(
Math.min(
3,
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
),
).fill(undefined),
]}
size={135}
/>
<View style={[a.gap_2xs]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_sm,
]}>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>Group chat</Trans>
</Text>
<View style={[a.flex_row, a.align_center]}>
<PersonGroupIcon
size="xs"
style={[a.mr_2xs, t.atoms.text_contrast_medium]}
/>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans comment="The number of active group chat members out of the total number allowed.">
{data.joinLinkPreviews[0].memberCount}/
{data.joinLinkPreviews[0].memberLimit}
</Trans>
</Text>
</View>
</View>
<Text
style={[
a.text_center,
a.text_4xl,
a.leading_tight,
a.font_bold,
t.atoms.text,
]}>
{data.joinLinkPreviews[0].name}
</Text>
</View>
<View style={[a.w_full]}>
<View
style={[
a.flex_row,
a.gap_xs,
a.align_center,
a.justify_center,
]}>
<Text
style={[
a.mb_xs,
a.text_center,
a.text_sm,
a.leading_snug,
a.font_semi_bold,
t.atoms.text,
a.max_w_full,
]}>
<Trans comment="The owner (creator) of a group chat.">
By{' '}
{createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
moderationOpts,
).ui('displayName'),
)}
</Trans>
</Text>
<ProfileBadges
profile={data.joinLinkPreviews[0].owner}
size="sm"
style={{marginTop: -4}}
/>
</View>
<Text
style={[
a.text_center,
a.text_xs,
a.leading_snug,
a.font_medium,
t.atoms.text_contrast_medium,
a.max_w_full,
]}>
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')}
</Text>
</View>
<Text
style={[
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{requiresApproval
? l`Sign in to request access to this group chat.`
: l`Sign in to accept invite.`}{' '}
{requiresFollow &&
l`Only people ${createSanitizedDisplayName(
data.joinLinkPreviews[0].owner,
true,
moderateProfile(
data.joinLinkPreviews[0].owner,
moderationOpts,
).ui('displayName'),
)} follows can join.`}
</Text>
<ActionButtons setScreenState={setScreenState} />
</Wrapper>
) : null}
</View>
</ImageBackground>
</View>
)
}
function Wrapper({children}: React.PropsWithChildren<unknown>) {
const t = useTheme()
const isDarkMode = t.name !== 'light'
return (
<>
<LogomarkWithType
width={136}
fill={t.palette.primary_500}
style={[
a.absolute,
{
top: 40,
},
]}
/>
<View
style={[
a.zoom_fade_in,
a.align_center,
a.gap_lg,
a.p_2xl,
a.pt_4xl,
t.atoms.bg,
t.atoms.shadow_xl,
isDarkMode ? [a.border, t.atoms.border_contrast_low] : null,
{
borderRadius: 48,
maxWidth: 320,
width: '90%',
},
]}>
{children}
</View>
</>
)
}
function ActionButtons({
setScreenState,
}: {
setScreenState: (state: LoggedOutScreenState) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const isDarkMode = t.name !== 'light'
return (
<View style={[a.w_full, a.gap_md]}>
<Button
testID="signInButton"
onPress={() => {
setScreenState(LoggedOutScreenState.S_Login)
}}
label={l`Sign in`}
accessibilityHint={l`Opens flow to sign in to your existing Bluesky account`}
size="large"
color="primary"
style={[a.w_full]}>
<ButtonText>
<Trans>Sign in</Trans>
</ButtonText>
</Button>
<Button
testID="createAccountButton"
onPress={() => {
setScreenState(LoggedOutScreenState.S_CreateAccount)
}}
label={l`Create new account`}
accessibilityHint={l`Opens flow to create a new Bluesky account`}
size="large"
color={isDarkMode ? 'secondary_inverted' : 'secondary'}
style={[a.w_full]}>
<ButtonText>
<Trans>Create account</Trans>
</ButtonText>
</Button>
</View>
)
}
+491
View File
@@ -0,0 +1,491 @@
import {useState} from 'react'
import {View} from 'react-native'
import {
ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type InfiniteData, useQueryClient} from '@tanstack/react-query'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {isNetworkError} from '#/lib/hooks/useCleanError'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
type NavigationProp,
} from '#/lib/routes/types'
import {logger} from '#/logger'
import {ConvoProvider, useConvo} from '#/state/messages/convo'
import {ConvoStatus} from '#/state/messages/convo/types'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useJoinRequestMutation} from '#/state/queries/messages/join-requests'
import {
createListJoinRequestsQueryKey,
useListJoinRequestsQuery,
} from '#/state/queries/messages/list-join-requests'
import {useSession} from '#/state/session'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type ConvoWithDetails} from '#/components/dms/util'
import {Error} from '#/components/Error'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
import {KnownFollowers} from '#/components/KnownFollowers'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import {InviteLinkDialog} from './components/InviteLinkDialog'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'MessagesJoinRequests'
>
export function MessagesJoinRequestsScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
return (
<AgeRestrictedScreen
screenTitle={l`Requests to join`}
infoText={aaCopy.chatsInfoText}>
<MessagesJoinRequestsScreenInner {...props} />
</AgeRestrictedScreen>
)
}
function MessagesJoinRequestsScreenInner({route}: Props) {
const convoId = route.params.conversation
return (
<Layout.Screen>
<ConvoProvider key={convoId} convoId={convoId}>
<JoinRequestsInner />
</ConvoProvider>
</Layout.Screen>
)
}
function JoinRequestsInner() {
const {t: l} = useLingui()
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
if (convoState.status === ConvoStatus.Error) {
return (
<>
<Header />
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations join requests`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</>
)
}
if (!convoState.convo) {
return (
<>
<Header />
<View style={[a.flex_1, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
</>
)
}
if (convoState.convo.kind !== 'group') {
return (
<Error
title={l`Wrong kind of conversation`}
message={l`This screen is only available for group conversations.`}
onGoBack={() => {
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.replace('Messages', {animation: 'pop'})
}
}}
/>
)
}
return <JoinRequestsList convo={convoState.convo} />
}
function JoinRequestsList({
convo,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
}) {
const t = useTheme()
const {t: l} = useLingui()
const moderationOpts = useModerationOpts()
const bottomBarOffset = useBottomBarOffset()
const {currentAccount} = useSession()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const inviteLinkControl = Dialog.useDialogControl()
const getRemainingRequestCount = () => {
const data = queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(createListJoinRequestsQueryKey({convoId: convo.view.id}))
return data?.pages.reduce((sum, page) => sum + page.requests.length, 0) ?? 0
}
const [isPTRing, setIsPTRing] = useState(false)
const [footerHeight, setFooterHeight] = useState(0)
const owner = convo.primaryMember
const isOwner = !!owner && owner.did === currentAccount?.did
const {
data: joinRequestsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
refetch,
} = useListJoinRequestsQuery({
convoId: convo.view.id,
})
const items =
joinRequestsData?.pages.flatMap(page =>
page.requests.map(request => request.requestedBy),
) ?? []
const requestCount =
joinRequestsData?.pages.reduce(
(sum, page) => sum + page.requests.length,
0,
) ?? 0
const {mutate: approveJoinRequest, isPending: isApprovePending} =
useJoinRequestMutation('approve', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request approved.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to accept join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupApproveJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can accept join requests.`
} else if (
error instanceof
ChatBskyGroupApproveJoinRequest.MemberLimitReachedError
) {
errorMessage = l`The member limit has been reached.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const {mutate: rejectJoinRequest, isPending: isRejectPending} =
useJoinRequestMutation('reject', convo.view.id, {
onSuccess: () => {
Toast.show(l`Request ignored.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InvalidConvoError
) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
const isMutating = isApprovePending || isRejectPending
const renderItem = ({item}: {item: bsky.profile.AnyProfileView}) => {
if (!moderationOpts) return null
return (
<View style={[a.relative, a.flex_1, a.p_lg]}>
<View style={[a.flex_row, a.align_start, a.gap_md]}>
<ProfileCard.Link profile={item}>
<ProfileCard.Avatar
profile={item}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
</ProfileCard.Link>
<View>
<ProfileCard.Name profile={item} moderationOpts={moderationOpts} />
<ProfileCard.Handle profile={item} />
<View style={[a.mt_xs]}>
<KnownFollowers
profile={item}
moderationOpts={moderationOpts}
minimal
showIfEmpty
/>
</View>
<View style={[a.flex_row, a.align_center, a.gap_sm, a.mt_md]}>
<AcceptButton
disabled={isMutating}
onPress={() => approveJoinRequest({member: item.did})}
/>
<RejectButton
disabled={isMutating}
onPress={() => rejectJoinRequest({member: item.did})}
/>
</View>
</View>
</View>
</View>
)
}
const footer = (
<View
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
style={[
a.absolute,
a.left_0,
a.right_0,
{bottom: 0},
a.px_xl,
a.border_t,
t.atoms.bg,
t.atoms.border_contrast_low,
{
paddingTop: a.py_lg.paddingTop,
paddingBottom: a.py_lg.paddingBottom + bottomBarOffset,
},
]}>
<Button
label={l`Edit invite link`}
size="large"
color="primary"
onPress={() => inviteLinkControl.open()}
style={[a.w_full]}>
<ButtonText>
<Trans context="button">Edit invite link</Trans>
</ButtonText>
</Button>
</View>
)
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more join requests', {message: err})
}
}
const onRefresh = async () => {
setIsPTRing(true)
try {
await refetch()
} catch (err) {
logger.error('Failed to refresh group chat requests', {message: err})
}
setIsPTRing(false)
}
if (isError) {
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<View
style={[
a.flex_1,
a.align_center,
a.justify_center,
a.gap_sm,
a.p_lg,
]}>
<ErrorIcon size="3xl" fill={t.atoms.text_contrast_high.color} />
<Text
style={[
a.leading_snug,
a.text_center,
a.px_lg,
a.text_md,
t.atoms.text_contrast_high,
]}>
<Trans>Unable to fetch join requests.</Trans>
</Text>
<Button
color="primary"
label={l`Press to retry`}
onPress={() => void onRefresh()}
disabled={isPTRing}
size="large"
style={[a.mt_md]}>
<ButtonText>
<Trans>Retry</Trans>
</ButtonText>
<ButtonIcon icon={isPTRing ? Loader : RetryIcon} />
</Button>
</View>
</>
)
}
const showFooter = isOwner
return (
<>
<Header count={requestCount} hasMoreRequests={hasNextPage} />
<List
data={items}
keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
renderItem={renderItem}
ListEmptyComponent={
isPending ? (
<View
style={[a.flex_1, a.align_center, a.justify_center, a.py_4xl]}>
<Loader size="xl" />
</View>
) : null
}
contentContainerStyle={
showFooter ? {paddingBottom: footerHeight} : undefined
}
scrollIndicatorInsets={showFooter ? {bottom: footerHeight} : undefined}
refreshing={isPTRing}
onEndReached={() => void onEndReached()}
onRefresh={() => void onRefresh()}
keyboardDismissMode="on-drag"
sideBorders={false}
desktopFixedHeight
/>
{showFooter ? footer : null}
{owner && moderationOpts && (
<InviteLinkDialog
convo={convo}
control={inviteLinkControl}
owner={owner}
isOwner={isOwner}
moderationOpts={moderationOpts}
/>
)}
</>
)
}
function Header({
count,
hasMoreRequests,
}: {
count?: number
hasMoreRequests?: boolean
}) {
const {t: l} = useLingui()
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
{count === undefined ? (
<Trans>Requests to join</Trans>
) : hasMoreRequests ? (
l({
message: `${count}+ requests to join`,
comment:
'Displayed when there are more requests to join a group chat than have been loaded',
})
) : (
<Plural
value={count}
zero="No requests to join"
one="# request to join"
other="# requests to join"
/>
)}
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
)
}
function AcceptButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Accept join request`}
size="small"
color="primary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Accept a request to join a chat" context="button">
Accept
</Trans>
</ButtonText>
</Button>
)
}
function RejectButton({
disabled,
onPress,
}: {
disabled?: boolean
onPress: () => void
}) {
const {t: l} = useLingui()
return (
<Button
label={l`Ignore join request`}
size="small"
color="secondary"
disabled={disabled}
onPress={onPress}>
<ButtonText>
<Trans comment="Ignore a request to join a chat" context="button">
Ignore
</Trans>
</ButtonText>
</Button>
)
}
@@ -6,6 +6,7 @@ import {
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
@@ -213,6 +214,20 @@ function GroupChatItem({
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
subtitle={
convo.details.joinRequestCount
? convo.details.joinRequestCount > 20
? l({
message: '20+ new join requests',
context:
'Displayed when there are more than 20 requests to join a group chat',
})
: plural(convo.details.joinRequestCount, {
one: '# new join request',
other: '# new join requests',
})
: undefined
}
showProfileBadges={false}
selected={selected}
showMenu={showMenu}>
@@ -35,13 +35,14 @@ export function EditTextButton({
a.flex_row,
a.align_center,
a.justify_between,
a.px_md,
a.pl_lg,
a.pr_sm,
a.py_sm,
a.gap_sm,
]}>
{typeof children === 'function' ? children(context) : children}
<View
style={[
a.ml_sm,
a.rounded_full,
t.atoms.bg_contrast_50,
{paddingHorizontal: 10, paddingVertical: 8},
@@ -1,6 +1,11 @@
import {useEffect, useState} from 'react'
import {useState} from 'react'
import {View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {
type ChatBskyGroupDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useOpenComposer} from '#/lib/hooks/useOpenComposer'
@@ -64,14 +69,9 @@ export function InviteLinkDialog({
)
const {joinLink} = convo.details
const enabledStatus = joinLink?.enabledStatus
const defaultStep = joinLink ? Step.MANAGE : Step.INFO
const defaultWhoCanJoin = joinLink
? [
`${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`,
]
: ['anyone']
const defaultWhoCanJoin = joinLink ? joinLinkToKey(joinLink) : 'anyone'
const [step, setStep] = useState(defaultStep)
const [whoCanJoin, setWhoCanJoin] = useState(defaultWhoCanJoin)
@@ -79,13 +79,13 @@ export function InviteLinkDialog({
// Resync local state when the server-side join link rules change (mutation
// success, refetch, or change from another client). Keyed on the rule string
// so identity-only refetches don't bump a user mid-edit.
const joinLinkRuleKey = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: null
useEffect(() => {
const joinLinkRuleKey = joinLink ? joinLinkToKey(joinLink) : null
const [prevKey, setPrevKey] = useState(joinLinkRuleKey)
if (joinLinkRuleKey !== prevKey) {
setStep(joinLinkRuleKey ? Step.MANAGE : Step.INFO)
setWhoCanJoin([joinLinkRuleKey ?? 'anyone'])
}, [joinLinkRuleKey])
setWhoCanJoin(joinLinkRuleKey ?? 'anyone')
setPrevKey(joinLinkRuleKey)
}
const {openComposer} = useOpenComposer()
@@ -163,22 +163,32 @@ export function InviteLinkDialog({
let content: React.ReactNode = null
let header: string | null = null
switch (step) {
case Step.INFO:
case Step.INFO: {
header = l`Invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<View style={[a.gap_lg]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
An invite link lets people join this group chat without being
added directly. You control who can use the link and whether
they need your approval. You can disable the link at any time.
added directly. You control who can join the chat. You can
disable the link at any time.
</Trans>
</Text>
<Text style={[a.mt_lg, a.text_md, t.atoms.text]}>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, and the name of the group chat will be
visible to everyone.
Group chats can only have a maximum of{' '}
{plural(convo.details.memberLimit, {
one: '# person',
other: '# people',
})}
.
</Trans>
</Text>
<Text style={[a.text_md, a.leading_snug]}>
<Trans>
Your name, avatar, the name of the group chat, and the number of
members will be visible to everyone.
</Trans>
</Text>
</View>
@@ -199,15 +209,16 @@ export function InviteLinkDialog({
</>
)
break
case Step.GENERATE:
header =
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
: l`Generate invite link`
}
case Step.GENERATE: {
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkHasChanged = linkEnabled && joinLinkRuleKey !== whoCanJoin
header = linkEnabled ? l`Update invite link` : l`Generate invite link`
content = (
<>
<View>
<Text style={[a.text_md, t.atoms.text]}>
<Text style={[a.text_md]}>
<Trans>Choose who can join this group chat and how.</Trans>
</Text>
</View>
@@ -215,9 +226,9 @@ export function InviteLinkDialog({
<Toggle.Group
label={l`Who can join this group chat and how`}
type="radio"
values={whoCanJoin}
onChange={setWhoCanJoin}>
<View style={[a.gap_sm]}>
values={[whoCanJoin]}
onChange={([value]) => setWhoCanJoin(value)}>
<View style={[a.gap_xs]}>
{whoCanJoinOptions.map(option => (
<Toggle.Item
key={option.name}
@@ -239,18 +250,22 @@ export function InviteLinkDialog({
<View style={[a.mt_4xl]}>
<Button
label={
joinLink && enabledStatus === 'enabled'
? l`Update invite link`
linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`
}
color="primary"
color={linkEnabled && !linkHasChanged ? 'secondary' : 'primary'}
size="large"
disabled={isSaving}
onPress={() => {
const parts = whoCanJoin[0].split(':')
const joinRule = parts[0]
const requireApproval = parts[1] === 'requireApproval'
if (joinLink && enabledStatus === 'enabled') {
const {joinRule, requireApproval} = keyToJoinLink(whoCanJoin)
if (linkEnabled) {
if (!linkHasChanged) {
setStep(Step.MANAGE)
return
}
editJoinLink({
joinRule,
requireApproval,
@@ -263,36 +278,40 @@ export function InviteLinkDialog({
}
}}>
<ButtonText>
{joinLink && enabledStatus === 'enabled'
? l`Update invite link`
{linkEnabled
? linkHasChanged
? l`Update invite link`
: l`Back`
: l`Generate invite link`}
</ButtonText>
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
{linkHasChanged && (
<ButtonIcon icon={isSaving ? Loader : ArrowRightIcon} />
)}
</Button>
</View>
</>
)
break
}
case Step.MANAGE: {
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkDisabled = joinLink?.enabledStatus === 'disabled'
const joinLinkURI = joinLink?.code
? `https://bsky.app/chat/${joinLink.code}`
: 'https://bsky.app/chat'
? `https://bsky.app/c/${joinLink.code}`
: 'https://bsky.app/'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOptionName = joinLink
? `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
: whoCanJoinOptions[0].name
const currentOption = whoCanJoinOptions.find(
o => o.name === currentOptionName,
)
const currentOption =
whoCanJoinOptions.find(
o => o.name === (joinLink ? joinLinkToKey(joinLink) : null),
) ?? whoCanJoinOptions[0]
const ownerValue = currentOption?.owner ?? whoCanJoinOptions[0].owner
const memberValue = currentOption?.member ?? whoCanJoinOptions[0].member
header =
enabledStatus === 'enabled' ? l`Invite link` : l`Invite link disabled`
header = linkEnabled ? l`Invite link` : l`Invite link disabled`
content = (
<>
<View style={[a.mt_lg]}>
<CopyTextButton
disabled={enabledStatus === 'disabled' || !joinLink?.code}
disabled={linkDisabled || !joinLink?.code}
label={l`Invite link`}
value={joinLinkURI}>
<Text
@@ -300,9 +319,7 @@ export function InviteLinkDialog({
style={[
a.mr_xs,
a.text_md,
enabledStatus === 'disabled'
? t.atoms.text_contrast_low
: t.atoms.text,
linkDisabled ? t.atoms.text_contrast_low : t.atoms.text,
]}>
{joinLinkURI}
</Text>
@@ -319,25 +336,25 @@ export function InviteLinkDialog({
</Text>
) : null}
</View>
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.mt_lg]}>
{isOwner ? (
<EditTextButton
label={l`Edit link settings`}
value={ownerValue}
onPress={() => setStep(Step.GENERATE)}>
<View style={[a.flex_1, a.mr_xs]}>
<Text numberOfLines={1} style={[a.text_md, t.atoms.text]}>
<View style={[a.flex_1]}>
<Text numberOfLines={1} style={[a.text_sm]}>
{ownerValue}
</Text>
</View>
</EditTextButton>
) : (
<Text style={[a.text_sm, t.atoms.text]}>{memberValue}</Text>
<Text style={[a.text_sm]}>{memberValue}</Text>
)}
</View>
) : null}
{enabledStatus === 'enabled' ? (
{linkEnabled ? (
<View style={[a.flex_row, a.justify_between, a.gap_sm, a.mt_lg]}>
{isOwner ? (
<StackedButton
@@ -350,7 +367,7 @@ export function InviteLinkDialog({
</StackedButton>
) : null}
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Post link`}
icon={EditIcon}
color="primary_subtle"
@@ -366,7 +383,7 @@ export function InviteLinkDialog({
<Trans>Post link</Trans>
</StackedButton>
<StackedButton
disabled={enabledStatus === 'disabled'}
disabled={linkDisabled}
label={l`Share`}
icon={ArrowShareRightIcon}
color="primary_subtle"
@@ -408,7 +425,7 @@ export function InviteLinkDialog({
)
break
}
case Step.CONFIRM_DISABLE:
case Step.CONFIRM_DISABLE: {
content = (
<>
<View style={[a.align_center, a.justify_center, a.mb_lg]}>
@@ -425,14 +442,7 @@ export function InviteLinkDialog({
]}>
<Trans>Disable this invite link?</Trans>
</Text>
<Text
style={[
a.pb_2xl,
a.text_center,
a.text_sm,
a.leading_snug,
t.atoms.text,
]}>
<Text style={[a.pb_2xl, a.text_center, a.text_sm, a.leading_snug]}>
<Trans>
Anyone who has it will no longer be able to join or request to
join. You can always create a new one.
@@ -465,6 +475,7 @@ export function InviteLinkDialog({
</>
)
break
}
}
if (!isOwner && (!joinLink || joinLink.enabledStatus === 'disabled')) {
@@ -472,7 +483,7 @@ export function InviteLinkDialog({
content = (
<>
<View style={[a.mt_lg]}>
<Text style={[a.text_sm, t.atoms.text]}>
<Text style={[a.text_sm]}>
<Trans>There is no invite link for this group chat.</Trans>
</Text>
</View>
@@ -503,9 +514,7 @@ export function InviteLinkDialog({
header={
<View>
<View style={[IS_WEB ? [a.px_2xl, a.pt_xl] : {paddingTop: 10}]}>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm, t.atoms.text]}>
{header}
</Text>
<Text style={[a.font_bold, a.text_2xl, a.mb_sm]}>{header}</Text>
</View>
<Dialog.Close />
</View>
@@ -517,3 +526,17 @@ export function InviteLinkDialog({
</Dialog.Outer>
)
}
function joinLinkToKey(joinLink: ChatBskyGroupDefs.JoinLinkView): string {
return `${joinLink.joinRule}${joinLink.requireApproval ? ':requireApproval' : ''}`
}
function keyToJoinLink(
key: string,
): Pick<ChatBskyGroupDefs.JoinLinkView, 'joinRule' | 'requireApproval'> {
const [joinRule, requireApproval] = key.split(':')
return {
joinRule,
requireApproval: requireApproval === 'requireApproval',
}
}
+130 -119
View File
@@ -56,6 +56,7 @@ import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider'
import {MessageItem} from '#/components/dms/MessageItem'
import {MessageOverlays} from '#/components/dms/MessageOverlays'
import {NewMessagesPill} from '#/components/dms/NewMessagesPill'
import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup'
import {SystemMessageItem} from '#/components/dms/SystemMessageItem'
@@ -240,7 +241,11 @@ export function MessagesList({
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
if (!hasInitiallyScrolled.current && renderItems.length > 0) {
// Empty convos take this path too (once history is done) so hasScrolled gets set without an animated scroll.
if (
!hasInitiallyScrolled.current &&
(renderItems.length > 0 || !convoState.isFetchingHistory)
) {
hasInitiallyScrolled.current = true
flatListRef.current?.scrollToOffset({offset: height, animated: false})
// If history is already done loading, mark ready after a frame for the scroll to settle.
@@ -494,127 +499,133 @@ export function MessagesList({
return (
<InviteLinkDialogProvider convo={convoState.convo}>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(ax.features.DmsNewMessageComposerEnable) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
<MessageOverlays>
<KeyboardGestureArea
interpolator="ios"
// HACKFIX: https://github.com/kirillzyusko/react-native-keyboard-controller/issues/1419
offset={Math.round(inputHeightJS)}
// slightly too buggy unfortunately, enable when possible
// textInputNativeID={textInputId}
style={[a.flex_1]}>
{/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */}
<Animated.View style={[a.flex_1, animatedListStyle]}>
<ScrollProvider onScroll={onScroll}>
<List
ref={flatListRef}
data={renderItems}
renderItem={renderItem}
keyExtractor={keyExtractor}
disableFullWindowScroll={true}
disableVirtualization={true}
// The extra two items account for the header and the footer components
initialNumToRender={IS_NATIVE ? 32 : 62}
maxToRenderPerBatch={IS_WEB ? 32 : 62}
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
maintainVisibleContentPosition={{minIndexForVisible: 0}}
removeClippedSubviews={false}
sideBorders={false}
onContentSizeChange={onContentSizeChange}
onStartReached={onStartReached}
onScrollToIndexFailed={onScrollToIndexFailed}
showsVerticalScrollIndicator={!IS_ANDROID}
scrollEventThrottle={100}
ListHeaderComponent={
<>
<MaybeLoader isLoading={convoState.isFetchingHistory} />
{convoState.hasAllHistory ? (
convoState.convo?.kind === 'group' ? (
<MessagesListGroupInfoPanel convo={convoState.convo} />
) : (
<MessagesListInfoPanel convo={convoState.convo} />
)
) : null}
</>
}
// native only (prop is not supported on web)
renderScrollComponent={renderScrollComponent}
contentContainerStyle={{
paddingBottom: platform({
// ios is slightly larger as the input has no top padding
ios: tokens.space.lg,
android: tokens.space.md,
web: 0, // web uses ListFooterComponent instead for scroll reasons
}),
}}
ListFooterComponent={
<View
style={web({height: tokens.space.md + inputHeightJS})}
onLayout={onFooterLayout}
/>
}
style={[
web({
scrollbarWidth: 'thin',
scrollbarColor: `${t.palette.contrast_100} transparent`,
scrollbarGutter: 'stable',
}),
]}
pointerEvents={!hasScrolled ? 'none' : 'auto'}
contentInset={{top: transparentHeaderHeight}}
scrollIndicatorInsets={{top: transparentHeaderHeight}}
/>
</ScrollProvider>
</Animated.View>
<KeyboardStickyView
style={[a.absolute, a.bottom_0, a.left_0, a.right_0]}
onLayout={onInputLayout}
minimumOffset={bottomInset}
offset={{
closed: platform({
ios: tokens.space.lg, // hide bottom padding when closed
default: 0,
}),
opened: 0,
}}>
{footer ?? (
<ConversationFooter
convoState={convoState}
hasAcceptOverride={hasAcceptOverride}>
{({loading}) =>
ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
) ? (
<MessageComposer
textInputId={textInputId}
onSendMessage={(message: string) =>
void onSendMessage(message)
}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
loading={loading}>
<MessageInputEmbed
embedUri={embedUri}
setEmbed={setEmbed}
/>
</MessageInput>
)
}
</ConversationFooter>
)}
</KeyboardStickyView>
</KeyboardGestureArea>
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
{newMessagesPill.show && (
<NewMessagesPill onPress={scrollToEndOnPress} />
)}
</MessageOverlays>
</InviteLinkDialogProvider>
)
}
@@ -43,14 +43,11 @@ export function MessagesListGroupInfoPanel({
},
})
// TODO Enable this once the feature is working end-to-end. -dsb
// const joinLink = groupConvo?.details.joinLink
const isJoinLinkEnabled = false
// (isOwner && groupConvo) ||
// (!isOwner && groupConvo && joinLink?.enabledStatus === 'enabled')
const isOwner = convo.primaryMember?.did === currentAccount?.did
const isJoinLinkEnabled =
isOwner || convo.details.joinLink?.enabledStatus === 'enabled'
const members = (convo.members ?? []).filter(
profile => profile.did !== currentAccount?.did,
)
@@ -28,11 +28,11 @@ import {preferencesQueryKey} from '#/state/queries/preferences'
import {RQKEY as profileRQKey} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
} from '#/state/shell/landing'
import {useProgressGuideControls} from '#/state/shell/progress-guide'
import {
OnboardingControls,
OnboardingHeaderSlot,
@@ -123,7 +123,11 @@ export function AutomationLabelSettingsScreen({}: Props) {
paddingRight: 20, // helps visually center
},
]}>
<UserAvatar size={42} avatar={profile.avatar} type="user" />
<UserAvatar
size={42}
avatar={profile.avatar}
type={profile.associated?.labeler ? 'labeler' : 'user'}
/>
<View>
<View style={[a.flex_row, a.align_baseline]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
+1 -1
View File
@@ -9,7 +9,7 @@ import {FEEDBACK_FORM_URL} from '#/lib/constants'
import {logger} from '#/logger'
import {useServiceQuery} from '#/state/queries/service'
import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {useActiveStarterPack} from '#/state/shell/starter-pack'
import {useActiveStarterPack} from '#/state/shell/landing'
import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
import {
initialState,
@@ -19,7 +19,7 @@ import {useStarterPackQuery} from '#/state/queries/starter-packs'
import {
useActiveStarterPack,
useSetActiveStarterPack,
} from '#/state/shell/starter-pack'
} from '#/state/shell/landing'
import {LoggedOutScreenState} from '#/view/com/auth/LoggedOut'
import {formatCount} from '#/view/com/util/numeric/format'
import {Logo} from '#/view/icons/Logo'
@@ -37,12 +37,12 @@ import {
useStarterPackQuery,
} from '#/state/queries/starter-packs'
import {useAgent, useSession} from '#/state/session'
import {useSetActiveStarterPack} from '#/state/shell/landing'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
import {useSetActiveStarterPack} from '#/state/shell/starter-pack'
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader'
import {bulkWriteFollows} from '#/screens/Onboarding/util'
+3 -11
View File
@@ -13,7 +13,6 @@ import {
} from 'expo-image-manipulator'
import {nanoid} from 'nanoid/non-secure'
import {POST_IMG_MAX} from '#/lib/constants'
import {getImageDim} from '#/lib/media/manip'
import {openCropper} from '#/lib/media/picker'
import {type PickerImage} from '#/lib/media/picker.shared'
@@ -202,19 +201,12 @@ export function resetImageManipulation(
return img
}
export async function compressImage(
img: ComposerImage,
options?: {
highResolution?: boolean
increasedBlobSizeLimit?: boolean
},
): Promise<PickerImage> {
export async function compressImage(img: ComposerImage): Promise<PickerImage> {
const source = img.transformed || img.source
const highResolution = options?.highResolution ?? false
let attempts = 0
let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width
let maxBytes = options?.increasedBlobSizeLimit ? 2000000 : POST_IMG_MAX.size
let maxDimension = 4000
let maxBytes = 2000000
let minQualityPercentage = 0
let maxQualityPercentage = 101 // exclusive
+2 -2
View File
@@ -1,5 +1,5 @@
import {createContext, useContext, useMemo} from 'react'
import {BskyAgent, type ModerationOpts} from '@atproto/api'
import {AtpAgent, type ModerationOpts} from '@atproto/api'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
@@ -43,7 +43,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
...moderationPrefs,
labelers: moderationPrefs.labelers.length
? moderationPrefs.labelers
: BskyAgent.appLabelers.map(did => ({
: AtpAgent.appLabelers.map(did => ({
did,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})),
+72
View File
@@ -0,0 +1,72 @@
import {AtpAgent} from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {STALE} from '#/state/queries/index'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
export const createJoinLinkPreviewQueryKey = (args: {
codes: string[]
hasSession: boolean
}) =>
createQueryKey(joinLinkPreviewQueryKeyRoot, args, {
persistedVersion: 1,
})
export function useJoinLinkPreviewsQuery({
codes,
hasSession,
}: {
codes?: string[]
hasSession: boolean
}) {
const agent = useAgent()
return useQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: codes ?? [], hasSession}),
queryFn: async () => {
if (!codes) throw new Error('No invite code')
try {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
} catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error})
throw error
}
},
enabled: codes != null && codes.length > 0,
staleTime: STALE.SECONDS.FIFTEEN,
})
}
export function usePrefetchJoinLinkPreviews() {
const agent = useAgent()
const queryClient = useQueryClient()
return ({codes, hasSession}: {codes: string[]; hasSession: boolean}) => {
return queryClient.prefetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes, hasSession}),
queryFn: async () => {
const previewAgent = new AtpAgent({service: CHAT_SERVICE})
const res = hasSession
? await agent.chat.bsky.group.getJoinLinkPreviews(
{codes},
{headers: DM_SERVICE_HEADERS},
)
: await previewAgent.chat.bsky.group.getJoinLinkPreviews({codes})
return res.data
},
staleTime: STALE.SECONDS.FIFTEEN,
})
}
}
+129
View File
@@ -0,0 +1,129 @@
import {
type ChatBskyActorDefs,
type ChatBskyGroupApproveJoinRequest,
type ChatBskyGroupListJoinRequests,
type ChatBskyGroupRejectJoinRequest,
} from '@atproto/api'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {listConvoMembersQueryKey} from './list-convo-members'
import {createListJoinRequestsQueryKey} from './list-join-requests'
type JoinRequestAction = 'approve' | 'reject'
type JoinRequestOutput<A extends JoinRequestAction> = A extends 'approve'
? ChatBskyGroupApproveJoinRequest.OutputSchema
: ChatBskyGroupRejectJoinRequest.OutputSchema
export function useJoinRequestMutation<A extends JoinRequestAction>(
action: A,
convoId: string | undefined,
{
onSuccess,
onError,
}: {
onSuccess?: (data: JoinRequestOutput<A>) => void
onError?: (error: Error) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async ({member}: {member: string}) => {
if (!convoId) throw new Error('No convoId provided')
const endpoint =
action === 'approve'
? agent.chat.bsky.group.approveJoinRequest
: agent.chat.bsky.group.rejectJoinRequest
const {data} = await endpoint(
{convoId, member},
{headers: DM_SERVICE_HEADERS, encoding: 'application/json'},
)
return data as JoinRequestOutput<A>
},
onMutate: ({member}) => {
if (!convoId) return
const requestsKey = createListJoinRequestsQueryKey({convoId})
const prevRequests =
queryClient.getQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(requestsKey)
const requestedByProfile = prevRequests?.pages
.flatMap(page => page.requests)
.find(request => request.requestedBy.did === member)?.requestedBy
queryClient.setQueryData<
InfiniteData<ChatBskyGroupListJoinRequests.OutputSchema>
>(requestsKey, prev => {
if (!prev?.pages) return prev
return {
...prev,
pages: prev.pages.map(page => ({
...page,
requests: page.requests.filter(
request => request.requestedBy.did !== member,
),
})),
}
})
let prevMembers: ChatBskyActorDefs.ProfileViewBasic[] | undefined
if (action === 'approve' && requestedByProfile) {
const membersKey = listConvoMembersQueryKey(convoId)
prevMembers =
queryClient.getQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
membersKey,
)
queryClient.setQueryData<ChatBskyActorDefs.ProfileViewBasic[]>(
membersKey,
prev => {
if (!prev) return prev
if (prev.some(m => m.did === member)) return prev
return [...prev, requestedByProfile]
},
)
}
return {prevRequests, prevMembers}
},
onSuccess: data => {
if (convoId) {
void queryClient.invalidateQueries({
queryKey: createListJoinRequestsQueryKey({convoId}),
})
if (action === 'approve') {
void queryClient.invalidateQueries({
queryKey: listConvoMembersQueryKey(convoId),
})
}
}
onSuccess?.(data)
},
onError: (error, _variables, context) => {
logger.error(error)
if (convoId && context?.prevRequests) {
queryClient.setQueryData(
createListJoinRequestsQueryKey({convoId}),
context.prevRequests,
)
}
if (convoId && action === 'approve' && context?.prevMembers) {
queryClient.setQueryData(
listConvoMembersQueryKey(convoId),
context.prevMembers,
)
}
onError?.(error)
},
})
}
@@ -489,12 +489,22 @@ export function ListConvosProviderInner({
// Join link data not included in the log event, trigger refetch to get it
debouncedRefetch()
} else if (
ChatBskyConvoDefs.isLogIncomingJoinRequest(log) ||
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log) ||
ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
) {
// TODO Update join request count here when available. -dsb
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, -1),
)
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
(old?: ConvoListQueryData) =>
updateGroupConvoJoinRequestCount(log, old, 1),
)
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
// Viewer isn't in the chat yet, no need to do anything
} else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
@@ -762,6 +772,29 @@ function optimisticUpdate(
}
}
function updateGroupConvoJoinRequestCount(
log: {convoId: string; rev: string},
old: ConvoListQueryData | undefined,
delta: 1 | -1,
) {
return optimisticUpdate(log.convoId, old, convo => {
// Join requests are only meaningful for group convos.
if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
return {...convo, rev: log.rev}
}
const current = convo.kind.joinRequestCount ?? 0
const next = Math.max(0, current + delta)
return {
...convo,
kind: {
...convo.kind,
joinRequestCount: next === 0 ? undefined : next,
},
rev: log.rev,
}
})
}
function removeMemberFromConvoView(
convo: ChatBskyConvoDefs.ConvoView,
did: string,
@@ -0,0 +1,37 @@
import {type ChatBskyGroupRequestJoin} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
export function useRequestJoinGroupChat({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupRequestJoin.OutputSchema) => void
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const {hasSession} = useSession()
return useMutation({
mutationFn: async ({code}: {code: string}) => {
if (!hasSession) throw new Error('Must be logged in to join')
if (!code) throw new Error('No invite code')
const res = await agent.chat.bsky.group.requestJoin(
{code},
{headers: DM_SERVICE_HEADERS},
)
return res.data
},
onSuccess: data => {
onSuccess?.(data)
},
onError: error => {
logger.error('Failed to join group chat', {safeMessage: error})
onError?.(error)
},
})
}
@@ -0,0 +1,38 @@
import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api'
import {useMutation} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
export function useWithdrawJoinGroupChatRequest({
onSuccess,
onError,
}: {
onSuccess?: (data: ChatBskyGroupWithdrawJoinRequest.OutputSchema) => void
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
const {hasSession} = useSession()
return useMutation({
mutationFn: async ({convoId}: {convoId: string}) => {
if (!hasSession)
throw new Error('Must be logged in to withdraw a join request')
if (!convoId) throw new Error('No convoId provided')
const res = await agent.chat.bsky.group.withdrawJoinRequest(
{convoId},
{headers: DM_SERVICE_HEADERS},
)
return res.data
},
onSuccess: data => {
onSuccess?.(data)
},
onError: error => {
logger.error('Failed to withdraw join request', {safeMessage: error})
onError?.(error)
},
})
}
+66
View File
@@ -0,0 +1,66 @@
import {createContext, useContext, useState} from 'react'
import {logger} from '#/logger'
type StarterPackLanding = {
type: 'starterpack'
uri: string
isClip?: boolean
}
type GroupChatJoinRequestLanding = {
type: 'groupchat'
uri: string
code: string
}
type LandingType = StarterPackLanding | GroupChatJoinRequestLanding | undefined
type SetContext = (v: LandingType) => void
const stateContext = createContext<LandingType>(undefined)
stateContext.displayName = 'ActiveLandingStateContext'
const setContext = createContext<SetContext>((_: LandingType) => {})
setContext.displayName = 'ActiveLandingSetContext'
export function Provider({children}: {children: React.ReactNode}) {
const [state, setState] = useState<LandingType>()
return (
<stateContext.Provider value={state}>
<setContext.Provider value={setState}>{children}</setContext.Provider>
</stateContext.Provider>
)
}
// Core hooks
export const useActiveLanding = () => useContext(stateContext)
export const useSetActiveLanding = () => useContext(setContext)
// Filtered hooks for convenience
export const useActiveStarterPack = () => {
const landing = useActiveLanding()
return landing?.type === 'starterpack' ? landing : undefined
}
export const useSetActiveStarterPack = () => {
const setLanding = useSetActiveLanding()
const currentLanding = useActiveLanding()
return (pack: {uri: string; isClip?: boolean} | undefined) => {
if (!pack) {
setLanding(undefined)
} else {
if (currentLanding && currentLanding.type !== 'starterpack') {
logger.debug(
`[landing] Replacing ${currentLanding.type} landing with starterpack`,
)
}
setLanding({type: 'starterpack', ...pack})
}
}
}
export const useActiveGroupChatJoinRequest = () => {
const landing = useActiveLanding()
return landing?.type === 'groupchat' ? landing : undefined
}
+32 -10
View File
@@ -1,7 +1,7 @@
import {createContext, useContext, useMemo, useState} from 'react'
import {useSession} from '#/state/session'
import {useActiveStarterPack} from '#/state/shell/starter-pack'
import {useActiveLanding} from '#/state/shell/landing'
import {IS_WEB} from '#/env'
type State = {
@@ -26,7 +26,12 @@ type Controls = {
/**
* The did of the account to populate the login form with.
*/
requestedAccount?: (string & {}) | 'none' | 'new' | 'starterpack'
requestedAccount?:
| (string & {})
| 'none'
| 'new'
| 'starterpack'
| 'groupchat'
}) => void
/**
* Clears the requested account so that next time the logged out view is
@@ -48,17 +53,34 @@ const ControlsContext = createContext<Controls>({
})
ControlsContext.displayName = 'LoggedOutControlsContext'
function getRequestedAccountFromLanding(
landing: ReturnType<typeof useActiveLanding>,
hasSession: boolean,
): string | undefined {
if (hasSession || !landing) return undefined
switch (landing.type) {
case 'starterpack':
return IS_WEB ? 'starterpack' : 'new'
case 'groupchat':
return 'groupchat'
default:
return undefined
}
}
export function Provider({children}: React.PropsWithChildren<{}>) {
const activeStarterPack = useActiveStarterPack()
const activeLanding = useActiveLanding()
const {hasSession} = useSession()
const shouldShowStarterPack = Boolean(activeStarterPack?.uri) && !hasSession
const requestedAccount = getRequestedAccountFromLanding(
activeLanding,
hasSession,
)
const [state, setState] = useState<State>({
showLoggedOut: shouldShowStarterPack,
requestedAccountSwitchTo: shouldShowStarterPack
? IS_WEB
? 'starterpack'
: 'new'
: undefined,
showLoggedOut: Boolean(requestedAccount),
requestedAccountSwitchTo: requestedAccount,
})
const controls = useMemo<Controls>(
+30 -19
View File
@@ -9,6 +9,7 @@ import {PressableScale} from '#/lib/custom-animations/PressableScale'
import {STALE} from '#/state/queries'
import {profilesQueryKey} from '#/state/queries/profile'
import {useAgent, useSession} from '#/state/session'
import {useSetActiveLanding} from '#/state/shell/landing'
import {
useLoggedOutView,
useLoggedOutViewControls,
@@ -16,6 +17,7 @@ import {
import {useEnableMinimalShellMode} from '#/state/shell/minimal-mode'
import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
import {Login} from '#/screens/Login'
import {JoinRequest} from '#/screens/Messages/JoinRequest'
import {Signup} from '#/screens/Signup'
import {LandingScreen} from '#/screens/StarterPack/StarterPackLandingScreen'
import {atoms as a, native, tokens, useTheme} from '#/alf'
@@ -29,9 +31,26 @@ enum ScreenState {
S_Login,
S_CreateAccount,
S_StarterPack,
S_GroupChatJoinRequest,
}
export {ScreenState as LoggedOutScreenState}
function getInitialScreenState(requestedAccountSwitchTo?: string): ScreenState {
switch (requestedAccountSwitchTo) {
case 'new':
return ScreenState.S_CreateAccount
case 'starterpack':
return ScreenState.S_StarterPack
case 'groupchat':
return ScreenState.S_GroupChatJoinRequest
case undefined:
return ScreenState.S_LoginOrCreateAccount
default:
// DID or other string = login with that account
return ScreenState.S_Login
}
}
export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const {_} = useLingui()
const ax = useAnalytics()
@@ -39,18 +58,11 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
const insets = useSafeAreaInsets()
useEnableMinimalShellMode()
const {requestedAccountSwitchTo} = useLoggedOutView()
const [screenState, setScreenState] = useState<ScreenState>(() => {
if (requestedAccountSwitchTo === 'new') {
return ScreenState.S_CreateAccount
} else if (requestedAccountSwitchTo === 'starterpack') {
return ScreenState.S_StarterPack
} else if (requestedAccountSwitchTo != null) {
return ScreenState.S_Login
} else {
return ScreenState.S_LoginOrCreateAccount
}
})
const initialScreenState = getInitialScreenState(requestedAccountSwitchTo)
const [screenState, setScreenState] =
useState<ScreenState>(initialScreenState)
const {clearRequestedAccount} = useLoggedOutViewControls()
const setActiveLanding = useSetActiveLanding()
const queryClient = useQueryClient()
const {accounts} = useSession()
@@ -73,7 +85,9 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
onDismiss()
}
clearRequestedAccount()
}, [clearRequestedAccount, onDismiss])
// Clear landing context when user dismisses the modal
setActiveLanding(undefined)
}, [clearRequestedAccount, onDismiss, setActiveLanding])
return (
<View
@@ -107,6 +121,8 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
{screenState === ScreenState.S_StarterPack ? (
<LandingScreen setScreenState={setScreenState} />
) : screenState === ScreenState.S_GroupChatJoinRequest ? (
<JoinRequest setScreenState={setScreenState} />
) : screenState === ScreenState.S_LoginOrCreateAccount ? (
<SplashScreen
onPressSignin={() => {
@@ -122,17 +138,12 @@ export function LoggedOut({onDismiss}: {onDismiss?: () => void}) {
{screenState === ScreenState.S_Login ? (
<Login
onPressBack={() => {
setScreenState(ScreenState.S_LoginOrCreateAccount)
clearRequestedAccount()
setScreenState(initialScreenState)
}}
/>
) : undefined}
{screenState === ScreenState.S_CreateAccount ? (
<Signup
onPressBack={() =>
setScreenState(ScreenState.S_LoginOrCreateAccount)
}
/>
<Signup onPressBack={() => setScreenState(initialScreenState)} />
) : undefined}
</ErrorBoundary>
</View>

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