Compare commits

..

1 Commits

Author SHA1 Message Date
Eric Bailey 6754e89a74 Fire embed:standardSite:view from feed viewability, not embed mount
Move the standard site view metric out of the embed (where it fired on
mount regardless of visibility) into PostFeed's onItemSeen handler, so it
only fires once per URI and only when the post is actually on screen,
matching the existing post:view and live:view:post tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:06:31 -05:00
198 changed files with 52096 additions and 84794 deletions
+1 -1
View File
@@ -51,4 +51,4 @@ jobs:
# NOTE(sfn): we can add a custom system prompt here
claude_args: |
--model claude-opus-4-8
--model claude-opus-4-7
-2
View File
@@ -20,7 +20,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Check
@@ -38,7 +37,6 @@ jobs:
uses: actions/setup-go@v6
with:
go-version-file: bskyweb/go.mod
cache-dependency-path: bskyweb/go.sum
- name: Dummy Static Files
run: touch bskyweb/static/js/blah.js && touch bskyweb/static/css/blah.txt && touch bskyweb/static/media/blah.txt
- name: Lint
+10 -2
View File
@@ -57,7 +57,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
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: Lint checks
@@ -95,7 +99,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
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: Run tests
@@ -26,7 +26,11 @@ jobs:
node-version-file: package.json
cache: pnpm
- name: pnpm install
run: pnpm install --frozen-lockfile
uses: Wandalen/wretry.action@master
with:
command: pnpm install --frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Extract language strings
run: pnpm intl:extract
- name: Create commit
+6 -2
View File
@@ -34,8 +34,12 @@ jobs:
run: git show "origin/$BASE_REF:pnpm-lock.yaml" > pnpm-lock.yaml
- name: pnpm install
# Fine to skip scripts since we don't run any code
run: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
uses: Wandalen/wretry.action@master
with:
# Fine to skip scripts since we don't run any code
command: pnpm clean && pnpm install --ignore-scripts --no-frozen-lockfile
attempt_limit: 3
attempt_delay: 2000
- name: Verify pnpm-lock.yaml
run: |
+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.5.2 && \
npm install --global pnpm@11.5.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+20 -20
View File
@@ -184,39 +184,39 @@ describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
['https://bsky.app/chat/abcdefg', 'abcdefg'],
['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
['https://bsky.app/c/abcdefg', 'abcdefg'],
['https://bsky.app/c/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
['http://bsky.app/chat/abcdefg', undefined],
['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
['/chat/abcdefg', 'abcdefg'],
['/chat/abcdefg?utm=foo', 'abcdefg'],
['/chat/abcdefg#section', 'abcdefg'],
['http://bsky.app/c/abcdefg', undefined],
['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
['https://bsky.app/c/abcdefg#section', 'abcdefg'],
['/c/abcdefg', 'abcdefg'],
['/c/abcdefg?utm=foo', 'abcdefg'],
['/c/abcdefg#section', 'abcdefg'],
// too short
['https://bsky.app/chat/abcdef', undefined],
['/chat/abcdef', undefined],
['https://bsky.app/c/abcdef', undefined],
['/c/abcdef', undefined],
// too long
['https://bsky.app/chat/abcdefghijk', undefined],
['/chat/abcdefghijk', undefined],
['https://bsky.app/c/abcdefghijk', undefined],
['/c/abcdefghijk', undefined],
// invalid characters
['https://bsky.app/chat/abc-def', undefined],
['/chat/abc def', undefined],
['https://bsky.app/c/abc-def', undefined],
['/c/abc def', undefined],
// trailing path
['https://bsky.app/chat/abcdefg/extra', undefined],
['/chat/abcdefg/extra', undefined],
['https://bsky.app/c/abcdefg/extra', undefined],
['/c/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
['https://bsky.app/chat', undefined],
['https://bsky.app/c', undefined],
// wrong host
['https://example.com/chat/abcdefg', undefined],
['https://example.com/c/abcdefg', undefined],
// not a url, not a path
['chat/abcdefg', undefined],
['c/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
['https://[invalid/chat/abcdefg', undefined],
['https://[invalid/c/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

+1 -1
View File
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/api": "0.20.6",
"@atproto/common": "^0.6.1",
"@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2",
+5 -5
View File
@@ -208,8 +208,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.11
version: 0.20.11
specifier: 0.20.6
version: 0.20.6
'@atproto/common':
specifier: ^0.6.1
version: 0.6.1
@@ -259,8 +259,8 @@ importers:
packages:
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
'@atproto/api@0.20.6':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -1154,7 +1154,7 @@ packages:
snapshots:
'@atproto/api@0.20.11':
'@atproto/api@0.20.6':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert'
import {ChatBskyGroupDefs} from '@atproto/api'
import {type ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js'
import {type Express} from 'express'
import satori from 'satori'
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
codes: [code],
})
const found = result.data.joinLinkPreviews[0]
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
if (!found) {
return res.status(404).end('not found')
}
preview = found
+3 -34
View File
@@ -136,9 +136,9 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
}
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
// or video embed, byte-identical to what we put in og:image. Callers
// derive thumbnailUrl from urls[0].
// extractPostMedia returns thumbnail URLs for the post's image or video
// embed, byte-identical to what we put in og:image. Callers derive
// thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden {
return nil
@@ -147,9 +147,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images)
}
if pv.Embed.EmbedGallery_View != nil {
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
}
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
}
@@ -158,9 +155,6 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images)
}
if media.EmbedGallery_View != nil {
return galleryThumbs(media.EmbedGallery_View.Items)
}
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail}
}
@@ -180,31 +174,6 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls
}
// galleryThumbs returns the thumbnail URLs of image items in a gallery
// embed, or nil if empty. Items_Elem is a union; non-image variants and
// nil entries are skipped so future gallery item types don't break SEO
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
// <meta property="og:image" content=""> if the appview ever returns one.
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
if len(items) == 0 {
return nil
}
urls := make([]string, 0, len(items))
for _, item := range items {
if item == nil || item.EmbedGallery_ViewImage == nil {
continue
}
if item.EmbedGallery_ViewImage.Thumbnail == "" {
continue
}
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
}
if len(urls) == 0 {
return nil
}
return urls
}
// extractQuotedPostURL returns the canonical URL of a quoted post, or ""
// if the embed is blocked / not-found / detached / a non-post record.
func extractQuotedPostURL(pv *appbsky.FeedDefs_PostView) string {
-155
View File
@@ -72,60 +72,6 @@ func withImages(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
}
}
// withGallery adds an app.bsky.embed.gallery view with image items.
func withGallery(thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
}
}
}
// withRecordWithMediaGallery adds a record-with-media embed whose media slot
// is an app.bsky.embed.gallery view.
func withRecordWithMediaGallery(qHandle, qDid, qRkey string, thumbs ...string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
var items []*appbsky.EmbedGallery_View_Items_Elem
for _, t := range thumbs {
items = append(items, &appbsky.EmbedGallery_View_Items_Elem{
EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{
Thumbnail: t,
Fullsize: t + "_full",
},
})
}
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedRecordWithMedia_View: &appbsky.EmbedRecordWithMedia_View{
Record: &appbsky.EmbedRecord_View{
Record: &appbsky.EmbedRecord_View_Record{
EmbedRecord_ViewRecord: &appbsky.EmbedRecord_ViewRecord{
Uri: "at://" + qDid + "/app.bsky.feed.post/" + qRkey,
Cid: "bafy-quoted",
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: qDid,
Handle: qHandle,
},
IndexedAt: "2024-01-01T00:00:00Z",
},
},
},
Media: &appbsky.EmbedRecordWithMedia_View_Media{
EmbedGallery_View: &appbsky.EmbedGallery_View{Items: items},
},
},
}
}
}
// withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -353,88 +299,6 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
}
}
func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok {
t.Fatalf("image should be array, got %T", main["image"])
}
if len(imgs) != 3 {
t.Errorf("expected 3 gallery images, got %d", len(imgs))
}
if imgs[0] != thumb1 || imgs[1] != thumb2 || imgs[2] != thumb3 {
t.Errorf("gallery image[] order wrong: %v", imgs)
}
if main["thumbnailUrl"] != thumb1 {
t.Errorf("thumbnailUrl should equal image[0] (Google byte-equality requirement), got %v", main["thumbnailUrl"])
}
}
// Gallery in the media slot of a record-with-media embed should still
// produce og:image / JSON-LD image[]. Quote-post URL still emits
// alongside via isBasedOn.
func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
imgs, ok := main["image"].([]any)
if !ok || len(imgs) != 1 || imgs[0] != thumb {
t.Errorf("expected single gallery thumb in image[], got %v", main["image"])
}
if main["thumbnailUrl"] != thumb {
t.Errorf("thumbnailUrl wrong: %v", main["thumbnailUrl"])
}
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn should still emit for record-with-media gallery, got %v", main["isBasedOn"])
}
}
// Forward-compat: nil items, unknown-variant union elements, and empty
// Thumbnail strings must be skipped, not panic or leak as <meta
// property="og:image" content="">. Unknown variants are dropped silently
// so older deploys keep working when new gallery item types ship.
func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery")
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{
nil,
{}, // empty union, no variant set
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: ""}}, // empty Thumbnail
{EmbedGallery_ViewImage: &appbsky.EmbedGallery_ViewImage{Thumbnail: thumb}},
},
},
}
got := extractPostMedia(pv, false)
if len(got) != 1 || got[0] != thumb {
t.Errorf("expected single thumb, got %v", got)
}
// All-nil / all-unknown gallery should produce no thumbs (not [""]).
pv.Embed = &appbsky.FeedDefs_PostView_Embed{
EmbedGallery_View: &appbsky.EmbedGallery_View{
Items: []*appbsky.EmbedGallery_View_Items_Elem{nil, {}},
},
}
if got := extractPostMedia(pv, false); got != nil {
t.Errorf("expected nil for empty/unknown-only gallery, got %v", got)
}
}
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
@@ -497,25 +361,6 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
}
}
// Symmetric guard for the gallery extraction path. Functionally redundant
// with the early-return at the top of extractPostMedia, but exists so the
// hide-embed contract is asserted directly against the gallery branch -
// catches anyone who later moves the embedHidden check inside an
// embed-shape branch.
func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
}
if _, present := main["thumbnailUrl"]; present {
t.Errorf("hidden-embed gallery post should not emit thumbnailUrl")
}
}
func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
-88
View File
@@ -43,13 +43,6 @@ func extractJSONLD(t *testing.T, html string) string {
return strings.TrimSpace(m[1])
}
func TestRenderBase_NoindexMeta(t *testing.T) {
html := renderTemplate(t, "base.html", pongo2.Context{"noindex": true, "nofollow": true})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("expected combined noindex,nofollow meta; got:\n%s", html)
}
}
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
@@ -105,40 +98,6 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
}
}
// Gallery posts must hit the same og:image / JSON-LD image[] byte-equality
// contract that legacy images posts do. Regression guard for the
// app.bsky.embed.gallery extraction path.
func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g1@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
"imgThumbUrls": thumbs,
})
if !strings.Contains(html, `<meta property="og:image" content="`+thumb1+`">`) {
t.Errorf("og:image[0] not found in rendered HTML for gallery post")
}
if !strings.Contains(html, `<meta property="og:image" content="`+thumb2+`">`) {
t.Errorf("og:image[1] not found in rendered HTML for gallery post")
}
body := extractJSONLD(t, html)
var parsed map[string]any
_ = json.Unmarshal([]byte(body), &parsed)
main := parsed["mainEntity"].(map[string]any)
imgs := main["image"].([]any)
if len(imgs) != 2 || imgs[0] != thumb1 || main["thumbnailUrl"] != thumb1 {
t.Errorf("JSON-LD image strings drifted from og:image for gallery; image=%v thumbnailUrl=%v",
imgs, main["thumbnailUrl"])
}
}
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
@@ -250,50 +209,3 @@ func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
t.Errorf("og:video:type should emit even without imgThumbUrls; got:\n%s", html)
}
}
// Auth-required posts must emit noindex,nofollow so the stub page (no body
// text, no comments) is not indexed.
func TestRenderPost_AuthRequiredNoindex(t *testing.T) {
html := renderTemplate(t, "post.html", pongo2.Context{
"requiresAuth": true,
"profileHandle": "alice.bsky.social",
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required post should emit noindex,nofollow; got:\n%s", html)
}
}
// Auth-required profiles must emit noindex,nofollow.
func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
pv := newProfileViewDetailed()
html := renderTemplate(t, "profile.html", pongo2.Context{
"profileView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social",
"requiresAuth": true,
"noindex": true,
"nofollow": true,
})
if !strings.Contains(html, `<meta name="robots" content="noindex, nofollow">`) {
t.Errorf("auth-required profile should emit noindex,nofollow; got:\n%s", html)
}
}
// Public posts must NOT emit a robots meta tag. Guards against an accidental
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"canonicalURL": "https://bsky.app/profile/alice.bsky.social/post/abc123",
"postJSONLD": ld,
})
if strings.Contains(html, `<meta name="robots"`) {
t.Errorf("public post should not emit robots meta; got:\n%s", html)
}
}
+46 -81
View File
@@ -88,7 +88,7 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
// optional client for the chat appview, used by /chat/<code> for OG previews.
// optional client for the chat appview, used by /c/<code> for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
@@ -296,50 +296,50 @@ func serve(cctx *cli.Context) error {
// generic routes
e.GET("/hashtag/:tag", server.WebGeneric)
e.GET("/topic/:topic", server.WebGeneric)
e.GET("/search", server.WebGenericNoindex)
e.GET("/feeds", server.WebGenericNoindex)
e.GET("/notifications", server.WebGenericNoindex)
e.GET("/notifications/settings", server.WebGenericNoindex)
e.GET("/notifications/activity", server.WebGenericNoindex)
e.GET("/lists", server.WebGenericNoindex)
e.GET("/moderation", server.WebGenericNoindex)
e.GET("/moderation/modlists", server.WebGenericNoindex)
e.GET("/moderation/muted-accounts", server.WebGenericNoindex)
e.GET("/moderation/blocked-accounts", server.WebGenericNoindex)
e.GET("/moderation/verification-settings", server.WebGenericNoindex)
e.GET("/settings", server.WebGenericNoindex)
e.GET("/settings/language", server.WebGenericNoindex)
e.GET("/settings/app-passwords", server.WebGenericNoindex)
e.GET("/settings/following-feed", server.WebGenericNoindex)
e.GET("/settings/saved-feeds", server.WebGenericNoindex)
e.GET("/settings/threads", server.WebGenericNoindex)
e.GET("/settings/external-embeds", server.WebGenericNoindex)
e.GET("/settings/accessibility", server.WebGenericNoindex)
e.GET("/settings/appearance", server.WebGenericNoindex)
e.GET("/settings/account", server.WebGenericNoindex)
e.GET("/settings/automation-label", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security", server.WebGenericNoindex)
e.GET("/settings/privacy-and-security/activity", server.WebGenericNoindex)
e.GET("/settings/content-and-media", server.WebGenericNoindex)
e.GET("/settings/interests", server.WebGenericNoindex)
e.GET("/settings/about", server.WebGenericNoindex)
e.GET("/settings/notifications", server.WebGenericNoindex)
e.GET("/sys/debug", server.WebGenericNoindex)
e.GET("/sys/debug-mod", server.WebGenericNoindex)
e.GET("/sys/log", server.WebGenericNoindex)
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
e.GET("/moderation/muted-accounts", server.WebGeneric)
e.GET("/moderation/blocked-accounts", server.WebGeneric)
e.GET("/moderation/verification-settings", server.WebGeneric)
e.GET("/settings", server.WebGeneric)
e.GET("/settings/language", server.WebGeneric)
e.GET("/settings/app-passwords", server.WebGeneric)
e.GET("/settings/following-feed", server.WebGeneric)
e.GET("/settings/saved-feeds", server.WebGeneric)
e.GET("/settings/threads", server.WebGeneric)
e.GET("/settings/external-embeds", server.WebGeneric)
e.GET("/settings/accessibility", server.WebGeneric)
e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/automation-label", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
e.GET("/settings/notifications", server.WebGeneric)
e.GET("/sys/debug", server.WebGeneric)
e.GET("/sys/debug-mod", server.WebGeneric)
e.GET("/sys/log", server.WebGeneric)
e.GET("/support", server.WebGeneric)
e.GET("/support/privacy", server.WebGeneric)
e.GET("/support/tos", server.WebGeneric)
e.GET("/support/community-guidelines", server.WebGeneric)
e.GET("/support/copyright", server.WebGeneric)
e.GET("/intent/compose", server.WebGenericNoindexNofollow)
e.GET("/intent/verify-email", server.WebGenericNoindexNofollow)
e.GET("/intent/age-assurance", server.WebGenericNoindexNofollow)
e.GET("/messages", server.WebGenericNoindex)
e.GET("/messages/inbox", server.WebGenericNoindex)
e.GET("/messages/:conversation", server.WebGenericNoindex)
e.GET("/messages/:conversation/settings", server.WebGenericNoindex)
e.GET("/messages/:conversation/requests", server.WebGenericNoindex)
e.GET("/intent/compose", server.WebGeneric)
e.GET("/intent/verify-email", server.WebGeneric)
e.GET("/intent/age-assurance", server.WebGeneric)
e.GET("/messages", server.WebGeneric)
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)
@@ -363,14 +363,14 @@ func serve(cctx *cli.Context) error {
// starter packs
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/starter-pack-short/:code", server.WebGenericNoindex)
e.GET("/starter-pack-short/:code", server.WebGeneric)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
e.GET("/chat/:code", server.WebChatInvite)
e.GET("/c/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGenericNoindex)
e.GET("/saved", server.WebGeneric)
// ipcc
e.GET("/ipcc", server.WebIpCC)
@@ -437,8 +437,6 @@ func (srv *Server) NewTemplateContext() pongo2.Context {
return pongo2.Context{
"staticCDNHost": srv.cfg.staticCDNHost,
"favicon": fmt.Sprintf("%s/static/favicon.png", srv.cfg.staticCDNHost),
"noindex": false,
"nofollow": false,
}
}
@@ -491,38 +489,10 @@ func (srv *Server) LinkProxyMiddleware(url *url.URL) echo.MiddlewareFunc {
)
}
// renderOptions controls per-request rendering flags for the generic web handler.
type renderOptions struct {
noindex bool
nofollow bool
}
// webGeneric returns a handler that renders the base SPA shell with the given
// render options applied to the template context.
func (srv *Server) webGeneric(c echo.Context, o renderOptions) error {
data := srv.NewTemplateContext()
data["noindex"] = o.noindex
data["nofollow"] = o.nofollow
return c.Render(http.StatusOK, "base.html", data)
}
// handler for endpoint that have no specific server-side handling
func (srv *Server) WebGeneric(c echo.Context) error {
return srv.webGeneric(c, renderOptions{})
}
// handler for routes that should not be indexed by search engines
// (e.g. auth-only user-state surfaces, internal/debug pages, action/intent dispatch URLs, search results)
func (srv *Server) WebGenericNoindex(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true})
}
// handler for action/intent dispatch URLs (e.g. /intent/compose). These accept
// arbitrary query parameters from arbitrary third-party referrers, so we treat
// them as link-graph dead-ends in addition to noindex. Anything legitimately
// reachable from a hydrated intent page is also reachable via its canonical URL.
func (srv *Server) WebGenericNoindexNofollow(c echo.Context) error {
return srv.webGeneric(c, renderOptions{noindex: true, nofollow: true})
data := srv.NewTemplateContext()
return c.Render(http.StatusOK, "base.html", data)
}
func (srv *Server) WebHome(c echo.Context) error {
@@ -597,8 +567,6 @@ func (srv *Server) WebPost(c echo.Context) error {
data["canonicalURL"] = canonicalURL
}
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
@@ -705,7 +673,6 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
req := c.Request()
ctx := req.Context()
data := srv.NewTemplateContext()
data["noindex"] = true
data["requestURI"] = fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
code := c.Param("code")
@@ -728,7 +695,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
// bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
// 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)
@@ -793,8 +760,6 @@ func (srv *Server) WebProfile(c echo.Context) error {
}
} else {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
}
if jsonld, err := buildProfileJSONLD(pv, recentPosts, hideEmbedLabels, hideReplyLabels); err == nil {
+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-20260605210604-af2fec94f34c
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 -2
View File
@@ -2,8 +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-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/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=
-1
View File
@@ -139,7 +139,6 @@
<meta name="theme-color">
<meta name="application-name" content="Bluesky">
<meta name="generator" content="bskyweb">
{% if noindex %}<meta name="robots" content="noindex{% if nofollow %}, nofollow{% endif %}">{% endif %}
<meta property="og:site_name" content="Bluesky Social">
<meta property="og:logo" content="{{ favicon }}">
<meta name="twitter:site" content="@bluesky" />
+59 -109
View File
@@ -1,113 +1,4 @@
{
"modules/bottom-sheet/src/BottomSheetNativeComponent.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/bottom-sheet/src/lib/Portal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
"@typescript-eslint/require-await": {
"count": 4
}
},
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"@typescript-eslint/require-await": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 3
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 3
}
},
"modules/expo-bluesky-swiss-army/src/Referrer/index.android.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-unsafe-call": {
"count": 9
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 9
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.native.tsx": {
"@typescript-eslint/no-unsafe-call": {
"count": 1
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
"@typescript-eslint/require-await": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
@@ -128,6 +19,11 @@
"count": 1
}
},
"src/ageAssurance/util.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -365,6 +261,11 @@
"count": 1
}
},
"src/components/Post/Embed/StandardSiteEmbed/index.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -707,6 +608,11 @@
"count": 1
}
},
"src/components/dms/MessageItem.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -749,6 +655,14 @@
"count": 2
}
},
"src/components/hooks/useFullscreen.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/hooks/useLandingEntry.native.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
@@ -1437,6 +1351,9 @@
}
},
"src/screens/Profile/components/ProfileFeedHeader.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 5
}
@@ -1864,6 +1781,16 @@
"count": 7
}
},
"src/state/queries/messages/accept-conversation.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
}
},
"src/state/queries/messages/update-all-read.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 3
}
},
"src/state/queries/my-lists.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
@@ -1994,6 +1921,12 @@
"src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/state/shell/color-mode.tsx": {
@@ -2356,6 +2289,23 @@
"count": 2
}
},
"src/view/com/profile/ProfileMenu.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 6
},
"@typescript-eslint/no-floating-promises": {
"count": 4
},
"@typescript-eslint/no-misused-promises": {
"count": 3
},
"@typescript-eslint/no-unsafe-call": {
"count": 6
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 12
}
},
"src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 1
@@ -67,18 +67,7 @@ export class GifView extends PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
try {
await this.videoPlayerRef.current?.play()
} catch (err) {
// `play()` rejects with a NotAllowedError when the browser blocks
// playback (e.g. Safari low-power mode or autoplay policy). This is
// expected and benign - the GIF simply stays paused - so swallow it
// rather than letting it surface as an unhandled rejection.
if (err instanceof DOMException && err.name === 'NotAllowedError') {
return
}
throw err
}
this.videoPlayerRef.current?.play()
}
async pauseAsync(): Promise<void> {
@@ -1,12 +1,12 @@
import React from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {StyleProp, ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {type VisibilityViewProps} from './types'
import {VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode
enabled: boolean
enabled: Boolean
style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
@@ -6,7 +6,6 @@ import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.util.Log
import androidx.core.net.toUri
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
@@ -14,8 +13,6 @@ import java.io.File
import java.io.FileOutputStream
import java.net.URLEncoder
private const val TAG = "ExpoReceiveAndroidIntents"
enum class AttachmentType {
IMAGE,
VIDEO,
@@ -122,15 +119,17 @@ class ExpoReceiveAndroidIntentsModule : Module() {
uris: List<Uri>,
text: String?,
) {
// Some URIs we receive may be unreadable (revoked permission, deleted file,
// a provider that rejects the read). Skip those rather than crashing the
// whole app, since this runs synchronously on the module init path.
val allParams =
uris
.mapNotNull { uri -> getImageInfo(uri) }
.joinToString(",") { info -> buildUriData(info) }
var allParams = ""
if (allParams.isEmpty()) return
uris.forEachIndexed { index, uri ->
val info = getImageInfo(uri)
val params = buildUriData(info)
allParams = "${allParams}$params"
if (index < uris.count() - 1) {
allParams = "$allParams,"
}
}
val encodedUris = URLEncoder.encode(allParams, "UTF-8")
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -159,30 +158,12 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
val file = createFile(extension)
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Bail rather than crashing the whole
// app, since this runs synchronously on the module init path.
try {
FileOutputStream(file).use { out ->
val input =
appContext.currentActivity?.contentResolver?.openInputStream(uri)
?: run {
file.delete()
return
}
input.use { it.copyTo(out) }
}
} catch (e: Exception) {
Log.w(TAG, "Failed to copy shared video to cache", e)
file.delete()
return
val out = FileOutputStream(file)
appContext.currentActivity?.contentResolver?.openInputStream(uri)?.use {
it.copyTo(out)
}
val info =
getVideoInfo(uri) ?: run {
file.delete()
return
}
val info = getVideoInfo(uri) ?: return
val encodedText = text?.let { URLEncoder.encode(it, "UTF-8") }
@@ -195,29 +176,15 @@ class ExpoReceiveAndroidIntentsModule : Module() {
}
}
private fun getImageInfo(uri: Uri): Map<String, Any>? {
val bitmap =
try {
MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
} catch (e: Exception) {
// The URI may be unreadable (revoked permission, deleted file, or a
// provider that rejects the read). Skip this image rather than crash.
Log.w(TAG, "Failed to read shared image", e)
return null
} ?: return null
private fun getImageInfo(uri: Uri): Map<String, Any> {
val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
// We have to save this so that we can access it later when uploading the image.
// createTempFile will automatically place a unique string between "img" and "temp.jpeg"
val file = createFile("jpeg")
try {
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to write shared image to cache", e)
file.delete()
return null
}
val out = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
out.flush()
out.close()
return mapOf(
"width" to bitmap.width,
@@ -228,19 +195,10 @@ class ExpoReceiveAndroidIntentsModule : Module() {
private fun getVideoInfo(uri: Uri): Map<String, Any>? {
val retriever = MediaMetadataRetriever()
val width: Int?
val height: Int?
try {
retriever.setDataSource(appContext.currentActivity, uri)
width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
} catch (e: Exception) {
// The URI may be unreadable or not a valid media source. Skip rather than crash.
Log.w(TAG, "Failed to read shared video metadata", e)
return null
} finally {
retriever.release()
}
retriever.setDataSource(appContext.currentActivity, uri)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toIntOrNull()
if (width == null || height == null) {
return null
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.124.0",
"version": "1.123.0",
"private": true,
"engines": {
"node": ">=24.15.0"
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.5.2",
"version": "11.5.0",
"onFail": "warn"
},
"runtime": {
@@ -59,7 +59,7 @@
"test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src modules",
"lint": "eslint --cache --quiet src",
"lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json",
@@ -93,7 +93,7 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.11",
"@atproto/api": "0.20.9",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
+43 -43
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.5.2
version: 11.5.2
specifier: 11.5.0
version: 11.5.0
pnpm:
specifier: 11.5.2
version: 11.5.2
specifier: 11.5.0
version: 11.5.0
packages:
'@pnpm/exe@11.5.2':
resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
'@pnpm/exe@11.5.0':
resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
hasBin: true
'@pnpm/linux-arm64@11.5.2':
resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
'@pnpm/linux-arm64@11.5.0':
resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.5.2':
resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
'@pnpm/linux-x64@11.5.0':
resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.5.2':
resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
'@pnpm/linuxstatic-arm64@11.5.0':
resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.5.2':
resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
'@pnpm/linuxstatic-x64@11.5.0':
resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.5.2':
resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
'@pnpm/macos-arm64@11.5.0':
resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.5.2':
resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
'@pnpm/win-arm64@11.5.0':
resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.5.2':
resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
'@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.5.2:
resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
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.5.2':
'@pnpm/exe@11.5.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.5.2
'@pnpm/linux-x64': 11.5.2
'@pnpm/linuxstatic-arm64': 11.5.2
'@pnpm/linuxstatic-x64': 11.5.2
'@pnpm/macos-arm64': 11.5.2
'@pnpm/win-arm64': 11.5.2
'@pnpm/win-x64': 11.5.2
'@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.5.2':
'@pnpm/linux-arm64@11.5.0':
optional: true
'@pnpm/linux-x64@11.5.2':
'@pnpm/linux-x64@11.5.0':
optional: true
'@pnpm/linuxstatic-arm64@11.5.2':
'@pnpm/linuxstatic-arm64@11.5.0':
optional: true
'@pnpm/linuxstatic-x64@11.5.2':
'@pnpm/linuxstatic-x64@11.5.0':
optional: true
'@pnpm/macos-arm64@11.5.2':
'@pnpm/macos-arm64@11.5.0':
optional: true
'@pnpm/win-arm64@11.5.2':
'@pnpm/win-arm64@11.5.0':
optional: true
'@pnpm/win-x64@11.5.2':
'@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.5.2: {}
pnpm@11.5.0: {}
---
lockfileVersion: '9.0'
@@ -242,8 +242,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.11
version: 0.20.11
specifier: 0.20.9
version: 0.20.9
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -877,8 +877,8 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.11':
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
'@atproto/api@0.20.9':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.11':
'@atproto/api@0.20.9':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
+1 -1
View File
@@ -804,7 +804,7 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
// Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
// 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
+1 -1
View File
@@ -1,3 +1,3 @@
export const prefetchAgeAssuranceServerData = () => {}
export const prefetchAgeAssuranceData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
const {metadata} = useAgeAssuranceServerDataContext()
const {data} = useAgeAssuranceDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
const hasDeclaredAge = metadata?.declaredAge !== undefined
const hasDeclaredAge = data?.declaredAge !== undefined
const canUpdateBirthday =
isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
useEffect(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
accountCreatedAt: data?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
-28
View File
@@ -1,28 +0,0 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
/**
* Minimum age required to access the app at all.
*/
export const MIN_ACCESS_AGE = 13
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
+22 -24
View File
@@ -24,7 +24,6 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
import {
getBirthdateStringFromAge,
isLegacyBirthdateBug,
@@ -486,9 +485,9 @@ export function useOtherRequiredDataQuery() {
}
/**
* Helper to prefetch all age assurance data from the server.
* Helper to prefetch all age assurance data.
*/
export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
@@ -497,8 +496,8 @@ export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
])
}
export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
export function clearAgeAssuranceDataForDid({did}: {did: string}) {
logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
qc.removeQueries({
queryKey: createOtherRequiredDataQueryKey({did}),
@@ -506,8 +505,8 @@ export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
})
}
export function clearAgeAssuranceServerDataForAll() {
logger.debug(`clearAgeAssuranceServerDataForAll`)
export function clearAgeAssuranceData() {
logger.debug(`clearAgeAssuranceData`)
qc.clear()
}
@@ -515,30 +514,30 @@ export function clearAgeAssuranceServerDataForAll() {
* Context
*/
export type AgeAssuranceServerData = {
/**
* The raw config from the appview.
*/
export type AgeAssuranceData = {
config: AppBskyAgeassuranceDefs.Config | undefined
/**
* The raw state from the appview. Must be further processed before being useful.
*/
state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | undefined
data:
| {
accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
declaredAge: number | undefined
birthdate: string | undefined
}
| undefined
}
const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
config: undefined,
state: undefined,
metadata: {
data: {
accountCreatedAt: undefined,
declaredAge: undefined,
birthdate: undefined,
},
})
export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext)
export function useAgeAssuranceDataContext() {
return useContext(AgeAssuranceDataContext)
}
export function AgeAssuranceServerDataProvider({
export function AgeAssuranceDataProvider({
children,
}: {
children: React.ReactNode
@@ -551,8 +550,7 @@ export function AgeAssuranceServerDataProvider({
() => ({
config,
state,
metadata: {
// yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
data: {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
@@ -563,8 +561,8 @@ export function AgeAssuranceServerDataProvider({
[config, state, data, metadata],
)
return (
<AgeAssuranceServerDataContext.Provider value={ctx}>
<AgeAssuranceDataContext.Provider value={ctx}>
{children}
</AgeAssuranceServerDataContext.Provider>
</AgeAssuranceDataContext.Provider>
)
}
+28 -213
View File
@@ -26,8 +26,35 @@ export const deviceGeolocation: Geolocation | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'BB',
regionCode: undefined,
minAccessAge: 16,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
],
}
export const otherRequiredData: OtherRequiredData = {
birthdate: new Date(2010, 12, 1).toISOString(),
birthdate: new Date(2000, 1, 1).toISOString(),
}
const serverStateEnabled = false || IS_E2E
@@ -45,218 +72,6 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
}
: undefined
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
countryCode: 'AA',
regionCode: undefined,
minAccessAge: 13,
rules: [
{
$type: ids.Default,
access: 'full',
},
],
},
{
countryCode: 'GB',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'AU',
minAccessAge: 16,
rules: [
{
date: '2025-12-10T00:00:00Z',
access: 'none',
$type: ids.IfAccountNewerThan,
},
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'SD',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'WY',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'OH',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'MS',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'VA',
minAccessAge: 16,
rules: [
{
age: 16,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 16,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'US',
regionCode: 'TN',
minAccessAge: 18,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
{
countryCode: 'BR',
minAccessAge: 13,
rules: [
{
age: 18,
access: 'full',
$type: ids.IfAssuredOverAge,
},
{
age: 18,
access: 'full',
$type: ids.IfDeclaredOverAge,
},
{
age: 13,
access: 'safe',
$type: ids.IfDeclaredOverAge,
},
{
access: 'none',
$type: ids.Default,
},
],
},
],
}
export async function resolve<T>(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
return data
+49 -40
View File
@@ -1,12 +1,11 @@
import {createContext, useCallback, useContext, useMemo} from 'react'
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {
AgeAssuranceServerDataProvider,
useAgeAssuranceServerDataContext,
AgeAssuranceDataProvider,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
@@ -15,29 +14,37 @@ import {
} from '#/ageAssurance/state'
import {
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {
computeAgeAssuranceFlags,
isUnderAge,
maybeRestrictChatSettings,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
prefetchAgeAssuranceServerData,
prefetchAgeAssuranceData,
refetchServerState as refetchAgeAssuranceServerState,
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
Status: typeof AgeAssuranceStatus
state: AgeAssuranceState
flags: AgeAssuranceFlags
flags: {
adultContentDisabled: boolean
chatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
}>({
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -47,10 +54,8 @@ const AgeAssuranceStateContext = createContext<{
access: AgeAssuranceAccess.Full,
},
flags: {
isAgeRestricted: false,
adultContentDisabled: false,
chatDisabled: false,
groupChatDisabled: false,
isDeclaredUnderAdultAge: false,
isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false,
@@ -68,61 +73,65 @@ export function useAgeAssurance() {
export function Provider({children}: {children: React.ReactNode}) {
return (
<AgeAssuranceServerDataProvider>
<AgeAssuranceDataProvider>
<InnerProvider>
<RedirectOverlayProvider>{children}</RedirectOverlayProvider>
</InnerProvider>
</AgeAssuranceServerDataProvider>
</AgeAssuranceDataProvider>
)
}
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
const {metadata} = useAgeAssuranceServerDataContext()
const regionConfig = useAgeAssuranceRegionConfigWithFallback()
const {data} = useAgeAssuranceDataContext()
const config = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => {
const flags = computeAgeAssuranceFlags({
state: s,
regionConfig,
metadata,
})
if (flags.isAgeRestricted) {
void getAndRegisterPushToken({
isAgeRestricted: true,
})
}
if (flags.chatDisabled || flags.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
if (isAgeRestricted) {
void getAndRegisterPushToken({isAgeRestricted})
maybeRestrictChatSettings({agent})
}
},
[agent, getAndRegisterPushToken, regionConfig, metadata],
[agent, getAndRegisterPushToken],
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
useEffect(() => {
logger.debug(`useAgeAssuranceState`, {state})
}, [state])
return (
<AgeAssuranceStateContext.Provider
value={useMemo(() => {
const res = {
const chatDisabled = state.access !== AgeAssuranceAccess.Full
const isDeclaredUnderAdultAge = data?.birthdate
? isUnderAge(data.birthdate, 18)
: true
const isOverRegionMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, config.minAccessAge)
: false
const isOverAppMinAccessAge = data?.birthdate
? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state,
flags: computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}),
flags: {
adultContentDisabled,
chatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
},
}
logger.debug(`useAgeAssurance`, res)
return res
}, [state, metadata, regionConfig])}>
}, [state, data, config])}>
{children}
</AgeAssuranceStateContext.Provider>
)
+29 -51
View File
@@ -1,30 +1,24 @@
import {useEffect, useMemo, useState} from 'react'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
type AgeAssuranceData,
getConfigFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
useAgeAssuranceServerDataContext,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
type AgeAssuranceMetadata,
type AgeAssuranceState,
AgeAssuranceStatus,
parseAccessFromString,
parseStatusFromString,
} from '#/ageAssurance/types'
import {
computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -33,18 +27,18 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
function computeAgeAssuranceState({
export function computeAgeAssuranceState({
hasSession,
geolocation,
config,
geolocation,
state,
metadata,
data,
}: {
hasSession: boolean
config: AgeAssuranceData['config']
geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata
state: AgeAssuranceData['state']
data: AgeAssuranceData['data']
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -94,10 +88,7 @@ function computeAgeAssuranceState({
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
const result = computeAgeAssuranceRegionAccess(region, data)
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
@@ -109,10 +100,10 @@ function computeAgeAssuranceState({
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
logger.debug('computeAgeAssuranceState', {
logger.debug('debug useAgeAssuranceState', {
region,
state,
metadata,
data,
computed,
})
return computed
@@ -122,51 +113,38 @@ function computeAgeAssuranceState({
* This is a last-ditch helper for out-of-band reads of the AA state, such as
* during account creation. Don't use it for anything else.
*/
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function getAndComputeAgeAssuranceState({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did})
const data = getOtherRequiredDataFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !requiredData) {
if (!geolocation || !config || !state || !data) {
return {
state: {
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
},
status: AgeAssuranceStatus.Unknown,
access: AgeAssuranceAccess.Safe,
}
}
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
const metadata: AgeAssuranceMetadata = {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: requiredData?.birthdate
? getAge(new Date(requiredData.birthdate))
: undefined,
birthdate: requiredData?.birthdate,
}
const computed = computeAgeAssuranceState({
return computeAgeAssuranceState({
hasSession: true,
config,
geolocation,
state: state.state,
metadata,
data: {
accountCreatedAt: state.metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
: undefined,
birthdate: data?.birthdate,
},
})
return {
state: computed,
flags: computeAgeAssuranceFlags({
state: computed,
regionConfig: region,
metadata,
}),
}
}
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
const {config, state, metadata} = useAgeAssuranceServerDataContext()
const {config, state, data} = useAgeAssuranceDataContext()
return useMemo(
() =>
@@ -175,9 +153,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
config,
geolocation,
state,
metadata,
data,
}),
[hasSession, geolocation, config, state, metadata],
[hasSession, geolocation, config, state, data],
)
}
-18
View File
@@ -1,5 +1,3 @@
import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
import {logger} from '#/ageAssurance/logger'
export enum AgeAssuranceAccess {
@@ -16,12 +14,6 @@ export enum AgeAssuranceStatus {
Blocked = 'blocked',
}
export type AgeAssuranceMetadata = Parameters<
typeof computeAgeAssuranceRegionAccess
>[1] & {
birthdate: string | undefined
}
export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
@@ -29,16 +21,6 @@ export type AgeAssuranceState = {
error?: 'config' // maybe other specific cases in the future
}
export type AgeAssuranceFlags = {
isAgeRestricted: boolean
adultContentDisabled: boolean
chatDisabled: boolean
groupChatDisabled: boolean
isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean
}
export function parseStatusFromString(raw: string) {
switch (raw) {
case 'unknown':
@@ -1,14 +1,14 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation} from '#/geolocation'
export function useComputeAgeAssuranceRegionAccess() {
const {config, metadata} = useAgeAssuranceServerDataContext()
const {config, data} = useAgeAssuranceDataContext()
return useCallback(
(geolocation: Geolocation) => {
if (!config) {
@@ -19,14 +19,11 @@ export function useComputeAgeAssuranceRegionAccess() {
config,
geolocation,
)
const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge,
})
const result = computeAgeAssuranceRegionAccess(region, data)
return result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full
},
[config, metadata],
[config, data],
)
}
+39 -42
View File
@@ -1,22 +1,41 @@
import {useMemo} from 'react'
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AtpAgent,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
import {
AgeAssuranceAccess,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
} from '#/ageAssurance/types'
getDidFromAgentSession,
getOtherRequiredDataFromCache,
useAgeAssuranceDataContext,
} from '#/ageAssurance/data'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
export const MIN_ACCESS_AGE = 13
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
countryCode: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
access: AgeAssuranceAccess.None,
},
],
}
/**
* Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found.
@@ -43,7 +62,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
const {config} = useAgeAssuranceServerDataContext()
const {config} = useAgeAssuranceDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
@@ -97,37 +116,15 @@ export const makeAgeRestrictedModerationPrefs = (
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
export function computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}: {
state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata
}): AgeAssuranceFlags {
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
const chatDisabled = isAgeRestricted
const isDeclaredUnderAdultAge = metadata?.declaredAge
? metadata.declaredAge < 18
: true
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
const isOverRegionMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= regionConfig.minAccessAge
: false
const isOverAppMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= MIN_ACCESS_AGE
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
isAgeRestricted,
adultContentDisabled,
chatDisabled,
groupChatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
}
/**
* Checks our cache of the actor's chat declaration record, and if it's not
* already restricted, restricts it.
*/
export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent)
if (!did) return
const data = getOtherRequiredDataFromCache({did})
// ...update the chat setting record if allowIncoming is not already 'none'.
if (data?.actorDeclaration?.allowIncoming === 'none') return
restrictChatSettings({agent, did})
}
-19
View File
@@ -1,19 +0,0 @@
import {useEffect, useState} from 'react'
import {Dimensions} from 'react-native'
/**
* Same as `useWindowDimensions().fontScale`, but avoids rerendering
* whenever the screen size changes
*/
export function useNativeFontScale() {
const [fontScale, setFontScale] = useState(Dimensions.get('window').fontScale)
useEffect(() => {
const sub = Dimensions.addEventListener('change', evt => {
setFontScale(evt.window.fontScale)
})
return () => sub.remove()
}, [])
return fontScale
}
+6 -25
View File
@@ -1175,37 +1175,18 @@ export type Events = {
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Post photo embed events
'post:photoEmbed:impression': {
layout: 'single' | 'grid' | 'carousel'
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:open': {
layout: 'single' | 'grid' | 'carousel'
fromImage: number
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:carouselSwipe': {
// Gallery carousel events
'post:gallery:swipe': {
fromImage: number
toImage: number
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
'post:photoEmbed:lightboxSwipe': {
layout: 'single' | 'grid' | 'carousel'
'post:gallery:openLightbox': {
fromImage: number
toImage: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
}
+3 -3
View File
@@ -36,7 +36,7 @@ export function AvatarBubbles({
moderationOpts,
}: {
animate?: boolean
profiles: (bsky.profile.AnyProfileView | undefined)[]
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).
@@ -50,12 +50,12 @@ export function AvatarBubbles({
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
return profiles.map(p => {
return p && moderateProfile(p, moderationOpts)
return moderateProfile(p, moderationOpts)
})
}, [profiles, moderationOpts])
+13 -8
View File
@@ -1,10 +1,12 @@
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function Error({
@@ -13,20 +15,22 @@ export function Error({
onRetry,
onGoBack,
hideBackButton,
sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
sideBorders?: boolean
}) {
const {t: l} = useLingui()
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
<Layout.Center
<CenteredView
style={[
a.h_full_vh,
a.align_center,
@@ -34,7 +38,8 @@ export function Error({
!gtMobile && a.justify_between,
t.atoms.border_contrast_low,
{paddingTop: 175, paddingBottom: 110},
]}>
]}
sideBorders={sideBorders}>
<View style={[a.w_full, a.align_center, a.gap_lg]}>
<Text style={[a.font_semi_bold, a.text_3xl]}>{title}</Text>
<Text
@@ -53,7 +58,7 @@ export function Error({
<Button
variant="solid"
color="primary"
label={l`Press to retry`}
label={_(msg`Press to retry`)}
onPress={onRetry}
size="large">
<ButtonText>
@@ -65,7 +70,7 @@ export function Error({
<Button
variant="solid"
color={onRetry ? 'secondary' : 'primary'}
label={l`Return to previous page`}
label={_(msg`Return to previous page`)}
onPress={goBack}
size="large">
<ButtonText>
@@ -74,6 +79,6 @@ export function Error({
</Button>
)}
</View>
</Layout.Center>
</CenteredView>
)
}
+5 -2
View File
@@ -10,7 +10,7 @@ import Animated, {
} from 'react-native-reanimated'
import {useLingui} from '@lingui/react/macro'
import {atoms as a} from '#/alf'
import {android, atoms as a, ios} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
@@ -25,6 +25,7 @@ type Props = {
type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6
const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e'
@@ -123,8 +124,9 @@ function MenuCard({
<Animated.View
style={[
a.absolute,
a.self_start,
styles.card,
android({alignSelf: 'flex-start'}),
ios({width: MENU_WIDTH}),
{
top: anchor.y + anchor.height + GAP,
left: anchor.x,
@@ -184,6 +186,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
itemText: {
flex: 1,
fontSize: 15,
fontWeight: '500',
lineHeight: 19.5,
+2 -18
View File
@@ -39,7 +39,6 @@ import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state'
import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer'
@@ -229,8 +228,7 @@ function ImageView({
openProgress: SharedValue<number>
thumbRects: SharedValue<Record<number, MeasuredDimensions | null>>
}) {
const {images, index: initialImageIndex, metricsContext} = lightbox
const ax = useAnalytics()
const {images, index: initialImageIndex} = lightbox
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -379,21 +377,7 @@ function ImageView({
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
const next = e.nativeEvent.position
setImageIndex(prev => {
if (metricsContext && prev !== next) {
ax.metric('post:photoEmbed:lightboxSwipe', {
layout: metricsContext.layout,
fromImage: prev + 1,
toImage: next + 1,
totalImages: images.length,
postUri: metricsContext.postUri,
postAuthorDid: metricsContext.postAuthorDid,
feedDescriptor: metricsContext.feedDescriptor,
})
}
return next
})
setImageIndex(e.nativeEvent.position)
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
-10
View File
@@ -11,20 +11,10 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types'
export type LightboxMetricsContext = {
layout: 'single' | 'grid' | 'carousel'
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
export type Lightbox = {
id: string
images: ImageSource[]
index: number
// Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe.
// Left unset for non-post contexts (e.g. profile avatar/banner lightbox).
metricsContext?: LightboxMetricsContext
}
const LightboxContext = createContext<{
+2
View File
@@ -185,6 +185,7 @@ let ListMaybePlaceholder = ({
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
@@ -225,6 +226,7 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
sideBorders={sideBorders}
/>
)
}
+2 -4
View File
@@ -127,12 +127,10 @@ export function ImageItem({
thumbnail,
alt,
children,
maxWidth = 100,
}: {
thumbnail?: string
alt?: string
children?: React.ReactNode
maxWidth?: number
}) {
const t = useTheme()
@@ -143,7 +141,7 @@ export function ImageItem({
{backgroundColor: 'black'},
a.flex_1,
a.aspect_square,
{maxWidth},
{maxWidth: 100},
a.rounded_xs,
]}
accessibilityLabel={alt}
@@ -154,7 +152,7 @@ export function ImageItem({
}
return (
<View style={[a.relative, a.aspect_square, {maxWidth}]}>
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth: 100}]}>
<Image
key={thumbnail}
source={{uri: thumbnail}}
+1 -1
View File
@@ -172,7 +172,7 @@ export function FollowsYou({size = 'sm'}: CommonProps) {
return (
<View style={[variantStyles, a.justify_center, t.atoms.bg_contrast_50]}>
<Text style={[a.text_xs, a.leading_tight]}>
<Trans>Follows you</Trans>
<Trans>Follows You</Trans>
</Text>
</View>
)
@@ -8,7 +8,7 @@ import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
/**
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
* a `bsky.app/chat/<code>` link posted to the feed) as a join request card,
* a `bsky.app/c/<code>` link posted to the feed) as a join request card,
* falling back to a plain external embed if the invite can't be resolved.
*/
export function ChatInviteEmbed({
@@ -23,7 +23,7 @@ export function ChatInviteEmbed({
style?: StyleProp<ViewStyle>
}) {
return (
<ChatInvite.Root code={code} hasFixedHeight>
<ChatInvite.Root code={code}>
<ChatInviteEmbedBody link={link} onOpen={onOpen} style={style} />
</ChatInvite.Root>
)
@@ -1,4 +1,4 @@
import {useMemo} from 'react'
import {useCallback, useMemo} from 'react'
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {type AppBskyEmbedExternal} from '@atproto/api'
@@ -51,19 +51,17 @@ export const ExternalEmbed = ({
}, [link.uri, externalEmbedPrefs])
const hasMedia = Boolean(imageUri || embedPlayerParams)
const onPress = () => {
const onPress = useCallback(() => {
playHaptic('Light')
onOpen?.()
}
}, [playHaptic, onOpen])
const onShareExternal = IS_NATIVE
? () => {
if (link.uri) {
playHaptic('Heavy')
void shareUrl(link.uri)
}
}
: undefined
const onShareExternal = useCallback(() => {
if (link.uri && IS_NATIVE) {
playHaptic('Heavy')
void shareUrl(link.uri)
}
}, [link.uri, playHaptic])
if (
embedPlayerParams?.source === 'tenor' ||
@@ -1,99 +0,0 @@
import {Linking, View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {BSKY_DOWNLOAD_URL} from '#/lib/constants'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Sparkle_Stroke2_Corner0_Rounded as Sparkle} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
/**
* OTA-able fallback that ships to native builds which don't yet know how to
* render the new gallery embed (>4 images, Photos v2). Final copy and visual
* treatment pending design from Darrin/Danielle/Alex.
*
* Native-only per APP-2308 - web builds receive the new gallery support in
* the same release that adds it.
*/
export function GalleryFallbackEmbed({count}: {count?: number}) {
const t = useTheme()
const {t: l} = useLingui()
const bodyStyle = [
a.text_sm,
a.text_center,
a.leading_snug,
t.atoms.text_contrast_high,
]
return (
<View
style={[
a.mt_sm,
a.rounded_md,
a.border,
a.p_lg,
a.pb_2xl,
a.gap_sm,
a.align_center,
{
borderColor: t.palette.primary_200,
backgroundColor: t.palette.primary_25,
},
]}>
<Sparkle size="lg" fill={t.palette.primary_500} />
<Text style={[a.text_md, a.font_bold, a.text_center, t.atoms.text]}>
<Trans>Something new is here</Trans>
</Text>
{count ? (
<View>
<Text style={bodyStyle}>
{plural(count, {
one: 'This post has # photo.',
other: 'This post has # photos.',
})}
</Text>
{IS_NATIVE ? (
<Text style={bodyStyle}>
{plural(count, {
one: 'Update your app to see it.',
other: 'Update your app to see them all.',
})}
</Text>
) : (
<Text style={bodyStyle}>
{plural(count, {
one: 'Refresh the page to see it.',
other: 'Refresh the page to see them all.',
})}
</Text>
)}
</View>
) : IS_NATIVE ? (
<Text style={bodyStyle}>
<Trans>Update your app to see it.</Trans>
</Text>
) : (
<Text style={bodyStyle}>
<Trans>Refresh the page to see it.</Trans>
</Text>
)}
{IS_NATIVE && (
<Button
label={l`Update your app`}
size="small"
color="primary"
onPress={() => {
void Linking.openURL(BSKY_DOWNLOAD_URL)
}}
style={[a.mt_xs]}>
<ButtonText>
<Trans>Update app</Trans>
</ButtonText>
</Button>
)}
</View>
)
}
+1 -28
View File
@@ -8,10 +8,7 @@ import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {
type LightboxMetricsContext,
useLightboxControls,
} from '#/components/Lightbox/state'
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'
@@ -43,20 +40,6 @@ export function ImageEmbed({
? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const layout: 'single' | 'grid' | 'carousel' =
images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid'
const postContext = rest.post
? {
postUri: rest.post.uri,
postAuthorDid: rest.post.author.did,
feedDescriptor: rest.feedDescriptor,
}
: undefined
const metricsContext: LightboxMetricsContext | undefined = postContext
? {layout, ...postContext}
: undefined
// 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)
@@ -74,14 +57,6 @@ export function ImageEmbed({
refs: AnimatedRef<any>[],
fetchedDims: (Dimensions | null)[],
) => {
if (postContext) {
ax.metric('post:photoEmbed:open', {
layout,
fromImage: index + 1,
totalImages: images.length,
...postContext,
})
}
openLightbox({
images: items.map((item, i) => ({
...item,
@@ -92,7 +67,6 @@ export function ImageEmbed({
type: 'image',
})),
index,
metricsContext,
})
}
const onPressIn = (_: number) => {
@@ -158,7 +132,6 @@ export function ImageEmbed({
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
metricsPostContext={postContext}
/>
</View>
)
+5 -13
View File
@@ -1,11 +1,7 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {ChatBskyGroupDefs} from '@atproto/api'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {
type ChatInvitePreview,
isKnownJoinLinkPreview,
} from '#/state/queries/join-links'
import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
@@ -27,19 +23,15 @@ export function JoinRequestEmbed({
onOpen,
}: {
code?: string
preview?: ChatInvitePreview
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
style?: StyleProp<ViewStyle>
onOpen?: () => void
}) {
const resolvedCode =
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
const resolvedCode = code ?? preview?.code
if (!resolvedCode) return null
return (
<ChatInvite.Root
code={resolvedCode}
initialPreview={preview}
hasFixedHeight>
<ChatInvite.Root code={resolvedCode} initialPreview={preview}>
<JoinRequestEmbedBody style={style} onOpen={onOpen} />
</ChatInvite.Root>
)
@@ -78,7 +70,7 @@ export function JoinRequestEmbedBody({
)
}
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
if (!preview) {
return (
<View
style={[
@@ -1,10 +1,13 @@
import {Fragment, type ReactNode} from 'react'
import {View} from 'react-native'
import {AtUri} from '@atproto/api'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {toNiceDomain} from '#/lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {InlineLinkText} from '#/components/Link'
import {
matchStandardSitePublisher,
matchStandardSitePublisherByUri,
@@ -15,15 +18,19 @@ import {
isStandardSitePublicationUri,
} from '#/components/Post/Embed/StandardSiteEmbed/utils'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
export function StandardSiteMetaRow({
type = 'document',
preview,
view,
}: ssTypes.CommonProps &
ssTypes.PreviewProps & {
type?: 'document' | 'publication'
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const highlightedPublisher = !!matchStandardSitePublisher(view)
const didsFromRecords =
view.associatedRefs
@@ -40,11 +47,7 @@ export function StandardSiteMetaRow({
: undefined
const articleDomain = toNiceDomain(view.uri)
const articlePublisher = matchStandardSitePublisherByUri(view.uri)
const domainHandleMatch =
authorProfile?.handle &&
(articleDomain === authorProfile.handle ||
articleDomain.endsWith(`.${authorProfile.handle}`))
const DomainIcon = articlePublisher?.Icon
const DomainIcon = articlePublisher?.Icon || StandardSite
const metaTextStyle = [
a.text_xs,
a.leading_tight,
@@ -53,7 +56,7 @@ export function StandardSiteMetaRow({
const items: {key: string; node: ReactNode}[] = []
if (!highlightedPublisher && !domainHandleMatch) {
if (!highlightedPublisher) {
items.push({
key: 'domain',
node: (
@@ -74,7 +77,25 @@ export function StandardSiteMetaRow({
key: 'author',
node: (
<Text numberOfLines={1} style={[metaTextStyle]}>
<Trans>by @{authorProfile.handle}</Trans>
<Trans>
by{' '}
<InlineLinkText
label={l`View @${authorProfile.handle}'s profile`}
to={makeProfileLink(authorProfile)}
style={[
metaTextStyle,
preview ? a.pointer_events_none : a.pointer_events_auto,
]}
onPress={e => {
e.stopPropagation()
e.preventDefault()
ax.metric('embed:standardSite:authorHandle:press', {
handle: authorProfile.handle,
})
}}>
@{authorProfile.handle}
</InlineLinkText>
</Trans>
</Text>
),
})
@@ -15,7 +15,6 @@ import {Divider} from '#/components/Divider'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {ArrowTopRight_Stroke2_Corner0_Rounded as ArrowTopRightIcon} from '#/components/icons/Arrow'
import {Clock_Stroke2_Corner0_Rounded as Clock} from '#/components/icons/Clock'
import {StandardSite} from '#/components/icons/community/StandardSite'
import {Link} from '#/components/Link'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {matchStandardSitePublisher} from '#/components/Post/Embed/StandardSiteEmbed/publishers'
@@ -80,15 +79,13 @@ export const StandardSiteEmbed = ({
onEmbedInteractionCallback?.()
ax.metric('embed:standardSite:article:press', {url: view.uri})
}
const onLongPress = IS_NATIVE
? () => {
if (view.uri) {
playHaptic('Heavy')
void shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
: undefined
const onLongPress = () => {
if (view.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.uri)
ax.metric('embed:standardSite:article:longPress', {url: view.uri})
}
}
const onPressPublication = () => {
playHaptic('Light')
onEmbedInteractionCallback?.()
@@ -96,17 +93,15 @@ export const StandardSiteEmbed = ({
url: view.source?.uri || '',
})
}
const onLongPressPublication = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
: undefined
const onLongPressPublication = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
ax.metric('embed:standardSite:publication:longPress', {
url: view.source.uri,
})
}
}
if (isStandardPublication) {
return (
@@ -470,23 +465,21 @@ export function SubscribeButton({
}
}
const onLongPress = IS_NATIVE
? () => {
if (view.source?.uri) {
playHaptic('Heavy')
void shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
}
const onLongPress = () => {
if (view.source?.uri && IS_NATIVE) {
playHaptic('Heavy')
shareUrl(view.source.uri)
if (highlightedPublisher) {
ax.metric('embed:standardSite:subscribe:longPress', {
url: view.source?.uri || '',
})
} else {
ax.metric('embed:standardSite:publicationCta:longPress', {
url: view.source?.uri || '',
})
}
: undefined
}
}
const button = (
<Link
@@ -536,9 +529,8 @@ function PublicationIcon({
interacted?: boolean
themeColors: ssTypes.ThemeColors
}) {
const t = useTheme()
if (!view.source) return null
const icon = view.source?.icon ? (
return view.source?.icon ? (
<View>
<UserAvatar
noBorder
@@ -569,29 +561,6 @@ function PublicationIcon({
<MediaInsetBorder opaque style={[a.rounded_sm]} />
</View>
)
return (
<View style={[a.relative]}>
<View
style={[
a.absolute,
a.rounded_full,
a.z_10,
a.justify_center,
a.align_center,
t.atoms.bg,
{
width: 16,
height: 16,
top: -6,
left: -6,
},
]}>
<StandardSite size="xs" fill={t.atoms.text_contrast_medium.color} />
<MediaInsetBorder />
</View>
{icon}
</View>
)
}
export function PublicationFooter({
@@ -108,15 +108,10 @@ export function useActiveVideoWeb() {
return {
active: activeViewId === id,
setActive: useCallback(() => {
setActive: () => {
setActiveView(id)
}, [setActiveView, id]),
},
currentActiveView: activeViewId,
sendPosition: useCallback(
(y: number) => {
sendViewPosition(id, y)
},
[sendViewPosition, id],
),
sendPosition: (y: number) => sendViewPosition(id, y),
}
}
-3
View File
@@ -345,9 +345,6 @@ export function QuoteEmbed({
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
// The photo embed belongs to the quoted post, so attribute its
// analytics to the quoted post rather than the parent.
post={quote}
/>
)}
</>
-7
View File
@@ -15,13 +15,6 @@ export type CommonProps = {
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
allowNestedQuotes?: boolean
/**
* The post that contains this embed. Used for analytics on photo embed
* events (post:photoEmbed:*). When the embed has no owning post (e.g.
* composer previews), leave this undefined and no events will be emitted.
*/
post?: AppBskyFeedDefs.PostView
feedDescriptor?: string
}
export type EmbedProps = CommonProps & {
@@ -88,7 +88,6 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import {
ReportDialog,
useReportDialogControl,
@@ -846,10 +845,13 @@ let PostMenuItems = ({
onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={l`Yes, hide`}
/>
<BlockDialog
<Prompt.Basic
control={blockPromptControl}
profile={postAuthor}
onBlock={onBlockAuthor}
title={l`Block Account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
</>
)
+6 -11
View File
@@ -1,8 +1,7 @@
import {View} from 'react-native'
import {useWindowDimensions, View} from 'react-native'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {atoms as a, useAlf, type ViewStyleProp} from '#/alf'
import {useNativeFontScale} from '#/alf/util/dimensions'
import {BotBadge, BotBadgeButton, isBotAccount} from '#/components/BotBadge'
import {useSimpleVerificationState} from '#/components/verification'
import {VerificationCheck} from '#/components/verification/VerificationCheck'
@@ -32,16 +31,14 @@ export function ProfileBadges({
interactive = false,
size,
style,
allowFontScaling = true,
}: ViewStyleProp & {
profile: bsky.profile.AnyProfileView
interactive?: boolean
size: Size
allowFontScaling?: boolean
}) {
const shadowed = useProfileShadow(profile)
const verification = useSimpleVerificationState({profile})
const nativeScaleMultiplier = useNativeFontScale()
const {fontScale: nativeScaleMultiplier} = useWindowDimensions()
const {
fonts: {scaleMultiplier: alfScaleMultiplier},
} = useAlf()
@@ -51,12 +48,10 @@ export function ProfileBadges({
const isOnTheSmallSide = size === 'xs' || size === 'sm'
const scaleMultiplier = allowFontScaling
? nativeScaleMultiplier * alfScaleMultiplier
: 1
const verificationIconWidth = verificationIconSizes[size] * scaleMultiplier
const botIconWidth = botIconSizes[size] * scaleMultiplier
const verificationIconWidth =
verificationIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
const botIconWidth =
botIconSizes[size] * nativeScaleMultiplier * alfScaleMultiplier
return (
<View
+1 -3
View File
@@ -23,7 +23,6 @@ export function Text({
title,
dataSet,
numberOfLines,
allowFontScaling = true,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
@@ -37,7 +36,7 @@ export function Text({
style,
],
{
fontScale: allowFontScaling ? fonts.scaleMultiplier : 1,
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags,
},
@@ -58,7 +57,6 @@ export function Text({
numberOfLines,
style: s,
dataSet: Object.assign({tooltip: title}, dataSet || {}),
allowFontScaling,
...rest,
}
@@ -667,6 +667,7 @@ function SearchInput({
/>
<TextInput
// @ts-ignore bottom sheet input types issue — esb
ref={inputRef}
placeholder={l`Search`}
value={value}
+1 -14
View File
@@ -1,10 +1,8 @@
import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session'
@@ -74,18 +72,7 @@ export function ActionsWrapper({
.removeReaction(message.id, emoji)
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) {
Toast.show(
l`You cannot add more than ${plural(EMOJI_REACTION_LIMIT, {
one: '# emoji reaction',
other: '# emoji reactions',
})}`,
{
type: 'info',
},
)
return
}
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
+12 -30
View File
@@ -66,7 +66,6 @@ type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem
export type State = {
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
searchText: string
}
export type Action =
@@ -80,10 +79,6 @@ export type Action =
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
}
| {
type: 'setSearchText'
searchText: string
}
function reducer(state: State, action: Action): State {
switch (action.type) {
@@ -92,7 +87,6 @@ function reducer(state: State, action: Action): State {
...state,
groupChatDids: action.groupChatDids,
groupChatProfiles: action.groupChatProfiles,
searchText: '',
}
}
case 'removeDids': {
@@ -102,12 +96,6 @@ function reducer(state: State, action: Action): State {
groupChatProfiles: action.groupChatProfiles,
}
}
case 'setSearchText': {
return {
...state,
searchText: action.searchText,
}
}
}
}
@@ -132,19 +120,11 @@ export function AddMembersFlow({
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
const [searchText, setSearchText] = useState('')
const listRef = useRef<ListMethods>(null)
const inputRef = useRef<TextInput>(null)
const [{groupChatDids, groupChatProfiles, searchText}, dispatch] = useReducer(
reducer,
{
groupChatDids: [],
groupChatProfiles: [],
searchText: '',
},
)
const {
data: autocompleteResults,
isError,
@@ -161,12 +141,10 @@ export function AddMembersFlow({
[memberListData],
)
// The existing members (including the viewer) already occupy slots, so the
// number of people that can still be added is whatever's left.
const remainingSlots = Math.max(
0,
convo.details.memberLimit - memberListData.length,
)
const [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, {
groupChatDids: [],
groupChatProfiles: [],
})
const onRemoveDid = useCallback(
(did: string) => {
@@ -221,7 +199,6 @@ export function AddMembersFlow({
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
if (!canBeAddedToGroup(profile)) continue
_items.push({
type: 'profile',
key: profile.did,
@@ -229,6 +206,12 @@ export function AddMembersFlow({
})
}
}
_items.sort(item => {
return item.type === 'profile' && canBeAddedToGroup(item.profile)
? -1
: 1
})
} else {
for (let i = 0; i < 10; i++) {
_items.push({type: 'placeholder', key: i + ''})
@@ -417,7 +400,7 @@ export function AddMembersFlow({
inputRef={inputRef}
value={searchText}
onChangeText={text => {
dispatch({type: 'setSearchText', searchText: text})
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
@@ -488,7 +471,6 @@ export function AddMembersFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={remainingSlots}
label={l`Add group chat members`}
style={web([a.contents])}>
<Dialog.InnerFlatList
+13 -27
View File
@@ -1,13 +1,10 @@
import {View} from 'react-native'
import {ChatBskyGroupDefs} from '@atproto/api'
import {Plural, Trans} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {InlineLinkText} from '#/components/Link'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useChatInvite} from './Context'
@@ -19,9 +16,9 @@ import {useChatInvite} from './Context'
*/
export function Card({size}: {size: 'large' | 'small'}) {
const t = useTheme()
const {preview, hasFixedHeight} = useChatInvite()
const {preview} = useChatInvite()
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) return null
if (!preview) return null
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
@@ -34,15 +31,14 @@ export function Card({size}: {size: 'large' | 'small'}) {
<Text
emoji
style={[size === 'large' ? a.text_lg : a.text_md, a.font_bold]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
numberOfLines={1}>
{preview.name}
</Text>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Text
style={[a.text_2xs, a.font_medium, t.atoms.text_contrast_high]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
allowFontScaling
numberOfLines={1}>
<Trans>Group chat</Trans>
</Text>
<Text
@@ -52,8 +48,8 @@ export function Card({size}: {size: 'large' | 'small'}) {
a.font_medium,
t.atoms.text_contrast_high,
]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
allowFontScaling
numberOfLines={1}>
<Trans comment="The number of members in a group chat, in the format '{members}/{total} members'.">
{preview.memberCount}/{preview.memberLimit}{' '}
<Plural
@@ -74,27 +70,17 @@ export function Card({size}: {size: 'large' | 'small'}) {
<Text
emoji
style={[a.flex_shrink, a.text_sm, a.font_medium]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
allowFontScaling
numberOfLines={1}>
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
By{' '}
<InlineLinkText
to={makeProfileLink(preview.owner)}
label={ownerDisplayName}
style={[a.font_medium, t.atoms.text]}>
{ownerDisplayName}
</InlineLinkText>
By <Text style={[a.font_medium]}>{ownerDisplayName}</Text>
</Trans>
</Text>
<ProfileBadges
profile={preview.owner}
size="sm"
allowFontScaling={!hasFixedHeight}
/>
<ProfileBadges profile={preview.owner} size="sm" />
<Text
style={[a.flex_shrink, t.atoms.text_contrast_medium]}
numberOfLines={1}
allowFontScaling={!hasFixedHeight}>
allowFontScaling
numberOfLines={1}>
{ownerHandle}
</Text>
</View>
+2 -4
View File
@@ -1,6 +1,6 @@
import {createContext, useContext} from 'react'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type ButtonColor} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -26,14 +26,12 @@ export type ChatInviteContextValue = {
code: string
loading: boolean
error: boolean
preview: ChatInvitePreview | undefined
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
/**
* The derived action descriptor. Undefined while loading or when there's no
* preview to act on.
*/
action: ChatInviteAction | undefined
/** Whether the invite is rendered inside a fixed-height container; when true, text inside disables font scaling so the card doesn't overflow. */
hasFixedHeight: boolean
}
const ChatInviteContext = createContext<ChatInviteContextValue | null>(null)
+2 -2
View File
@@ -17,7 +17,7 @@ export function JoinButton({
onPress?: () => void
style?: StyleProp<ViewStyle>
}) {
const {action, hasFixedHeight} = useChatInvite()
const {action} = useChatInvite()
if (!action) return null
@@ -35,7 +35,7 @@ export function JoinButton({
disabled={action.disabled}
style={[a.w_full, style]}>
{action.side === 'left' && <ButtonIcon icon={action.icon} />}
<ButtonText allowFontScaling={!hasFixedHeight}>{action.label}</ButtonText>
<ButtonText>{action.label}</ButtonText>
{action.side === 'right' && <ButtonIcon icon={action.icon} />}
</Button>
)
+14 -13
View File
@@ -1,21 +1,19 @@
import {setStringAsync} from 'expo-clipboard'
import {ChatBskyGroupDefs} from '@atproto/api'
import {type ChatBskyGroupDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {
type ChatInvitePreview,
useJoinLinkPreviewsQuery,
} from '#/state/queries/join-links'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links'
import {useSession} from '#/state/session'
import {type ButtonColor} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
import * as Toast from '#/components/Toast'
import {type ChatInviteAction, ChatInviteProvider} from './Context'
@@ -32,18 +30,16 @@ export function Root({
code,
initialPreview,
currentConvoId,
hasFixedHeight,
children,
}: {
code: string
initialPreview?: ChatInvitePreview
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView
/**
* The convo this invite is being viewed within, if any. When the invite
* links to the same chat, the action becomes "Copy link" instead of
* open/join (you're already here).
*/
currentConvoId?: string
hasFixedHeight: boolean
children: React.ReactNode
}) {
const {hasSession} = useSession()
@@ -64,7 +60,7 @@ export function Root({
const loading = isPending && !preview
let action: ChatInviteAction | undefined
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
if (preview) {
const convoId = preview.convo?.id
const isFollowing = preview.owner.viewer?.following ?? false
const hasRequested = !convoId && preview.viewer?.requestedAt != null
@@ -80,7 +76,7 @@ export function Root({
color: 'primary',
disabled: false,
onPress: () => {
void setStringAsync(`https://bsky.app/chat/${preview.code}`)
void setStringAsync(`https://bsky.app/c/${preview.code}`)
Toast.show(l`Copied to clipboard`, {type: 'success'})
},
}
@@ -101,7 +97,12 @@ export function Root({
let icon: React.ComponentType<SVGIconProps> = JoinIcon
let label = preview.requireApproval ? l`Request to join` : l`Join`
let color: ButtonColor = 'primary'
if (preview.memberCount >= preview.memberLimit) {
if (preview.enabledStatus !== 'enabled') {
canJoin = false
icon = WarningIcon
label = l`Chat invite link no longer available`
color = 'secondary'
} else if (preview.memberCount >= preview.memberLimit) {
canJoin = false
icon = HandIcon
label = l`This chat is full`
@@ -136,7 +137,7 @@ export function Root({
return (
<ChatInviteProvider
value={{code, loading, error: !!error, preview, action, hasFixedHeight}}>
value={{code, loading, error: !!error, preview, action}}>
{children}
</ChatInviteProvider>
)
+65 -57
View File
@@ -1,6 +1,6 @@
import {memo, useCallback} from 'react'
import {Keyboard, View} from 'react-native'
import {type ModerationCause} from '@atproto/api'
import {ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -16,18 +16,13 @@ import {
unstableCacheProfileView,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {type ViewStyleProp} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {
type ConvoWithDetails,
getConvoReportSubject,
} from '#/components/dms/util'
import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
@@ -44,6 +39,7 @@ import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
import {AfterReportConversationDialog} from './AfterReportConversationDialog'
let ConvoMenu = ({
convo,
@@ -53,9 +49,10 @@ let ConvoMenu = ({
showMarkAsRead,
hideTrigger,
blockInfo,
latestReportableMessage,
style,
}: {
convo: ConvoWithDetails
convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<bsky.profile.AnyProfileView>
control?: Menu.MenuControlProps
currentScreen: 'list' | 'conversation'
@@ -65,21 +62,20 @@ let ConvoMenu = ({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
latestReportableMessage?: ChatBskyConvoDefs.MessageView
style?: ViewStyleProp['style']
}): React.ReactNode => {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const leaveConvoControl = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl()
const afterReportControl = Prompt.usePromptControl()
const blockOrDeleteControl = Prompt.usePromptControl()
const deleteControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo
const reportSubject = getConvoReportSubject(convo, currentAccount?.did)
return (
<>
<Menu.Root control={control}>
@@ -112,7 +108,6 @@ let ConvoMenu = ({
showMarkAsRead={showMarkAsRead}
blockInfo={blockInfo}
convo={convo}
canReport={!!reportSubject}
leaveConvoControl={leaveConvoControl}
reportControl={reportControl}
blockedByListControl={blockedByListControl}
@@ -121,37 +116,54 @@ let ConvoMenu = ({
</Menu.Root>
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.view.id}
convoId={convo.id}
currentScreen={currentScreen}
/>
{reportSubject && (
<ReportDialog
subject={reportSubject}
control={reportControl}
onAfterSubmit={() => {
unstableCacheProfileView(queryClient, profile)
afterReportControl.open()
}}
/>
)}
{convo.kind === 'group' ? (
<AfterReportConversationDialog
control={afterReportControl}
currentScreen={currentScreen}
params={{
convoId: convo.view.id,
did: profile.did,
}}
/>
{latestReportableMessage ? (
<>
<ReportDialog
subject={{
view: 'convo',
convoId: convo.id,
message: latestReportableMessage,
}}
control={reportControl}
onAfterSubmit={() => {
const sender = convo.members.find(
member => member.did === latestReportableMessage.sender.did,
)
if (sender) {
unstableCacheProfileView(queryClient, sender)
}
blockOrDeleteControl.open()
}}
/>
<AfterReportDialog
control={blockOrDeleteControl}
currentScreen={currentScreen}
params={{
convoId: convo.id,
did: latestReportableMessage.sender.did,
}}
/>
</>
) : (
<AfterReportDialog
control={afterReportControl}
currentScreen={currentScreen}
params={{
convoId: convo.view.id,
did: profile.did,
}}
/>
<>
<ReportConversationDialog
control={reportControl}
convoId={convo.id}
did={profile.did}
onAfterSubmit={deleteControl.open}
/>
<AfterReportConversationDialog
control={deleteControl}
currentScreen={currentScreen}
params={{
convoId: convo.id,
did: profile.did,
}}
/>
</>
)}
<BlockedByListDialog
control={blockedByListControl}
@@ -165,16 +177,14 @@ ConvoMenu = memo(ConvoMenu)
function MenuContent({
convo: initialConvo,
profile,
canReport,
showMarkAsRead,
blockInfo,
leaveConvoControl,
reportControl,
blockedByListControl,
}: {
convo: ConvoWithDetails
convo: ChatBskyConvoDefs.ConvoView
profile: Shadow<bsky.profile.AnyProfileView>
canReport: boolean
showMarkAsRead?: boolean
blockInfo: {
listBlocks: ModerationCause[]
@@ -191,9 +201,9 @@ function MenuContent({
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const isDeletedAccount = profile.handle === 'missing.invalid'
const isGroupConvo = initialConvo.kind === 'group'
const isGroupConvo = ChatBskyConvoDefs.isGroupConvo(initialConvo.kind)
const convoId = initialConvo.view.id
const convoId = initialConvo.id
const {data: convo} = useConvoQuery({convoId})
const onNavigateToProfile = useCallback(() => {
@@ -289,17 +299,15 @@ function MenuContent({
</Menu.ItemText>
</Menu.Item>
)}
{canReport && (
<Menu.Item
destructive
label={l`Report conversation`}
onPress={reportControl.open}>
<Menu.ItemIcon icon={Flag} />
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
</Menu.Item>
)}
<Menu.Item
destructive
label={l`Report conversation`}
onPress={reportControl.open}>
<Menu.ItemIcon icon={Flag} />
<Menu.ItemText>
<Trans>Report conversation</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
+25 -79
View File
@@ -8,12 +8,10 @@ import {
} from 'react'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
@@ -36,7 +34,6 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs'
@@ -99,7 +96,6 @@ export type State = {
groupChatDids: string[]
groupChatProfiles: bsky.profile.AnyProfileView[]
groupName: string
searchText: string
}
export type Action =
@@ -133,10 +129,6 @@ export type Action =
type: 'goBackFromGroupName'
screenTitle: string
}
| {
type: 'setSearchText'
searchText: string
}
function reducer(state: State, action: Action): State {
switch (action.type) {
@@ -155,7 +147,6 @@ function reducer(state: State, action: Action): State {
...state,
groupChatDids: action.groupChatDids,
groupChatProfiles: action.groupChatProfiles,
searchText: '',
}
}
case 'removeDids': {
@@ -170,7 +161,6 @@ function reducer(state: State, action: Action): State {
...state,
chatState: ChatState.GROUP_NAME,
screenTitle: action.screenTitle,
searchText: '',
}
}
case 'nameGroup': {
@@ -187,7 +177,6 @@ function reducer(state: State, action: Action): State {
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
searchText: '',
}
}
case 'goBackFromGroupName': {
@@ -198,12 +187,6 @@ function reducer(state: State, action: Action): State {
groupName: '',
}
}
case 'setSearchText': {
return {
...state,
searchText: action.searchText,
}
}
}
}
@@ -224,32 +207,13 @@ export function InitiateChatFlow({
const [footerHeight, setFooterHeight] = useState(0)
const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession()
const aa = useAgeAssurance()
const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl()
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
const [
{
chatState,
screenTitle,
groupChatDids,
groupChatProfiles,
groupName,
searchText,
},
dispatch,
] = useReducer(reducer, {
chatState: ChatState.NEW_CHAT,
screenTitle: title,
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
searchText: '',
})
const [searchText, setSearchText] = useState('')
const {
data: results,
@@ -258,6 +222,17 @@ export function InitiateChatFlow({
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
const [
{chatState, screenTitle, groupChatDids, groupChatProfiles, groupName},
dispatch,
] = useReducer(reducer, {
chatState: ChatState.NEW_CHAT,
screenTitle: title,
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
})
const newGroupChatTitle = l`New group chat`
const groupNameTitle = l`Group name`
@@ -323,7 +298,6 @@ export function InitiateChatFlow({
if (follows) {
for (const page of follows.pages) {
for (const profile of page.follows) {
if (!checker(profile)) continue
_items.push({
type: 'profile',
key: profile.did,
@@ -331,6 +305,10 @@ export function InitiateChatFlow({
})
}
}
_items = _items.sort(item => {
return item.type === 'profile' && checker(item.profile) ? -1 : 1
})
} else {
_items.push(...placeholders)
}
@@ -349,11 +327,7 @@ export function InitiateChatFlow({
})
}
if (
chatState === ChatState.NEW_CHAT &&
searchText === '' &&
!aa.flags.groupChatDisabled
) {
if (chatState === ChatState.NEW_CHAT && searchText === '') {
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
}
@@ -367,7 +341,6 @@ export function InitiateChatFlow({
results,
currentAccount?.did,
follows,
aa.flags.groupChatDisabled,
])
if (searchText && !isFetching && !items.length && !isError) {
@@ -382,6 +355,7 @@ export function InitiateChatFlow({
case ChatState.NEW_GROUP_CHAT:
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
dispatch({type: 'goBackFromNewGroupChat', screenTitle: title})
setSearchText('')
break
case ChatState.GROUP_NAME:
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
@@ -402,6 +376,7 @@ export function InitiateChatFlow({
const handlePressNext = useCallback(() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
dispatch({type: 'startNameGroup', screenTitle: groupNameTitle})
setSearchText('')
}, [groupNameTitle])
const handlePressConfirm = useCallback(() => {
@@ -483,11 +458,6 @@ export function InitiateChatFlow({
}
}, [])
const groupNameTooLong = isOverMaxGraphemeCount({
text: groupName,
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
})
let buttonLabel = l`Continue to group name`
let buttonText = l`Next`
let handleButtonPress = handlePressNext
@@ -500,7 +470,7 @@ export function InitiateChatFlow({
buttonText = l`Create`
handleButtonPress = handlePressConfirm
showButton = true
isButtonDisabled = groupName === '' || groupNameTooLong
isButtonDisabled = groupName === ''
break
}
@@ -532,6 +502,7 @@ export function InitiateChatFlow({
a.relative,
a.align_center,
a.justify_between,
web(a.pb_lg),
]}>
{IS_NATIVE ? (
<Button
@@ -567,7 +538,7 @@ export function InitiateChatFlow({
color="secondary"
style={[a.absolute, a.z_20, {right: -4}]}
onPress={() => control.close()}>
<ButtonIcon icon={XIcon} size="md" />
<ButtonIcon icon={XIcon} size="lg" />
</Button>
) : showButton ? (
<Button
@@ -593,7 +564,7 @@ export function InitiateChatFlow({
{chatState === ChatState.GROUP_NAME ? (
<View
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
<TextField.Root isInvalid={groupNameTooLong}>
<TextField.Root>
<TextField.Input
label={l`Group name`}
value={groupName}
@@ -602,7 +573,6 @@ export function InitiateChatFlow({
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="text"
clearButtonMode="while-editing"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
@@ -612,30 +582,13 @@ export function InitiateChatFlow({
}
/>
</TextField.Root>
{groupNameTooLong ? (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_semi_bold,
{color: t.palette.negative_400},
]}>
<Trans>
Group name is too long.{' '}
<Plural
value={MAX_GROUP_NAME_GRAPHEME_LENGTH}
other="The maximum number of characters is #."
/>
</Trans>
</Text>
) : null}
</View>
) : (
<UserSearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
dispatch({type: 'setSearchText', searchText: text})
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
@@ -669,8 +622,6 @@ export function InitiateChatFlow({
handleButtonPress,
buttonText,
groupName,
groupNameTooLong,
t.palette.negative_400,
searchText,
control,
showChatProfileTabs,
@@ -713,11 +664,6 @@ export function InitiateChatFlow({
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={
// groupMemberLimit counts the creator, who is added implicitly, so
// reserve one slot for them
groupMemberLimit ? groupMemberLimit - 1 : undefined
}
label={
chatState === ChatState.NEW_GROUP_CHAT
? l`Select group chat members`
+5 -15
View File
@@ -1,9 +1,7 @@
import {ChatBskyConvoLeaveConvo} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types'
import {isNetworkError} from '#/lib/strings/errors'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {type DialogOuterProps} from '#/components/Dialog'
import * as Prompt from '#/components/Prompt'
@@ -32,18 +30,10 @@ export function LeaveConvoPrompt({
)
}
},
onError: error => {
let errorMessage = l`Could not leave chat`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (error instanceof ChatBskyConvoLeaveConvo.InvalidConvoError) {
errorMessage = l`Conversation not found.`
} else if (
error instanceof ChatBskyConvoLeaveConvo.OwnerCannotLeaveError
) {
errorMessage = l`Owner must lock the group before leaving.`
}
Toast.show(errorMessage, {type: 'error'})
onError: () => {
Toast.show(l`Could not leave chat`, {
type: 'error',
})
},
})
@@ -53,7 +43,7 @@ export function LeaveConvoPrompt({
title={l`Leave conversation`}
description={
hasMessages
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participants.`
? l`Are you sure you want to leave this conversation? Your messages will be deleted for you, but not for the other participant.`
: l`Are you sure you want to leave this conversation?`
}
confirmButtonCta={l`Leave`}
+1 -14
View File
@@ -6,10 +6,8 @@ import {
type ModerationOpts,
RichText,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
@@ -100,18 +98,7 @@ export let MessageContextMenu = ({
.removeReaction(message.id, emoji)
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else {
if (hasReachedReactionLimit(message, currentAccount?.did)) {
Toast.show(
l`You cannot add more than ${plural(EMOJI_REACTION_LIMIT, {
one: '# emoji reaction',
other: '# emoji reactions',
})}`,
{
type: 'info',
},
)
return
}
if (hasReachedReactionLimit(message, currentAccount?.did)) return
convo.addReaction(message.id, emoji).catch(() =>
Toast.show(l`Failed to add emoji reaction`, {
type: 'error',
+20 -12
View File
@@ -21,7 +21,6 @@ import {
type ChatBskyActorDefs,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
moderateProfile,
RichText as RichTextAPI,
} from '@atproto/api'
import {plural} from '@lingui/core/macro'
@@ -30,6 +29,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow} from '#/state/cache/types'
@@ -38,13 +38,12 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {InlineLinkText} from '#/components/Link'
import {InlineLinkText, Link} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
@@ -59,7 +58,7 @@ const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 20
const DISPLAY_NAME_INSET = 22
function isWithinClusterBoundary({
isPending,
@@ -228,13 +227,22 @@ let MessageItem = ({
const avatar =
profile && moderationOpts ? (
<PreviewableUserAvatar
profile={profile}
size={AVATAR_SIZE}
type={profile.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={() => unstableCacheProfileView(queryClient, profile)}
moderation={moderateProfile(profile, moderationOpts).ui('avatar')}
/>
<Link
style={[a.rounded_full]}
label={l`${createSanitizedDisplayName(profile)}s avatar`}
accessibilityHint={l`Opens this profile`}
to={makeProfileLink({
did: profile.did,
handle: profile.handle,
})}
onPress={() => unstableCacheProfileView(queryClient, profile)}>
<ProfileCard.Avatar
profile={profile}
size={AVATAR_SIZE}
moderationOpts={moderationOpts}
disabledPreview
/>
</Link>
) : (
<ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
)
@@ -633,7 +641,7 @@ function BlockedPlaceholder({
<Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" />
{profile.viewer?.blocking && !profile.viewer.blockingByList && (
<Prompt.Action
onPress={() => void queueUnblock()}
onPress={() => queueUnblock()}
cta={l`Unblock`}
color="secondary"
/>
@@ -3,7 +3,6 @@ import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo'
import {isKnownJoinLinkPreview} from '#/state/queries/join-links'
import {atoms as a, native, useTheme, web} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite'
import {MessageContextProvider} from './MessageContext'
@@ -28,11 +27,6 @@ let MessageItemInviteEmbed = ({
const screen = useWindowDimensions()
const convo = useConvoActive()
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
? embed.joinLinkPreview.code
: undefined
if (!code) return null
return (
<MessageContextProvider>
<View
@@ -78,10 +72,9 @@ let MessageItemInviteEmbed = ({
},
]}>
<ChatInvite.Root
code={code}
code={embed.joinLinkPreview.code}
initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id}
hasFixedHeight={false}>
currentConvoId={convo.convo.view.id}>
<ChatInvite.Card size="small" />
<ChatInvite.JoinButton />
</ChatInvite.Root>
+2 -18
View File
@@ -125,22 +125,6 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
[openDeleteMessage, openReportMessage, openReactions],
)
// `reactionsTarget` is a snapshot from when the dialog was opened. Read the
// live message out of the convo items so optimistic reaction changes (e.g.
// "Tap to remove") are reflected in the dialog without closing it first.
const reactionsMessage = useMemo(() => {
if (!reactionsTarget) return null
for (const item of convo.items) {
if (
(item.type === 'message' || item.type === 'pending-message') &&
item.message.id === reactionsTarget.id
) {
return item.message
}
}
return reactionsTarget
}, [convo.items, reactionsTarget])
const reportSubject = reportTarget
? ({
view: 'message',
@@ -169,11 +153,11 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)}
/>
)}
{reactionsMessage && (
{reactionsTarget && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={reactionsMessage}
message={reactionsTarget}
onClose={() => setReactionsTarget(null)}
/>
)}
+17 -6
View File
@@ -1,6 +1,10 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {
ChatBskyConvoDefs,
moderateProfile,
type ModerationOpts,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -8,6 +12,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
import {atoms as a, useTheme, web} from '#/alf'
@@ -83,6 +88,7 @@ function ProfileHeaderReady({
}) {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const profile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts)
@@ -104,6 +110,12 @@ function ProfileHeaderReady({
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const handle = isDeletedAccount ? null : sanitizeHandle(profile.handle, '@')
const latestReportableMessage =
ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) &&
convo.view.lastMessage.sender?.did !== currentAccount?.did
? convo.view.lastMessage
: undefined
return (
<Wrapper
heading={
@@ -121,8 +133,7 @@ function ProfileHeaderReady({
<View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}>
<Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}
emoji>
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
@@ -140,10 +151,11 @@ function ProfileHeaderReady({
}
settings={
<ConvoMenu
convo={convo}
convo={convo.view}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
}
/>
@@ -186,8 +198,7 @@ function GroupHeaderReady({
<View style={[a.flex_row, a.flex_1, a.align_center]}>
<Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}
emoji>
numberOfLines={1}>
{convo.details.name}
</Text>
<MuteStatus muted={convo.view.muted} />
+1 -2
View File
@@ -45,8 +45,7 @@ export function SystemMessageItem({
a.text_center,
t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'},
]}
emoji>
]}>
{text}
</Text>
</View>
@@ -34,40 +34,32 @@ export function GroupChatProfileCard({
name={profile.did}
label={displayName}
style={[a.flex_1, a.py_sm, a.px_lg]}>
{({disabled, selected}) => (
<>
<View
style={[
a.flex_grow,
!enabled || (disabled && !selected) ? {opacity: 0.5} : null,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
<View style={[a.flex_grow, !enabled ? {opacity: 0.5} : null]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
</View>
{enabled ? <Toggle.Checkbox /> : null}
</>
)}
</ProfileCard.Header>
</View>
{enabled ? <Toggle.Checkbox /> : null}
</Toggle.Item>
)
}
+1 -11
View File
@@ -1,12 +1,7 @@
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
} from '@atproto/api'
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
postUriToRelativePath,
@@ -18,7 +13,6 @@ export type UserMessageInfo = {
message: string | null
sentAt: string
reportableMessage?: ChatBskyConvoDefs.MessageView
isBlockedMessage: boolean
}
export function getMessageInfo({
@@ -42,7 +36,6 @@ export function getMessageInfo({
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
const prefix = (message: string) => {
if (isFromMe) {
@@ -87,8 +80,6 @@ export function getMessageInfo({
} else {
message = prefix(defaultEmbeddedContentMessage)
}
} else if (ChatBskyEmbedJoinLink.isView(lastMessage.embed)) {
message = prefix(i18n._(msg`(chat invite link)`))
} else {
message = prefix(defaultEmbeddedContentMessage)
}
@@ -98,6 +89,5 @@ export function getMessageInfo({
message,
sentAt: lastMessage.sentAt,
reportableMessage,
isBlockedMessage,
}
}
-55
View File
@@ -10,7 +10,6 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
@@ -48,22 +47,6 @@ export function canBeAddedToGroup(profile: bsky.profile.AnyProfileView) {
}
}
/**
* Resolves the effective `allowGroupInvites` value for a chat declaration.
* When unset, group invites follow the general DM preference
* (`allowIncoming`), which itself defaults to `following`. This mirrors the
* `undefined` fallthrough in canBeAddedToGroup, and is the single source of
* truth for both displaying and persisting the setting.
*/
export function resolveAllowGroupInvites(
chat: {allowIncoming?: string; allowGroupInvites?: string} | undefined,
): 'all' | 'none' | 'following' {
return (chat?.allowGroupInvites ?? chat?.allowIncoming ?? 'following') as
| 'all'
| 'none'
| 'following'
}
export function localDateString(date: Date) {
// can't use toISOString because it should be in local time
const mm = date.getMonth()
@@ -241,41 +224,3 @@ export function parseConvoView(
return null
}
}
/**
* Resolves the report subject for a conversation-level "Report conversation"
* action (as opposed to reporting an individual message, which always reports
* that message + its sender).
*
* - group: always report the whole convo, targeting the owner. Returns null if
* the owner has left, in which case there is nothing to report against.
* - direct: report the last reportable message if there is one (i.e. the last
* message exists and wasn't sent by us), otherwise report the whole convo
* targeting the other user.
*/
export function getConvoReportSubject(
convo: ConvoWithDetails,
ownDid: string | undefined,
): ReportSubject | null {
if (convo.kind === 'group') {
if (!convo.primaryMember) return null
return {convoId: convo.view.id, did: convo.primaryMember.did}
}
const lastMessage = convo.view.lastMessage
const reportableMessage =
ChatBskyConvoDefs.isMessageView(lastMessage) &&
lastMessage.sender?.did !== ownDid
? lastMessage
: null
if (reportableMessage) {
return {
view: 'convo',
convoId: convo.view.id,
message: reportableMessage,
}
}
return {convoId: convo.view.id, did: convo.primaryMember.did}
}
+14 -18
View File
@@ -1,4 +1,10 @@
import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {IS_WEB, IS_WEB_FIREFOX, IS_WEB_SAFARI} from '#/env'
@@ -7,38 +13,28 @@ function fullscreenSubscribe(onChange: () => void) {
return () => document.removeEventListener('fullscreenchange', onChange)
}
function getFullscreenSnapshot() {
return Boolean(document.fullscreenElement)
}
export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
if (!IS_WEB) throw new Error("'useFullscreen' is a web-only hook")
const isFullscreen = useSyncExternalStore(
fullscreenSubscribe,
getFullscreenSnapshot,
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
Boolean(document.fullscreenElement),
)
const scrollYRef = useRef<null | number>(null)
// Tracked via a ref rather than state so that reacting to a fullscreen change
// never schedules its own render. Scheduling a render in response to the
// external store value (the old `setPrevIsFullscreen` pattern) was a seed for
// the commit-phase update loop reported in APP-315 / APP-5PP / APP-7ZB.
const prevIsFullscreenRef = useRef(isFullscreen)
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen)
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
void document.exitFullscreen()
document.exitFullscreen()
} else {
if (!ref) throw new Error('No ref provided')
if (!ref.current) return
scrollYRef.current = window.scrollY
void ref.current.requestFullscreen()
ref.current.requestFullscreen()
}
}, [isFullscreen, ref])
useEffect(() => {
const prevIsFullscreen = prevIsFullscreenRef.current
if (prevIsFullscreen === isFullscreen) return
prevIsFullscreenRef.current = isFullscreen
setPrevIsFullscreen(isFullscreen)
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen
// Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
@@ -50,7 +46,7 @@ export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
}
}, 100)
}
}, [isFullscreen])
}, [isFullscreen, prevIsFullscreen])
return [isFullscreen, toggleFullscreen] as const
}
+7 -19
View File
@@ -55,13 +55,6 @@ interface GalleryProps {
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
// Post context for the in-feed carousel swipe metric. Omit for non-post
// contexts (no event will be emitted).
metricsPostContext?: {
postUri: string
postAuthorDid: string
feedDescriptor?: string
}
}
const Context = createContext<{
@@ -106,7 +99,6 @@ export function Gallery({
onPressIn,
viewContext,
isWithinQuote,
metricsPostContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
@@ -177,17 +169,13 @@ export function Gallery({
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
if (!metricsPostContext) return
ax.metric('post:photoEmbed:carouselSwipe', {
ax.metric('post:gallery:swipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
postUri: metricsPostContext.postUri,
postAuthorDid: metricsPostContext.postAuthorDid,
feedDescriptor: metricsPostContext.feedDescriptor,
})
}, 200),
[ax, images.length, metricsPostContext],
[ax, images.length],
)
const setCurrentIndex = (index: number) => {
@@ -289,6 +277,10 @@ export function Gallery({
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++) {
@@ -523,11 +515,7 @@ function GalleryImage({
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans
context="gallery-badge-image-position-numbers"
comment="Badge showing the current image position out of the total number of images in a gallery.">
{index + 1}/{imageCount}
</Trans>
{index + 1}/{imageCount}
</Text>
</View>
) : null}
+13 -12
View File
@@ -1,13 +1,11 @@
import {View} from 'react-native'
import {
ChatBskyGroupDefs,
ChatBskyGroupRequestJoin,
ChatBskyGroupWithdrawJoinRequest,
moderateProfile,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
@@ -16,10 +14,7 @@ import {isNetworkError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
invalidateJoinLinkPreviewsForCode,
useJoinLinkPreviewsQuery,
} from '#/state/queries/join-links'
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'
@@ -82,7 +77,6 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {hasSession} = useSession()
const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>()
const queryClient = useQueryClient()
const {data, error, isLoading} = useJoinLinkPreviewsQuery({
codes: code ? [code] : undefined,
@@ -93,7 +87,6 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const {mutate: joinGroupChat, isPending: isJoinPending} =
useRequestJoinGroupChat({
onSuccess: data => {
if (code) void invalidateJoinLinkPreviewsForCode(queryClient, code)
switch (data.status) {
case 'pending':
control.close(() => {
@@ -218,7 +211,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const joinLinkPreview = data.joinLinkPreviews[0]
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
if (!joinLinkPreview) {
return (
<>
<View style={[a.py_lg, a.align_center]}>
@@ -260,7 +253,12 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
? l`Request to join`
: l`Join`
let buttonColor: ButtonColor = 'primary'
if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
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`
@@ -313,7 +311,10 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</Trans>
</Text>
<View style={[a.flex_row, a.ml_md]}>
<PersonGroupIcon size="xs" style={[a.mr_xs, t.atoms.text]} />
<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]}>
@@ -370,7 +371,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</InlineLinkText>
</Text>
<ProfileBadges
profile={joinLinkPreview.owner}
profile={data.joinLinkPreviews[0].owner}
size="sm"
style={{marginTop: -3}}
/>
-398
View File
@@ -1,398 +0,0 @@
import {useState} from 'react'
import {View} from 'react-native'
import {
type ChatBskyConvoDefs,
ChatBskyConvoLeaveConvo,
ChatBskyGroupRemoveMembers,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {isNetworkError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/types'
import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
import {
createListMutualGroupsQueryKey,
useListMutualGroupsQuery,
} from '#/state/queries/messages/list-mutual-groups'
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
import {useSession} from '#/state/session'
import {atoms as a, native, useTheme, web} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {type DialogControlProps} from '#/components/Dialog'
import {parseConvoView} from '#/components/dms/util'
import {Loader} from '#/components/Loader'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {type AnyProfileView} from '#/types/bsky/profile'
type Item = ChatBskyConvoDefs.ConvoView
type BlockDialogProps = {
control: DialogControlProps
profile: Shadow<AnyProfileView>
onBlock: () => Promise<void>
currentConvoId?: string
}
export function BlockDialog({
control,
profile,
onBlock,
currentConvoId,
}: BlockDialogProps) {
return (
<Dialog.Outer control={control}>
<View style={[a.relative]}>
<Dialog.Handle />
<BlockDialogInner
control={control}
profile={profile}
onBlock={onBlock}
currentConvoId={currentConvoId}
/>
<Dialog.Close />
</View>
</Dialog.Outer>
)
}
function BlockDialogInner({
control,
profile,
onBlock,
currentConvoId,
}: {
control: DialogControlProps
profile: Shadow<AnyProfileView>
onBlock: () => Promise<void>
currentConvoId?: string
}) {
const t = useTheme()
const {t: l} = useLingui()
const [headerHeight, setHeaderHeight] = useState(0)
const [footerHeight, setFooterHeight] = useState(0)
/*
* Optimistically hide convos the viewer has left or removed the profile
* from, before the query refetches. We don't expect many items here, so a
* simple filter is fine.
*/
const [removedConvoIds, setRemovedConvoIds] = useState<Set<string>>(
() => new Set(),
)
const onOptimisticallyRemoveConvo = (convoId: string) => {
setRemovedConvoIds(prev => {
const next = new Set(prev)
next.add(convoId)
return next
})
}
const onRestoreConvo = (convoId: string) => {
setRemovedConvoIds(prev => {
const next = new Set(prev)
next.delete(convoId)
return next
})
}
const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} =
useListMutualGroupsQuery({
subject: profile.did,
enabled: !profile.viewer?.blocking,
})
const items: Item[] = (data?.pages.flatMap(page => page.convos) ?? []).filter(
item => !removedConvoIds.has(item.id),
)
const hasMutualGroupChats = items.length > 0
const onEndReached = async () => {
if (isFetchingNextPage || !hasNextPage) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more mutual group chats', {message: err})
}
}
const renderItems = ({item}: {item: Item}) => {
return (
<MutualGroupChat
view={item}
profileDid={profile.did}
currentConvoId={currentConvoId}
onOptimisticallyRemoveConvo={onOptimisticallyRemoveConvo}
onRestoreConvo={onRestoreConvo}
/>
)
}
const listHeader = (
<View
style={[t.atoms.bg]}
onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View
style={[
hasMutualGroupChats && native([a.pt_2xl, a.px_2xl]),
a.pb_lg,
a.gap_sm,
]}>
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>
{profile.viewer?.blocking ? (
<Trans>Unblock account?</Trans>
) : (
<Trans>Block account?</Trans>
)}
</Text>
<Text style={[a.text_md, t.atoms.text_contrast_medium]}>
{profile.viewer?.blocking ? (
<Trans>
The account will be able to interact with you after unblocking.
</Trans>
) : profile.associated?.labeler ? (
<Trans>
Blocking will not prevent labels from being applied on your
account, but it will stop this account from replying in your
threads or interacting with you.
</Trans>
) : (
<Trans>
Blocked accounts cannot reply in your threads, mention you, or
otherwise interact with you.
</Trans>
)}
</Text>
</View>
{hasMutualGroupChats ? (
<View style={[web(a.pt_sm), native(a.px_2xl), a.pb_xs, t.atoms.bg]}>
<Text
style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_high]}>
<Trans>Mutual group chats</Trans>
</Text>
</View>
) : null}
</View>
)
const footer = (
<View style={[a.w_full, a.gap_sm, a.justify_end]}>
<Button
color={profile.viewer?.blocking ? undefined : 'negative'}
size="large"
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
onPress={() => control.close(() => void onBlock())}>
<ButtonText>
{profile.viewer?.blocking ? (
<Trans>Unblock</Trans>
) : (
<Trans>Block</Trans>
)}
</ButtonText>
</Button>
<Button
color="secondary"
size="large"
label={l`Close dialog`}
onPress={() => control.close()}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
)
if (isLoading || !hasMutualGroupChats) {
return (
<Dialog.ScrollableInner
label={profile.viewer?.blocking ? l`Unblock` : l`Block`}
style={[web([{maxWidth: 420}])]}>
{listHeader}
{isLoading ? (
<View style={[a.pb_2xl, a.align_center, a.justify_center]}>
<Loader size="xl" />
</View>
) : null}
{footer}
</Dialog.ScrollableInner>
)
}
return (
<Dialog.InnerFlatList
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
ListFooterComponent={
isFetchingNextPage ? (
<View style={[a.py_lg, a.align_center, a.justify_center]}>
<Loader size="lg" />
</View>
) : null
}
footer={
<Dialog.FlatListFooter
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
{footer}
</Dialog.FlatListFooter>
}
contentContainerStyle={[a.gap_0, {paddingBottom: footerHeight}]}
scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}}
onEndReached={() => void onEndReached()}
onEndReachedThreshold={0.5}
style={[web([{maxWidth: 420}])]}
/>
)
}
function MutualGroupChat({
view,
profileDid,
currentConvoId,
onOptimisticallyRemoveConvo,
onRestoreConvo,
}: {
view: ChatBskyConvoDefs.ConvoView
profileDid: string
currentConvoId?: string
onOptimisticallyRemoveConvo: (convoId: string) => void
onRestoreConvo: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
const convo = parseConvoView(view, currentAccount?.did)
const {mutate: leaveConvo, isPending: isLeavePending} = useLeaveConvo(
convo?.view.id,
{
onSuccess: () => {
Toast.show(l`Left group chat.`)
void queryClient.invalidateQueries({
queryKey: createListMutualGroupsQueryKey({subject: profileDid}),
})
},
onError: error => {
onRestoreConvo(view.id)
logger.error('Error leaving group chat', {message: error})
let errorMessage = l`Could not leave chat.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (error instanceof ChatBskyConvoLeaveConvo.InvalidConvoError) {
errorMessage = l`Chat not found.`
} else if (
error instanceof ChatBskyConvoLeaveConvo.OwnerCannotLeaveError
) {
errorMessage = l`Chat owners cannot leave a group chat.`
}
Toast.show(errorMessage, {type: 'error'})
},
},
)
const {mutate: removeMembers, isPending: isRemovePending} =
useRemoveFromGroupChat(convo?.view.id, {
onSuccess: () => {
Toast.show(l`Member removed from group chat.`)
void queryClient.invalidateQueries({
queryKey: createListMutualGroupsQueryKey({subject: profileDid}),
})
},
onError: error => {
onRestoreConvo(view.id)
logger.error('Error removing group chat member', {message: error})
let errorMessage = l`Could not remove member.`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
error instanceof ChatBskyGroupRemoveMembers.InvalidConvoError
) {
errorMessage = l`Chat not found.`
} else if (
error instanceof ChatBskyGroupRemoveMembers.InsufficientRoleError
) {
errorMessage = l`You must be a chat owner to remove a member.`
}
Toast.show(errorMessage, {type: 'error'})
},
})
if (!convo || convo.kind !== 'group') return null
const owner = convo.primaryMember
const isViewerOwner = owner?.did != null && owner.did === currentAccount?.did
const isProfileOwner = owner?.did != null && owner.did === profileDid
const isCurrentConvo = view.id === currentConvoId
return (
<View
style={[
a.flex_row,
a.align_center,
a.gap_sm,
a.justify_between,
a.py_sm,
native(a.px_2xl),
]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<AvatarBubbles profiles={convo.members} size={40} />
<View>
<Text
style={[a.text_md, a.font_semi_bold, a.leading_snug, t.atoms.text]}>
{convo.details.name}
</Text>
{isViewerOwner ? (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>You own this chat</Trans>
</Text>
) : isProfileOwner ? (
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
<Trans>They own this chat</Trans>
</Text>
) : null}
</View>
</View>
{isViewerOwner ? (
<Button
color="negative_subtle"
disabled={isRemovePending}
label={l`Kick member`}
size="small"
onPress={() => {
onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]})
}}>
<ButtonText>
<Trans>Kick member</Trans>
</ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button>
) : isCurrentConvo ? (
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Current chat</Trans>
</Text>
) : (
<Button
color="secondary"
disabled={isLeavePending}
label={l`Leave chat`}
size="small"
onPress={() => {
onOptimisticallyRemoveConvo(view.id)
leaveConvo()
}}>
<ButtonText>
<Trans>Leave chat</Trans>
</ButtonText>
{isLeavePending ? <ButtonIcon icon={Loader} /> : null}
</Button>
)}
</View>
)
}
+1 -5
View File
@@ -9,7 +9,6 @@ import {
AppBskyFeedPost,
BlobRef,
type BskyAgent,
ChatBskyGroupDefs,
type ComAtprotoLabelDefs,
type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef,
@@ -464,10 +463,7 @@ async function resolveMedia(
},
}
}
if (
resolvedLink.type === 'chat-invite' &&
ChatBskyGroupDefs.isJoinLinkPreviewView(resolvedLink.view)
) {
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) {
return {
$type: 'app.bsky.embed.external',
external: {
+6 -6
View File
@@ -1,7 +1,8 @@
import {
type AppBskyFeedDefs,
type AppBskyGraphDefs,
type AtpAgent,
type BskyAgent,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
@@ -27,7 +28,6 @@ import {
} from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text'
@@ -77,7 +77,7 @@ type ResolvedChatInvite = {
type: 'chat-invite'
uri: string
code: string
view?: ChatInvitePreview
view?: ChatBskyGroupDefs.JoinLinkPreviewView
}
export type ResolvedLink =
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
}
export async function resolveLink(
agent: AtpAgent,
agent: BskyAgent,
uri: string,
): Promise<ResolvedLink> {
if (isShortLink(uri)) {
@@ -217,7 +217,7 @@ export async function resolveLink(
}
export async function resolveGif(
agent: AtpAgent,
agent: BskyAgent,
gif: Gif,
): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
}
async function resolveExternal(
agent: AtpAgent,
agent: BskyAgent,
uri: string,
): Promise<ResolvedExternalLink> {
const result = await getLinkMeta(agent, uri)
-2
View File
@@ -67,8 +67,6 @@ export const MAX_DRAFT_GRAPHEME_LENGTH = 1000
export const MAX_DM_GRAPHEME_LENGTH = 1000
export const MAX_GROUP_NAME_GRAPHEME_LENGTH = 50
// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
// but increasing limit per user feedback
export const MAX_ALT_TEXT = 2000
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as bsky from '#/types/bsky'
export function isBlockedOrBlocking(profile: bsky.profile.AnyProfileView) {
return Boolean(profile.viewer?.blockedBy || profile.viewer?.blocking)
return profile.viewer?.blockedBy || profile.viewer?.blocking
}
export function isMuted(profile: bsky.profile.AnyProfileView) {
+1 -1
View File
@@ -179,7 +179,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
}
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
export const CHAT_INVITE_CODE_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/
export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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