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

This commit is contained in:
Michael Black
2026-06-09 15:53:24 -05:00
70 changed files with 1738 additions and 684 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.6", "@atproto/api": "0.20.11",
"@atproto/common": "^0.6.1", "@atproto/common": "^0.6.1",
"@resvg/resvg-js": "^2.6.2", "@resvg/resvg-js": "^2.6.2",
"express": "^4.19.2", "express": "^4.19.2",
+5 -5
View File
@@ -208,8 +208,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.6 specifier: 0.20.11
version: 0.20.6 version: 0.20.11
'@atproto/common': '@atproto/common':
specifier: ^0.6.1 specifier: ^0.6.1
version: 0.6.1 version: 0.6.1
@@ -259,8 +259,8 @@ importers:
packages: packages:
'@atproto/api@0.20.6': '@atproto/api@0.20.11':
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==} resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.0':
@@ -1154,7 +1154,7 @@ packages:
snapshots: snapshots:
'@atproto/api@0.20.6': '@atproto/api@0.20.11':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
+2 -2
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert' import assert from 'node:assert'
import {type ChatBskyGroupDefs} from '@atproto/api' import {ChatBskyGroupDefs} from '@atproto/api'
import resvg from '@resvg/resvg-js' import resvg from '@resvg/resvg-js'
import {type Express} from 'express' import {type Express} from 'express'
import satori from 'satori' import satori from 'satori'
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
codes: [code], codes: [code],
}) })
const found = result.data.joinLinkPreviews[0] const found = result.data.joinLinkPreviews[0]
if (!found) { if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
return res.status(404).end('not found') return res.status(404).end('not found')
} }
preview = found preview = found
+34 -3
View File
@@ -177,9 +177,9 @@ func bskyProfileURL(handle string) string {
return fmt.Sprintf("https://bsky.app/profile/%s", handle) return fmt.Sprintf("https://bsky.app/profile/%s", handle)
} }
// extractPostMedia returns thumbnail URLs for the post's image or video // extractPostMedia returns thumbnail URLs for the post's image, gallery,
// embed, byte-identical to what we put in og:image. Callers derive // or video embed, byte-identical to what we put in og:image. Callers
// thumbnailUrl from urls[0]. // derive thumbnailUrl from urls[0].
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string { func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
if pv == nil || pv.Embed == nil || embedHidden { if pv == nil || pv.Embed == nil || embedHidden {
return nil return nil
@@ -188,6 +188,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if pv.Embed.EmbedImages_View != nil { if pv.Embed.EmbedImages_View != nil {
return imageThumbs(pv.Embed.EmbedImages_View.Images) 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 { if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
return []string{*pv.Embed.EmbedVideo_View.Thumbnail} return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
} }
@@ -196,6 +199,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
if media.EmbedImages_View != nil { if media.EmbedImages_View != nil {
return imageThumbs(media.EmbedImages_View.Images) 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 { if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
return []string{*media.EmbedVideo_View.Thumbnail} return []string{*media.EmbedVideo_View.Thumbnail}
} }
@@ -215,6 +221,31 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
return urls 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
}
// findVideoEmbed returns the post's video embed view, or nil if there is // findVideoEmbed returns the post's video embed view, or nil if there is
// none or embeds are hidden. Shared with extractVideoMeta so og:video and // none or embeds are hidden. Shared with extractVideoMeta so og:video and
// JSON-LD VideoObject stay in sync. // JSON-LD VideoObject stay in sync.
+155
View File
@@ -72,6 +72,60 @@ 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. // withVideo adds a video embed with a thumbnail.
func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) { func withVideo(thumb string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) { return func(pv *appbsky.FeedDefs_PostView) {
@@ -299,6 +353,88 @@ 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) { func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg" 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)) pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
@@ -361,6 +497,25 @@ 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) { func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char. // Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉" tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
+34
View File
@@ -98,6 +98,40 @@ 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) { func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url. // Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi") pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
go 1.26 go 1.26
require ( require (
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c
github.com/flosch/pongo2/v6 v6.0.0 github.com/flosch/pongo2/v6 v6.0.0
github.com/ipfs/go-log v1.0.5 github.com/ipfs/go-log v1.0.5
github.com/joho/godotenv v1.5.1 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/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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY= github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/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= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+109 -41
View File
@@ -1,4 +1,113 @@
{ {
"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": { "src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": { "@typescript-eslint/no-floating-promises": {
"count": 1 "count": 1
@@ -19,11 +128,6 @@
"count": 1 "count": 1
} }
}, },
"src/ageAssurance/util.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/alf/util/flatten.ts": { "src/alf/util/flatten.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
@@ -603,11 +707,6 @@
"count": 1 "count": 1
} }
}, },
"src/components/dms/MessageItem.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/components/forms/DateField/index.web.tsx": { "src/components/forms/DateField/index.web.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
@@ -650,14 +749,6 @@
"count": 2 "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": { "src/components/hooks/useLandingEntry.native.ts": {
"react-hooks/set-state-in-effect": { "react-hooks/set-state-in-effect": {
"count": 1 "count": 1
@@ -1903,12 +1994,6 @@
"src/state/session/agent.ts": { "src/state/session/agent.ts": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
} }
}, },
"src/state/shell/color-mode.tsx": { "src/state/shell/color-mode.tsx": {
@@ -2271,23 +2356,6 @@
"count": 2 "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": { "src/view/com/testing/TestCtrls.e2e.tsx": {
"@typescript-eslint/no-explicit-any": { "@typescript-eslint/no-explicit-any": {
"count": 1 "count": 1
@@ -67,7 +67,18 @@ export class GifView extends PureComponent<GifViewProps> {
} }
async playAsync(): Promise<void> { async playAsync(): Promise<void> {
this.videoPlayerRef.current?.play() 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
}
} }
async pauseAsync(): Promise<void> { async pauseAsync(): Promise<void> {
@@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import {StyleProp, ViewStyle} from 'react-native' import {type StyleProp, type ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core' import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
import {VisibilityViewProps} from './types' import {type VisibilityViewProps} from './types'
const NativeView: React.ComponentType<{ const NativeView: React.ComponentType<{
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
children: React.ReactNode children: React.ReactNode
enabled: Boolean enabled: boolean
style: StyleProp<ViewStyle> style: StyleProp<ViewStyle>
}> = requireNativeViewManager('ExpoBlueskyVisibilityView') }> = requireNativeViewManager('ExpoBlueskyVisibilityView')
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.123.0", "version": "1.124.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=24.15.0" "node": ">=24.15.0"
@@ -59,7 +59,7 @@
"test-watch": "NODE_ENV=test jest --watchAll", "test-watch": "NODE_ENV=test jest --watchAll",
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit", "test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
"test-coverage": "NODE_ENV=test jest --coverage", "test-coverage": "NODE_ENV=test jest --coverage",
"lint": "eslint --cache --quiet src", "lint": "eslint --cache --quiet src modules",
"lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native": "swiftlint ./modules && ktlint ./modules",
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
"typecheck": "tsgo --project ./tsconfig.check.json", "typecheck": "tsgo --project ./tsconfig.check.json",
@@ -93,7 +93,7 @@
"prettier": "prettier --check ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.9", "@atproto/api": "0.20.11",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
+5 -5
View File
@@ -242,8 +242,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.9 specifier: 0.20.11
version: 0.20.9 version: 0.20.11
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@@ -877,8 +877,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.9': '@atproto/api@0.20.11':
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==} resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.0':
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.9': '@atproto/api@0.20.11':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
+28
View File
@@ -0,0 +1,28 @@
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,
},
],
}
+20 -7
View File
@@ -1,6 +1,7 @@
import {createContext, useCallback, useContext, useMemo} from 'react' import {createContext, useCallback, useContext, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications' import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay' import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import { import {
@@ -20,7 +21,6 @@ import {
} from '#/ageAssurance/types' } from '#/ageAssurance/types'
import { import {
computeAgeAssuranceFlags, computeAgeAssuranceFlags,
maybeRestrictChatSettings,
useAgeAssuranceRegionConfigWithFallback, useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
@@ -32,7 +32,6 @@ export {
usePatchServerState as usePatchAgeAssuranceServerState, usePatchServerState as usePatchAgeAssuranceServerState,
} from '#/ageAssurance/data' } from '#/ageAssurance/data'
export {logger} from '#/ageAssurance/logger' export {logger} from '#/ageAssurance/logger'
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
const AgeAssuranceStateContext = createContext<{ const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess Access: typeof AgeAssuranceAccess
@@ -48,8 +47,10 @@ const AgeAssuranceStateContext = createContext<{
access: AgeAssuranceAccess.Full, access: AgeAssuranceAccess.Full,
}, },
flags: { flags: {
isAgeRestricted: false,
adultContentDisabled: false, adultContentDisabled: false,
chatDisabled: false, chatDisabled: false,
groupChatDisabled: false,
isDeclaredUnderAdultAge: false, isDeclaredUnderAdultAge: false,
isOverRegionMinAccessAge: false, isOverRegionMinAccessAge: false,
isOverAppMinAccessAge: false, isOverAppMinAccessAge: false,
@@ -84,13 +85,25 @@ function InnerProvider({children}: {children: React.ReactNode}) {
const handleAccessUpdate = useCallback( const handleAccessUpdate = useCallback(
(s: AgeAssuranceState) => { (s: AgeAssuranceState) => {
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full const flags = computeAgeAssuranceFlags({
if (isAgeRestricted) { state: s,
void getAndRegisterPushToken({isAgeRestricted}) regionConfig,
maybeRestrictChatSettings({agent}) metadata,
})
if (flags.isAgeRestricted) {
void getAndRegisterPushToken({
isAgeRestricted: true,
})
}
if (flags.chatDisabled || flags.groupChatDisabled) {
void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
} }
}, },
[agent, getAndRegisterPushToken], [agent, getAndRegisterPushToken, regionConfig, metadata],
) )
useOnAgeAssuranceAccessUpdate(handleAccessUpdate) useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
+2
View File
@@ -30,8 +30,10 @@ export type AgeAssuranceState = {
} }
export type AgeAssuranceFlags = { export type AgeAssuranceFlags = {
isAgeRestricted: boolean
adultContentDisabled: boolean adultContentDisabled: boolean
chatDisabled: boolean chatDisabled: boolean
groupChatDisabled: boolean
isDeclaredUnderAdultAge: boolean isDeclaredUnderAdultAge: boolean
isOverRegionMinAccessAge: boolean isOverRegionMinAccessAge: boolean
isOverAppMinAccessAge: boolean isOverAppMinAccessAge: boolean
+8 -41
View File
@@ -1,20 +1,14 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import { import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
type AtpAgent,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
type ModerationPrefs, type ModerationPrefs,
} from '@atproto/api' } from '@atproto/api'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import { import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
getDidFromAgentSession,
getOtherRequiredDataFromCache,
useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import { import {
AgeAssuranceAccess, AgeAssuranceAccess,
type AgeAssuranceFlags, type AgeAssuranceFlags,
@@ -23,24 +17,6 @@ import {
} from '#/ageAssurance/types' } from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation' 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 * Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found. * app defaults if no region config is found.
@@ -121,19 +97,6 @@ export const makeAgeRestrictedModerationPrefs = (
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES, labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
}) })
/**
* 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})
}
export function computeAgeAssuranceFlags({ export function computeAgeAssuranceFlags({
state, state,
regionConfig, regionConfig,
@@ -143,10 +106,12 @@ export function computeAgeAssuranceFlags({
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
}): AgeAssuranceFlags { }): AgeAssuranceFlags {
const chatDisabled = state.access !== AgeAssuranceAccess.Full const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
const chatDisabled = isAgeRestricted
const isDeclaredUnderAdultAge = metadata?.declaredAge const isDeclaredUnderAdultAge = metadata?.declaredAge
? metadata.declaredAge < 18 ? metadata.declaredAge < 18
: true : true
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
const isOverRegionMinAccessAge = metadata?.declaredAge const isOverRegionMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= regionConfig.minAccessAge ? metadata.declaredAge >= regionConfig.minAccessAge
: false : false
@@ -157,8 +122,10 @@ export function computeAgeAssuranceFlags({
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return { return {
isAgeRestricted,
adultContentDisabled, adultContentDisabled,
chatDisabled, chatDisabled,
groupChatDisabled,
isDeclaredUnderAdultAge, isDeclaredUnderAdultAge,
isOverRegionMinAccessAge, isOverRegionMinAccessAge,
isOverAppMinAccessAge, isOverAppMinAccessAge,
+2 -5
View File
@@ -10,7 +10,7 @@ import Animated, {
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {android, atoms as a, ios} from '#/alf' import {atoms as a} from '#/alf'
import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight' import {ArrowShareRight_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowShareRight'
import {type Props as IconProps} from '#/components/icons/common' import {type Props as IconProps} from '#/components/icons/common'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid'
@@ -25,7 +25,6 @@ type Props = {
type Anchor = {x: number; y: number; width: number; height: number} type Anchor = {x: number; y: number; width: number; height: number}
const MENU_WIDTH = 160
const GAP = 6 const GAP = 6
const CARD_BG = '#000000' const CARD_BG = '#000000'
const CARD_BORDER = '#232e3e' const CARD_BORDER = '#232e3e'
@@ -124,9 +123,8 @@ function MenuCard({
<Animated.View <Animated.View
style={[ style={[
a.absolute, a.absolute,
a.self_start,
styles.card, styles.card,
android({alignSelf: 'flex-start'}),
ios({width: MENU_WIDTH}),
{ {
top: anchor.y + anchor.height + GAP, top: anchor.y + anchor.height + GAP,
left: anchor.x, left: anchor.x,
@@ -186,7 +184,6 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255, 255, 255, 0.08)', backgroundColor: 'rgba(255, 255, 255, 0.08)',
}, },
itemText: { itemText: {
flex: 1,
fontSize: 15, fontSize: 15,
fontWeight: '500', fontWeight: '500',
lineHeight: 19.5, lineHeight: 19.5,
+1 -1
View File
@@ -154,7 +154,7 @@ export function ImageItem({
} }
return ( return (
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}> <View style={[a.relative, a.aspect_square, {maxWidth}]}>
<Image <Image
key={thumbnail} key={thumbnail}
source={{uri: thumbnail}} source={{uri: thumbnail}}
@@ -1,7 +1,11 @@
import {type StyleProp, View, type ViewStyle} from 'react-native' import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyGroupDefs} from '@atproto/api' import {ChatBskyGroupDefs} from '@atproto/api'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {
type ChatInvitePreview,
isKnownJoinLinkPreview,
} from '#/state/queries/join-links'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite' import * as ChatInvite from '#/components/dms/ChatInvite'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
@@ -23,11 +27,12 @@ export function JoinRequestEmbed({
onOpen, onOpen,
}: { }: {
code?: string code?: string
preview?: ChatBskyGroupDefs.JoinLinkPreviewView preview?: ChatInvitePreview
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
onOpen?: () => void onOpen?: () => void
}) { }) {
const resolvedCode = code ?? preview?.code const resolvedCode =
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
if (!resolvedCode) return null if (!resolvedCode) return null
return ( return (
@@ -73,7 +78,7 @@ export function JoinRequestEmbedBody({
) )
} }
if (!preview) { if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
return ( return (
<View <View
style={[ style={[
@@ -108,10 +108,15 @@ export function useActiveVideoWeb() {
return { return {
active: activeViewId === id, active: activeViewId === id,
setActive: () => { setActive: useCallback(() => {
setActiveView(id) setActiveView(id)
}, }, [setActiveView, id]),
currentActiveView: activeViewId, currentActiveView: activeViewId,
sendPosition: (y: number) => sendViewPosition(id, y), sendPosition: useCallback(
(y: number) => {
sendViewPosition(id, y)
},
[sendViewPosition, id],
),
} }
} }
@@ -88,6 +88,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import { import {
ReportDialog, ReportDialog,
useReportDialogControl, useReportDialogControl,
@@ -845,13 +846,10 @@ let PostMenuItems = ({
onConfirm={() => void onToggleReplyVisibility()} onConfirm={() => void onToggleReplyVisibility()}
confirmButtonCta={l`Yes, hide`} confirmButtonCta={l`Yes, hide`}
/> />
<Prompt.Basic <BlockDialog
control={blockPromptControl} control={blockPromptControl}
title={l`Block Account?`} profile={postAuthor}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} onBlock={onBlockAuthor}
onConfirm={() => void onBlockAuthor()}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/> />
</> </>
) )
+14 -1
View File
@@ -1,8 +1,10 @@
import {useCallback, useRef, useState} from 'react' import {useCallback, useRef, useState} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api' import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
@@ -72,7 +74,18 @@ export function ActionsWrapper({
.removeReaction(message.id, emoji) .removeReaction(message.id, emoji)
.catch(() => Toast.show(l`Failed to remove emoji reaction`)) .catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else { } else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return 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
}
convo.addReaction(message.id, emoji).catch(() => convo.addReaction(message.id, emoji).catch(() =>
Toast.show(l`Failed to add emoji reaction`, { Toast.show(l`Failed to add emoji reaction`, {
type: 'error', type: 'error',
+1 -6
View File
@@ -209,6 +209,7 @@ export function AddMembersFlow({
if (follows) { if (follows) {
for (const page of follows.pages) { for (const page of follows.pages) {
for (const profile of page.follows) { for (const profile of page.follows) {
if (!canBeAddedToGroup(profile)) continue
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
@@ -216,12 +217,6 @@ export function AddMembersFlow({
}) })
} }
} }
_items.sort(item => {
return item.type === 'profile' && canBeAddedToGroup(item.profile)
? -1
: 1
})
} else { } else {
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
_items.push({type: 'placeholder', key: i + ''}) _items.push({type: 'placeholder', key: i + ''})
+5 -4
View File
@@ -1,4 +1,5 @@
import {View} from 'react-native' import {View} from 'react-native'
import {ChatBskyGroupDefs} from '@atproto/api'
import {Plural, Trans} from '@lingui/react/macro' import {Plural, Trans} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -6,7 +7,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles' import {AvatarBubbles} from '#/components/AvatarBubbles'
import {SimpleInlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useChatInvite} from './Context' import {useChatInvite} from './Context'
@@ -20,7 +21,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
const t = useTheme() const t = useTheme()
const {preview, hasFixedHeight} = useChatInvite() const {preview, hasFixedHeight} = useChatInvite()
if (!preview) return null if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) return null
const ownerDisplayName = createSanitizedDisplayName(preview.owner) const ownerDisplayName = createSanitizedDisplayName(preview.owner)
const ownerHandle = sanitizeHandle(preview.owner.handle, '@') const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
@@ -77,12 +78,12 @@ export function Card({size}: {size: 'large' | 'small'}) {
allowFontScaling={!hasFixedHeight}> allowFontScaling={!hasFixedHeight}>
<Trans comment="The group chat creator, in the format 'By {displayName}'."> <Trans comment="The group chat creator, in the format 'By {displayName}'.">
By{' '} By{' '}
<SimpleInlineLinkText <InlineLinkText
to={makeProfileLink(preview.owner)} to={makeProfileLink(preview.owner)}
label={ownerDisplayName} label={ownerDisplayName}
style={[a.font_medium, t.atoms.text]}> style={[a.font_medium, t.atoms.text]}>
{ownerDisplayName} {ownerDisplayName}
</SimpleInlineLinkText> </InlineLinkText>
</Trans> </Trans>
</Text> </Text>
<ProfileBadges <ProfileBadges
+2 -2
View File
@@ -1,6 +1,6 @@
import {createContext, useContext} from 'react' 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 ButtonColor} from '#/components/Button'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
@@ -26,7 +26,7 @@ export type ChatInviteContextValue = {
code: string code: string
loading: boolean loading: boolean
error: boolean error: boolean
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined preview: ChatInvitePreview | undefined
/** /**
* The derived action descriptor. Undefined while loading or when there's no * The derived action descriptor. Undefined while loading or when there's no
* preview to act on. * preview to act on.
+8 -11
View File
@@ -1,10 +1,13 @@
import {setStringAsync} from 'expo-clipboard' import {setStringAsync} from 'expo-clipboard'
import {type ChatBskyGroupDefs} from '@atproto/api' import {ChatBskyGroupDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {type NavigationProp} from '#/lib/routes/types' import {type NavigationProp} from '#/lib/routes/types'
import {useJoinLinkPreviewsQuery} from '#/state/queries/join-links' import {
type ChatInvitePreview,
useJoinLinkPreviewsQuery,
} from '#/state/queries/join-links'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {type ButtonColor} from '#/components/Button' import {type ButtonColor} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
@@ -13,7 +16,6 @@ import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand' 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 {useIntentDialogs} from '#/components/intents/IntentDialogs'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {type ChatInviteAction, ChatInviteProvider} from './Context' import {type ChatInviteAction, ChatInviteProvider} from './Context'
@@ -34,7 +36,7 @@ export function Root({
children, children,
}: { }: {
code: string code: string
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView initialPreview?: ChatInvitePreview
/** /**
* The convo this invite is being viewed within, if any. When the invite * 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 * links to the same chat, the action becomes "Copy link" instead of
@@ -62,7 +64,7 @@ export function Root({
const loading = isPending && !preview const loading = isPending && !preview
let action: ChatInviteAction | undefined let action: ChatInviteAction | undefined
if (preview) { if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
const convoId = preview.convo?.id const convoId = preview.convo?.id
const isFollowing = preview.owner.viewer?.following ?? false const isFollowing = preview.owner.viewer?.following ?? false
const hasRequested = !convoId && preview.viewer?.requestedAt != null const hasRequested = !convoId && preview.viewer?.requestedAt != null
@@ -99,12 +101,7 @@ export function Root({
let icon: React.ComponentType<SVGIconProps> = JoinIcon let icon: React.ComponentType<SVGIconProps> = JoinIcon
let label = preview.requireApproval ? l`Request to join` : l`Join` let label = preview.requireApproval ? l`Request to join` : l`Join`
let color: ButtonColor = 'primary' let color: ButtonColor = 'primary'
if (preview.enabledStatus !== 'enabled') { if (preview.memberCount >= preview.memberLimit) {
canJoin = false
icon = WarningIcon
label = l`Chat invite link no longer available`
color = 'secondary'
} else if (preview.memberCount >= preview.memberLimit) {
canJoin = false canJoin = false
icon = HandIcon icon = HandIcon
label = l`This chat is full` label = l`This chat is full`
+10 -7
View File
@@ -36,6 +36,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env' import {IS_NATIVE, IS_WEB} from '#/env'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {ChatProfileTabs} from './ChatProfileTabs' import {ChatProfileTabs} from './ChatProfileTabs'
@@ -209,6 +210,7 @@ export function InitiateChatFlow({
const [footerHeight, setFooterHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0)
const listRef = useRef<ListMethods>(null) const listRef = useRef<ListMethods>(null)
const {currentAccount} = useSession() const {currentAccount} = useSession()
const aa = useAgeAssurance()
const inputRef = useRef<TextInput>(null) const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl() const accountTooNewPromptControl = Dialog.useDialogControl()
@@ -301,6 +303,7 @@ export function InitiateChatFlow({
if (follows) { if (follows) {
for (const page of follows.pages) { for (const page of follows.pages) {
for (const profile of page.follows) { for (const profile of page.follows) {
if (!checker(profile)) continue
_items.push({ _items.push({
type: 'profile', type: 'profile',
key: profile.did, key: profile.did,
@@ -308,10 +311,6 @@ export function InitiateChatFlow({
}) })
} }
} }
_items = _items.sort(item => {
return item.type === 'profile' && checker(item.profile) ? -1 : 1
})
} else { } else {
_items.push(...placeholders) _items.push(...placeholders)
} }
@@ -330,7 +329,11 @@ export function InitiateChatFlow({
}) })
} }
if (chatState === ChatState.NEW_CHAT && searchText === '') { if (
chatState === ChatState.NEW_CHAT &&
searchText === '' &&
!aa.flags.groupChatDisabled
) {
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) _items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
} }
@@ -344,6 +347,7 @@ export function InitiateChatFlow({
results, results,
currentAccount?.did, currentAccount?.did,
follows, follows,
aa.flags.groupChatDisabled,
]) ])
if (searchText && !isFetching && !items.length && !isError) { if (searchText && !isFetching && !items.length && !isError) {
@@ -510,7 +514,6 @@ export function InitiateChatFlow({
a.relative, a.relative,
a.align_center, a.align_center,
a.justify_between, a.justify_between,
web(a.pb_lg),
]}> ]}>
{IS_NATIVE ? ( {IS_NATIVE ? (
<Button <Button
@@ -546,7 +549,7 @@ export function InitiateChatFlow({
color="secondary" color="secondary"
style={[a.absolute, a.z_20, {right: -4}]} style={[a.absolute, a.z_20, {right: -4}]}
onPress={() => control.close()}> onPress={() => control.close()}>
<ButtonIcon icon={XIcon} size="lg" /> <ButtonIcon icon={XIcon} size="md" />
</Button> </Button>
) : showButton ? ( ) : showButton ? (
<Button <Button
+14 -1
View File
@@ -6,8 +6,10 @@ import {
type ModerationOpts, type ModerationOpts,
RichText, RichText,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro' import {useLingui} from '@lingui/react/macro'
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
import {richTextToString} from '#/lib/strings/rich-text-helpers' import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow' import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
@@ -98,7 +100,18 @@ export let MessageContextMenu = ({
.removeReaction(message.id, emoji) .removeReaction(message.id, emoji)
.catch(() => Toast.show(l`Failed to remove emoji reaction`)) .catch(() => Toast.show(l`Failed to remove emoji reaction`))
} else { } else {
if (hasReachedReactionLimit(message, currentAccount?.did)) return 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
}
convo.addReaction(message.id, emoji).catch(() => convo.addReaction(message.id, emoji).catch(() =>
Toast.show(l`Failed to add emoji reaction`, { Toast.show(l`Failed to add emoji reaction`, {
type: 'error', type: 'error',
+2 -2
View File
@@ -58,7 +58,7 @@ const AVATAR_SIZE = 28
const CLUSTERED_MESSAGE_GAP = 2 const CLUSTERED_MESSAGE_GAP = 2
const BORDER_RADIUS = 18 const BORDER_RADIUS = 18
const SQUARED_BORDER_RADIUS = 4 const SQUARED_BORDER_RADIUS = 4
const DISPLAY_NAME_INSET = 22 const DISPLAY_NAME_INSET = 20
function isWithinClusterBoundary({ function isWithinClusterBoundary({
isPending, isPending,
@@ -641,7 +641,7 @@ function BlockedPlaceholder({
<Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" /> <Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" />
{profile.viewer?.blocking && !profile.viewer.blockingByList && ( {profile.viewer?.blocking && !profile.viewer.blockingByList && (
<Prompt.Action <Prompt.Action
onPress={() => queueUnblock()} onPress={() => void queueUnblock()}
cta={l`Unblock`} cta={l`Unblock`}
color="secondary" color="secondary"
/> />
@@ -3,6 +3,7 @@ import {useWindowDimensions, View} from 'react-native'
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api' import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
import {useConvoActive} from '#/state/messages/convo' import {useConvoActive} from '#/state/messages/convo'
import {isKnownJoinLinkPreview} from '#/state/queries/join-links'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import * as ChatInvite from '#/components/dms/ChatInvite' import * as ChatInvite from '#/components/dms/ChatInvite'
import {MessageContextProvider} from './MessageContext' import {MessageContextProvider} from './MessageContext'
@@ -27,6 +28,11 @@ let MessageItemInviteEmbed = ({
const screen = useWindowDimensions() const screen = useWindowDimensions()
const convo = useConvoActive() const convo = useConvoActive()
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
? embed.joinLinkPreview.code
: undefined
if (!code) return null
return ( return (
<MessageContextProvider> <MessageContextProvider>
<View <View
@@ -72,7 +78,7 @@ let MessageItemInviteEmbed = ({
}, },
]}> ]}>
<ChatInvite.Root <ChatInvite.Root
code={embed.joinLinkPreview.code} code={code}
initialPreview={embed.joinLinkPreview} initialPreview={embed.joinLinkPreview}
currentConvoId={convo.convo.view.id} currentConvoId={convo.convo.view.id}
hasFixedHeight={false}> hasFixedHeight={false}>
+4 -2
View File
@@ -121,7 +121,8 @@ function ProfileHeaderReady({
<View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}> <View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}>
<Text <Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]} style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}> numberOfLines={1}
emoji>
{displayName} {displayName}
</Text> </Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} /> <ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
@@ -185,7 +186,8 @@ function GroupHeaderReady({
<View style={[a.flex_row, a.flex_1, a.align_center]}> <View style={[a.flex_row, a.flex_1, a.align_center]}>
<Text <Text
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]} style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
numberOfLines={1}> numberOfLines={1}
emoji>
{convo.details.name} {convo.details.name}
</Text> </Text>
<MuteStatus muted={convo.view.muted} /> <MuteStatus muted={convo.view.muted} />
+2 -1
View File
@@ -45,7 +45,8 @@ export function SystemMessageItem({
a.text_center, a.text_center,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
{includeFontPadding: false, textAlignVertical: 'center'}, {includeFontPadding: false, textAlignVertical: 'center'},
]}> ]}
emoji>
{text} {text}
</Text> </Text>
</View> </View>
+11 -1
View File
@@ -1,7 +1,12 @@
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api' import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
} from '@atproto/api'
import {type I18n} from '@lingui/core' import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import { import {
postUriToRelativePath, postUriToRelativePath,
@@ -13,6 +18,7 @@ export type UserMessageInfo = {
message: string | null message: string | null
sentAt: string sentAt: string
reportableMessage?: ChatBskyConvoDefs.MessageView reportableMessage?: ChatBskyConvoDefs.MessageView
isBlockedMessage: boolean
} }
export function getMessageInfo({ export function getMessageInfo({
@@ -36,6 +42,7 @@ export function getMessageInfo({
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind) const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
const reportableMessage = isFromMe ? undefined : lastMessage const reportableMessage = isFromMe ? undefined : lastMessage
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
const prefix = (message: string) => { const prefix = (message: string) => {
if (isFromMe) { if (isFromMe) {
@@ -80,6 +87,8 @@ export function getMessageInfo({
} else { } else {
message = prefix(defaultEmbeddedContentMessage) message = prefix(defaultEmbeddedContentMessage)
} }
} else if (ChatBskyEmbedJoinLink.isView(lastMessage.embed)) {
message = prefix(i18n._(msg`(chat invite link)`))
} else { } else {
message = prefix(defaultEmbeddedContentMessage) message = prefix(defaultEmbeddedContentMessage)
} }
@@ -89,5 +98,6 @@ export function getMessageInfo({
message, message,
sentAt: lastMessage.sentAt, sentAt: lastMessage.sentAt,
reportableMessage, reportableMessage,
isBlockedMessage,
} }
} }
+18 -14
View File
@@ -1,10 +1,4 @@
import { import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {IS_WEB, IS_WEB_FIREFOX, IS_WEB_SAFARI} from '#/env' import {IS_WEB, IS_WEB_FIREFOX, IS_WEB_SAFARI} from '#/env'
@@ -13,28 +7,38 @@ function fullscreenSubscribe(onChange: () => void) {
return () => document.removeEventListener('fullscreenchange', onChange) return () => document.removeEventListener('fullscreenchange', onChange)
} }
function getFullscreenSnapshot() {
return Boolean(document.fullscreenElement)
}
export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) { export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
if (!IS_WEB) throw new Error("'useFullscreen' is a web-only hook") if (!IS_WEB) throw new Error("'useFullscreen' is a web-only hook")
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () => const isFullscreen = useSyncExternalStore(
Boolean(document.fullscreenElement), fullscreenSubscribe,
getFullscreenSnapshot,
) )
const scrollYRef = useRef<null | number>(null) const scrollYRef = useRef<null | number>(null)
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen) // 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 toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (isFullscreen) { if (isFullscreen) {
document.exitFullscreen() void document.exitFullscreen()
} else { } else {
if (!ref) throw new Error('No ref provided') if (!ref) throw new Error('No ref provided')
if (!ref.current) return if (!ref.current) return
scrollYRef.current = window.scrollY scrollYRef.current = window.scrollY
ref.current.requestFullscreen() void ref.current.requestFullscreen()
} }
}, [isFullscreen, ref]) }, [isFullscreen, ref])
useEffect(() => { useEffect(() => {
const prevIsFullscreen = prevIsFullscreenRef.current
if (prevIsFullscreen === isFullscreen) return if (prevIsFullscreen === isFullscreen) return
setPrevIsFullscreen(isFullscreen) prevIsFullscreenRef.current = isFullscreen
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen // 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 // Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
@@ -46,7 +50,7 @@ export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
} }
}, 100) }, 100)
} }
}, [isFullscreen, prevIsFullscreen]) }, [isFullscreen])
return [isFullscreen, toggleFullscreen] as const return [isFullscreen, toggleFullscreen] as const
} }
+4
View File
@@ -523,7 +523,11 @@ function GalleryImage({
a.font_bold, a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8}, 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} {index + 1}/{imageCount}
</Trans>
</Text> </Text>
</View> </View>
) : null} ) : null}
+5 -12
View File
@@ -1,5 +1,6 @@
import {View} from 'react-native' import {View} from 'react-native'
import { import {
ChatBskyGroupDefs,
ChatBskyGroupRequestJoin, ChatBskyGroupRequestJoin,
ChatBskyGroupWithdrawJoinRequest, ChatBskyGroupWithdrawJoinRequest,
moderateProfile, moderateProfile,
@@ -211,7 +212,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
const joinLinkPreview = data.joinLinkPreviews[0] const joinLinkPreview = data.joinLinkPreviews[0]
if (!joinLinkPreview) { if (!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return ( return (
<> <>
<View style={[a.py_lg, a.align_center]}> <View style={[a.py_lg, a.align_center]}>
@@ -253,12 +254,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
? l`Request to join` ? l`Request to join`
: l`Join` : l`Join`
let buttonColor: ButtonColor = 'primary' let buttonColor: ButtonColor = 'primary'
if (joinLinkPreview.enabledStatus !== 'enabled') { if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
canJoin = false
ButtonIconImage = WarningIcon
buttonText = l`Chat invite link no longer available`
buttonColor = 'secondary'
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
canJoin = false canJoin = false
ButtonIconImage = HandIcon ButtonIconImage = HandIcon
buttonText = l`This chat is full` buttonText = l`This chat is full`
@@ -311,10 +307,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</Trans> </Trans>
</Text> </Text>
<View style={[a.flex_row, a.ml_md]}> <View style={[a.flex_row, a.ml_md]}>
<PersonGroupIcon <PersonGroupIcon size="xs" style={[a.mr_xs, t.atoms.text]} />
size="xs"
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
/>
</View> </View>
<Text <Text
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}> style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
@@ -371,7 +364,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
</InlineLinkText> </InlineLinkText>
</Text> </Text>
<ProfileBadges <ProfileBadges
profile={data.joinLinkPreviews[0].owner} profile={joinLinkPreview.owner}
size="sm" size="sm"
style={{marginTop: -3}} style={{marginTop: -3}}
/> />
+398
View File
@@ -0,0 +1,398 @@
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>
)
}
+5 -1
View File
@@ -9,6 +9,7 @@ import {
AppBskyFeedPost, AppBskyFeedPost,
BlobRef, BlobRef,
type BskyAgent, type BskyAgent,
ChatBskyGroupDefs,
type ComAtprotoLabelDefs, type ComAtprotoLabelDefs,
type ComAtprotoRepoApplyWrites, type ComAtprotoRepoApplyWrites,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
@@ -463,7 +464,10 @@ async function resolveMedia(
}, },
} }
} }
if (resolvedLink.type === 'chat-invite' && resolvedLink.view) { if (
resolvedLink.type === 'chat-invite' &&
ChatBskyGroupDefs.isJoinLinkPreviewView(resolvedLink.view)
) {
return { return {
$type: 'app.bsky.embed.external', $type: 'app.bsky.embed.external',
external: { external: {
+6 -6
View File
@@ -1,8 +1,7 @@
import { import {
type AppBskyFeedDefs, type AppBskyFeedDefs,
type AppBskyGraphDefs, type AppBskyGraphDefs,
type BskyAgent, type AtpAgent,
type ChatBskyGroupDefs,
type ComAtprotoRepoStrongRef, type ComAtprotoRepoStrongRef,
} from '@atproto/api' } from '@atproto/api'
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
@@ -28,6 +27,7 @@ import {
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {type ComposerImage} from '#/state/gallery' import {type ComposerImage} from '#/state/gallery'
import {createComposerImage} from '#/state/gallery' import {createComposerImage} from '#/state/gallery'
import {type ChatInvitePreview} from '#/state/queries/join-links'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
import {createGIFDescription} from '../gif-alt-text' import {createGIFDescription} from '../gif-alt-text'
@@ -77,7 +77,7 @@ type ResolvedChatInvite = {
type: 'chat-invite' type: 'chat-invite'
uri: string uri: string
code: string code: string
view?: ChatBskyGroupDefs.JoinLinkPreviewView view?: ChatInvitePreview
} }
export type ResolvedLink = export type ResolvedLink =
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
} }
export async function resolveLink( export async function resolveLink(
agent: BskyAgent, agent: AtpAgent,
uri: string, uri: string,
): Promise<ResolvedLink> { ): Promise<ResolvedLink> {
if (isShortLink(uri)) { if (isShortLink(uri)) {
@@ -217,7 +217,7 @@ export async function resolveLink(
} }
export async function resolveGif( export async function resolveGif(
agent: BskyAgent, agent: AtpAgent,
gif: Gif, gif: Gif,
): Promise<ResolvedExternalLink> { ): Promise<ResolvedExternalLink> {
const gifUrl = gif.media_formats.gif.url const gifUrl = gif.media_formats.gif.url
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
} }
async function resolveExternal( async function resolveExternal(
agent: BskyAgent, agent: AtpAgent,
uri: string, uri: string,
): Promise<ResolvedExternalLink> { ): Promise<ResolvedExternalLink> {
const result = await getLinkMeta(agent, uri) const result = await getLinkMeta(agent, uri)
+1 -1
View File
@@ -1,7 +1,7 @@
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
export function isBlockedOrBlocking(profile: bsky.profile.AnyProfileView) { export function isBlockedOrBlocking(profile: bsky.profile.AnyProfileView) {
return profile.viewer?.blockedBy || profile.viewer?.blocking return Boolean(profile.viewer?.blockedBy || profile.viewer?.blocking)
} }
export function isMuted(profile: bsky.profile.AnyProfileView) { export function isMuted(profile: bsky.profile.AnyProfileView) {
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,7 +1,7 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated' import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api' import {type ChatBskyActorGetStatus, ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -198,6 +198,7 @@ export function ChatList({
}) { }) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const aa = useAgeAssurance()
const scrollElRef: ListRef = useAnimatedRef() const scrollElRef: ListRef = useAnimatedRef()
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinSplitView} = useIsWithinSplitView()
@@ -230,6 +231,7 @@ export function ChatList({
const {refetch: refetchInbox} = useListConvosQuery({ const {refetch: refetchInbox} = useListConvosQuery({
status: 'request', status: 'request',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
}) })
useRefreshOnFocus(refetch) useRefreshOnFocus(refetch)
@@ -449,6 +451,7 @@ export function Header({
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const aa = useAgeAssurance()
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const leftConvos = useLeftConvos() const leftConvos = useLeftConvos()
const {isWithinSplitView} = useIsWithinSplitView() const {isWithinSplitView} = useIsWithinSplitView()
@@ -462,6 +465,7 @@ export function Header({
useListConvosQuery({ useListConvosQuery({
status: 'request', status: 'request',
readState: 'unread', readState: 'unread',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
}) })
const inboxAllConvos = const inboxAllConvos =
@@ -471,7 +475,10 @@ export function Header({
convo => convo =>
!leftConvos.includes(convo.id) && !leftConvos.includes(convo.id) &&
!convo.muted && !convo.muted &&
convo.members.every(member => member.handle !== 'missing.invalid'), convo.members.every(member => member.handle !== 'missing.invalid') &&
(ChatBskyConvoDefs.isGroupConvo(convo.kind)
? !aa.flags.groupChatDisabled
: true),
) ?? [] ) ?? []
const openChatControl = useCallback(() => { const openChatControl = useCallback(() => {
@@ -7,18 +7,22 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
import {logger} from '#/logger' import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth, useSession} from '#/state/session' import {useRequireAuth, useSession} from '#/state/session'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import { import {
type ConvoWithDetails, type ConvoWithDetails,
type GroupConvoMember, type GroupConvoMember,
} from '#/components/dms/util' } from '#/components/dms/util'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link' import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard' import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {MemberMenu} from './MemberMenu' import {MemberMenu} from './MemberMenu'
import {RemoveMemberPrompt} from './prompts'
import {StatusBadge} from './StatusBadge' import {StatusBadge} from './StatusBadge'
import {SubtleHoverWrapper} from './SubtleHoverWrapper' import {SubtleHoverWrapper} from './SubtleHoverWrapper'
@@ -45,6 +49,14 @@ export function Member({
const [queueFollow] = useProfileFollowMutationQueue(profile, 'GroupChat') const [queueFollow] = useProfileFollowMutationQueue(profile, 'GroupChat')
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const removeMemberPrompt = Prompt.usePromptControl()
const {mutate: removeMembers} = useRemoveFromGroupChat(convo.view.id, {
onError: e => {
logger.error('Failed to remove group chat member', {message: e})
Toast.show(l`Failed to remove group chat member`, {type: 'error'})
},
})
const isFollowing = !!profile.viewer?.following const isFollowing = !!profile.viewer?.following
const handleFollow = () => { const handleFollow = () => {
@@ -101,6 +113,9 @@ export function Member({
)}` )}`
: l`Added by invite link` : l`Added by invite link`
// Surface a prominent remove button to the owner for blocked members.
const showRemoveButton = isOwner && !isSelf && !!isBlockedOrBlocking(profile)
return ( return (
<SubtleHoverWrapper> <SubtleHoverWrapper>
<View style={outerStyles}> <View style={outerStyles}>
@@ -137,7 +152,17 @@ export function Member({
</ProfileCard.Header> </ProfileCard.Header>
</ProfileCard.Outer> </ProfileCard.Outer>
</ProfileCard.Link> </ProfileCard.Link>
{isSelf || isFollowing || isBlockedOrBlocking(profile) ? null : ( {showRemoveButton ? (
<Button
label={l`Remove ${displayName} from this group chat`}
size="tiny"
color="negative_subtle"
onPress={() => removeMemberPrompt.open()}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Button>
) : isSelf || isFollowing || isBlockedOrBlocking(profile) ? null : (
<SimpleInlineLinkText <SimpleInlineLinkText
label={l`Follow ${displayName}`} label={l`Follow ${displayName}`}
{...createStaticClick(handleFollow)} {...createStaticClick(handleFollow)}
@@ -147,6 +172,14 @@ export function Member({
)} )}
{statusBadge} {statusBadge}
</View> </View>
{/* Mounted outside the showRemoveButton conditional: confirming the
prompt optimistically drops this row, so gating the prompt on the
button would unmount it mid-close and race the dismiss animation. */}
<RemoveMemberPrompt
control={removeMemberPrompt}
displayName={displayName}
onConfirm={() => removeMembers({members: [profile.did]})}
/>
</SubtleHoverWrapper> </SubtleHoverWrapper>
) )
} }
@@ -22,11 +22,12 @@ import {
PersonX_Stroke2_Corner0_Rounded as PersonXIcon, PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
} from '#/components/icons/Person' } from '#/components/icons/Person'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import type * as bsky from '#/types/bsky' import type * as bsky from '#/types/bsky'
import {BlockMemberPrompt} from './prompts' import {RemoveMemberPrompt} from './prompts'
import {StatusBadge} from './StatusBadge' import {StatusBadge} from './StatusBadge'
export function MemberMenu({ export function MemberMenu({
@@ -50,6 +51,7 @@ export function MemberMenu({
const requireEmailVerification = useRequireEmailVerification() const requireEmailVerification = useRequireEmailVerification()
const blockMemberPrompt = Prompt.usePromptControl() const blockMemberPrompt = Prompt.usePromptControl()
const removeMemberPrompt = Prompt.usePromptControl()
const [menuDidOpen, setMenuDidOpen] = useState(false) const [menuDidOpen, setMenuDidOpen] = useState(false)
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, { const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
@@ -227,7 +229,7 @@ export function MemberMenu({
<Menu.Item <Menu.Item
destructive destructive
label={l`Remove ${displayName} from this group chat`} label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}> onPress={removeMemberPrompt.open}>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} /> <Menu.ItemIcon icon={ArrowBoxLeftIcon} />
<Menu.ItemText> <Menu.ItemText>
<Trans>Remove from chat</Trans> <Trans>Remove from chat</Trans>
@@ -237,9 +239,16 @@ export function MemberMenu({
</Menu.Group> </Menu.Group>
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<BlockMemberPrompt <BlockDialog
control={blockMemberPrompt} control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()} profile={profile}
onBlock={handleBlockMember}
currentConvoId={convoId}
/>
<RemoveMemberPrompt
control={removeMemberPrompt}
displayName={displayName}
onConfirm={() => removeMembers({members: [profile.did]})}
/> />
</> </>
) )
@@ -11,6 +11,7 @@ import {useNavigation} from '@react-navigation/native'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NativeStackScreenProps, type NativeStackScreenProps,
@@ -54,6 +55,7 @@ import {Loader} from '#/components/Loader'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {IS_WEB} from '#/env'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {InviteLinkDialog} from '../components/InviteLinkDialog' import {InviteLinkDialog} from '../components/InviteLinkDialog'
import {AddMembersLink} from './AddMembersLink' import {AddMembersLink} from './AddMembersLink'
@@ -86,12 +88,23 @@ type Props = NativeStackScreenProps<
> >
export function MessagesConversationSettingsScreen({route}: Props) { export function MessagesConversationSettingsScreen({route}: Props) {
const navigation = useNavigation<NavigationProp>()
const convoId = route.params.conversation const convoId = route.params.conversation
return ( return (
<Layout.Screen> <Layout.Screen>
<Layout.Header.Outer> <Layout.Header.Outer>
<Layout.Header.BackButton /> <Layout.Header.BackButton
onPress={evt => {
if (IS_WEB && !navigation.canGoBack()) {
evt.preventDefault()
navigation.navigate('MessagesConversation', {
conversation: convoId,
})
}
}}
/>
<Layout.Header.Content> <Layout.Header.Content>
<Layout.Header.TitleText> <Layout.Header.TitleText>
<Trans>Group chat settings</Trans> <Trans>Group chat settings</Trans>
@@ -214,6 +227,12 @@ function GroupSettings({
const bIsSelf = b.did === currentAccount?.did const bIsSelf = b.did === currentAccount?.did
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
// Surface blocked members to the owner so they can be removed.
if (isOwner) {
const aBlocked = !!isBlockedOrBlocking(a)
const bBlocked = !!isBlockedOrBlocking(b)
if (aBlocked !== bBlocked) return aBlocked ? -1 : 1
}
return 0 return 0
}) })
@@ -151,11 +151,13 @@ export function LeaveAndLockChatPrompt({
) )
} }
export function BlockMemberPrompt({ export function RemoveMemberPrompt({
control, control,
displayName,
onConfirm, onConfirm,
}: { }: {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
displayName: string
onConfirm: () => void onConfirm: () => void
}) { }) {
const {t: l} = useLingui() const {t: l} = useLingui()
@@ -163,11 +165,12 @@ export function BlockMemberPrompt({
return ( return (
<Prompt.Basic <Prompt.Basic
control={control} control={control}
title={l`Block account?`} title={l`Remove ${displayName}?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} description={l`They wont be able to rejoin unless you invite them again.`}
onConfirm={onConfirm} confirmButtonCta={l`Remove`}
confirmButtonCta={l`Block`}
confirmButtonColor="negative" confirmButtonColor="negative"
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/> />
) )
} }
+27 -27
View File
@@ -1,6 +1,6 @@
import {View} from 'react-native' import {View} from 'react-native'
import {ImageBackground} from 'expo-image' import {ImageBackground} from 'expo-image'
import {moderateProfile} from '@atproto/api' import {ChatBskyGroupDefs, moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -52,9 +52,7 @@ export function JoinRequest({setScreenState}: Props) {
? mobileDarkBg ? mobileDarkBg
: mobileLightBg : mobileLightBg
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval const joinLinkPreview = data?.joinLinkPreviews[0]
const requiresFollow =
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
return ( return (
<View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}> <View style={[a.util_screen_outer, a.w_full, t.atoms.bg_contrast_25]}>
@@ -69,7 +67,9 @@ export function JoinRequest({setScreenState}: Props) {
a.justify_center, a.justify_center,
a.align_center, a.align_center,
]}> ]}>
{error ? ( {error ||
(data &&
!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) ? (
<Wrapper> <Wrapper>
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" /> <ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
<Text <Text
@@ -80,20 +80,19 @@ export function JoinRequest({setScreenState}: Props) {
a.font_semi_bold, a.font_semi_bold,
t.atoms.text, t.atoms.text,
]}> ]}>
{l`This invite link has expired`} <Trans>Chat invite link no longer available</Trans>
</Text> </Text>
<ActionButtons setScreenState={setScreenState} /> <ActionButtons setScreenState={setScreenState} />
</Wrapper> </Wrapper>
) : data && moderationOpts ? ( ) : data &&
moderationOpts &&
ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview) ? (
<Wrapper> <Wrapper>
<AvatarBubbles <AvatarBubbles
profiles={[ profiles={[
data.joinLinkPreviews[0].owner, joinLinkPreview.owner,
...Array( ...Array(
Math.min( Math.min(3, Math.max(0, joinLinkPreview.memberCount - 1)),
3,
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
),
).fill(undefined), ).fill(undefined),
]} ]}
size={135} size={135}
@@ -127,9 +126,11 @@ export function JoinRequest({setScreenState}: Props) {
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
]}> ]}>
<Trans comment="The number of active group chat members out of the total number allowed."> <Trans
{data.joinLinkPreviews[0].memberCount}/ context="group-chat-member-count"
{data.joinLinkPreviews[0].memberLimit} comment="The number of active group chat members out of the total number allowed.">
{joinLinkPreview.memberCount}/
{joinLinkPreview.memberLimit}
</Trans> </Trans>
</Text> </Text>
</View> </View>
@@ -142,7 +143,7 @@ export function JoinRequest({setScreenState}: Props) {
a.font_bold, a.font_bold,
t.atoms.text, t.atoms.text,
]}> ]}>
{data.joinLinkPreviews[0].name} {joinLinkPreview.name}
</Text> </Text>
</View> </View>
<View style={[a.w_full]}> <View style={[a.w_full]}>
@@ -166,17 +167,17 @@ export function JoinRequest({setScreenState}: Props) {
<Trans comment="The owner (creator) of a group chat."> <Trans comment="The owner (creator) of a group chat.">
By{' '} By{' '}
{createSanitizedDisplayName( {createSanitizedDisplayName(
data.joinLinkPreviews[0].owner, joinLinkPreview.owner,
true, true,
moderateProfile( moderateProfile(
data.joinLinkPreviews[0].owner, joinLinkPreview.owner,
moderationOpts, moderationOpts,
).ui('displayName'), ).ui('displayName'),
)} )}
</Trans> </Trans>
</Text> </Text>
<ProfileBadges <ProfileBadges
profile={data.joinLinkPreviews[0].owner} profile={joinLinkPreview.owner}
size="sm" size="sm"
style={{marginTop: -4}} style={{marginTop: -4}}
/> />
@@ -190,7 +191,7 @@ export function JoinRequest({setScreenState}: Props) {
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
a.max_w_full, a.max_w_full,
]}> ]}>
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')} {sanitizeHandle(joinLinkPreview.owner.handle, '@')}
</Text> </Text>
</View> </View>
<Text <Text
@@ -200,17 +201,16 @@ export function JoinRequest({setScreenState}: Props) {
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
{requiresApproval {joinLinkPreview.requireApproval
? l`Sign in to request access to this group chat.` ? l`Sign in to request access to this group chat.`
: l`Sign in to accept invite.`}{' '} : l`Sign in to accept invite.`}{' '}
{requiresFollow && {joinLinkPreview.joinRule === 'followedByOwner' &&
l`Only people ${createSanitizedDisplayName( l`Only people ${createSanitizedDisplayName(
data.joinLinkPreviews[0].owner, joinLinkPreview.owner,
true, true,
moderateProfile( moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
data.joinLinkPreviews[0].owner, 'displayName',
moderationOpts, ),
).ui('displayName'),
)} follows can join.`} )} follows can join.`}
</Text> </Text>
<ActionButtons setScreenState={setScreenState} /> <ActionButtons setScreenState={setScreenState} />
+3 -3
View File
@@ -474,14 +474,14 @@ function RejectButton({
return ( return (
<Button <Button
label={l`Ignore join request`} label={l`Reject join request`}
size="small" size="small"
color="secondary" color="secondary"
disabled={disabled} disabled={disabled}
onPress={onPress}> onPress={onPress}>
<ButtonText> <ButtonText>
<Trans comment="Ignore a request to join a chat" context="button"> <Trans comment="Reject a request to join a chat" context="button">
Ignore Reject
</Trans> </Trans>
</ButtonText> </ButtonText>
</Button> </Button>
+15 -1
View File
@@ -21,6 +21,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/compon
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env' import {IS_NATIVE} from '#/env'
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -46,6 +47,7 @@ export function MessagesSettingsScreenInner({}: Props) {
const t = useTheme() const t = useTheme()
const {t: l} = useLingui() const {t: l} = useLingui()
const ax = useAnalytics() const ax = useAnalytics()
const aa = useAgeAssurance()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({ const {data: profile} = useProfileQuery({
did: currentAccount!.did, did: currentAccount!.did,
@@ -55,6 +57,7 @@ export function MessagesSettingsScreenInner({}: Props) {
const exportCarControl = Dialog.useDialogControl() const exportCarControl = Dialog.useDialogControl()
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable) const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
const groupInvitesLocked = aa.flags.groupChatDisabled
const allowMessagesFromOptions: {name: AllowIncoming; label: string}[] = [ const allowMessagesFromOptions: {name: AllowIncoming; label: string}[] = [
{ {
@@ -192,15 +195,26 @@ export function MessagesSettingsScreenInner({}: Props) {
a.leading_snug, a.leading_snug,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
]}> ]}>
{groupInvitesLocked ? (
<Trans>
Group chats are only available to users 18 and over.
</Trans>
) : (
<Trans> <Trans>
You can continue ongoing conversations regardless of which You can continue ongoing conversations regardless of which
setting you choose. setting you choose.
</Trans> </Trans>
)}
</Text> </Text>
<Toggle.Group <Toggle.Group
disabled={groupInvitesLocked}
label={l`Allow group chat invites from`} label={l`Allow group chat invites from`}
type="radio" type="radio"
values={[resolveAllowGroupInvites(profile?.associated?.chat)]} values={[
groupInvitesLocked
? 'none'
: resolveAllowGroupInvites(profile?.associated?.chat),
]}
onChange={onSelectGroupInvitesFrom}> onChange={onSelectGroupInvitesFrom}>
<View> <View>
{allowGroupInvitesFromOptions.map(option => ( {allowGroupInvitesFromOptions.map(option => (
@@ -40,11 +40,11 @@ import {getReactionInfo} from '#/components/dms/getReactionInfo'
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo' import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util' import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2' import {Bell2Off_Filled_Corner0_Rounded as BellStrokeIcon} from '#/components/icons/Bell2'
import {type Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen' import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpenIcon} from '#/components/icons/EnveopeOpen'
import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock'
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu' import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -331,7 +331,9 @@ function BaseChatItem({
i18n, i18n,
}) })
if (info) { if (info) {
lastMessage = info.message ?? lastMessage lastMessage = info.isBlockedMessage
? l`This message is hidden`
: (info.message ?? lastMessage)
lastMessageSentAt = info.sentAt lastMessageSentAt = info.sentAt
} }
} }
@@ -421,7 +423,7 @@ function BaseChatItem({
const markReadAction = { const markReadAction = {
threshold: 120, threshold: 120,
color: t.palette.primary_500, color: t.palette.primary_500,
icon: EnvelopeOpen, icon: EnvelopeOpenIcon,
action: () => { action: () => {
markAsRead({ markAsRead({
convoId: convo.view.id, convoId: convo.view.id,
@@ -432,7 +434,7 @@ function BaseChatItem({
const deleteAction = { const deleteAction = {
threshold: 225, threshold: 225,
color: t.palette.negative_500, color: t.palette.negative_500,
icon: Trash_Stroke2_Corner0_Rounded, icon: TrashIcon,
action: () => { action: () => {
leaveConvoControl.open() leaveConvoControl.open()
}, },
@@ -507,7 +509,7 @@ function BaseChatItem({
a.px_lg, a.px_lg,
a.py_md, a.py_md,
a.gap_md, a.gap_md,
isWithinLeftPanel && a.rounded_sm, isWithinLeftPanel && [a.rounded_sm, a.mt_2xs],
{ {
backgroundColor: hasUnread backgroundColor: hasUnread
? t.palette.primary_25 ? t.palette.primary_25
@@ -572,7 +574,7 @@ function BaseChatItem({
web({whiteSpace: 'preserve nowrap'}), web({whiteSpace: 'preserve nowrap'}),
]}> ]}>
{' '} {' '}
<BellStroke <BellStrokeIcon
size="xs" size="xs"
style={[t.atoms.text_contrast_medium]} style={[t.atoms.text_contrast_medium]}
/> />
@@ -516,7 +516,8 @@ export function InviteLinkDialog({
</View> </View>
} }
label={l`Group chat invite link dialog`} label={l`Group chat invite link dialog`}
style={web({maxWidth: 400})}> style={web({maxWidth: 400})}
contentContainerStyle={web(a.pt_0)}>
{content} {content}
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
@@ -4,6 +4,7 @@ import {
AppBskyFeedPost, AppBskyFeedPost,
AppBskyRichtextFacet, AppBskyRichtextFacet,
AtUri, AtUri,
ChatBskyGroupDefs,
moderatePost, moderatePost,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
@@ -30,6 +31,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as ChatInvite from '#/components/dms/ChatInvite' import * as ChatInvite from '#/components/dms/ChatInvite'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview' import * as MediaPreview from '#/components/MediaPreview'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
@@ -52,12 +54,12 @@ export function useMessageEmbed() {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed const embedFromParams = route.params.embed
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>( const [embed, setEmbed] = useState<MessageEmbedState | undefined>(
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined, embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
) )
if (embedFromParams && embed?.type !== 'post') { if (embedFromParams && embed?.type !== 'post') {
setEmbedState({type: 'post', uri: embedFromParams}) setEmbed({type: 'post', uri: embedFromParams})
} }
return { return {
@@ -68,7 +70,7 @@ export function useMessageEmbed() {
// Only the post embed is reflected in the route param (used by the // Only the post embed is reflected in the route param (used by the
// share-to-DM intent flow); invites are local-only. // share-to-DM intent flow); invites are local-only.
navigation.setParams({embed: ''}) navigation.setParams({embed: ''})
setEmbedState(undefined) setEmbed(undefined)
return return
} }
@@ -77,7 +79,7 @@ export function useMessageEmbed() {
if (isBskyChatInviteUrl(embedUrl)) { if (isBskyChatInviteUrl(embedUrl)) {
const code = getChatInviteCodeFromUrl(embedUrl) const code = getChatInviteCodeFromUrl(embedUrl)
if (code) { if (code) {
setEmbedState({type: 'invite', code}) setEmbed({type: 'invite', code})
} }
return return
} }
@@ -86,7 +88,7 @@ export function useMessageEmbed() {
const url = convertBskyAppUrlIfNeeded(embedUrl) const url = convertBskyAppUrlIfNeeded(embedUrl)
const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
setEmbedState({type: 'post', uri}) setEmbed({type: 'post', uri})
} }
}, },
[embedFromParams, navigation], [embedFromParams, navigation],
@@ -314,11 +316,19 @@ function MessageInputInviteEmbedBody() {
) )
} }
if (!preview) { if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
return ( return (
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}> <View
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}> style={[
<Trans>Could not load invite</Trans> {minHeight: 64},
a.flex_row,
a.gap_xs,
a.justify_center,
a.align_center,
]}>
<WarningIcon size="md" fill={t.atoms.text_contrast_medium.color} />
<Text style={[a.text_sm, a.font_medium, t.atoms.text_contrast_medium]}>
<Trans>Chat invite link no longer available</Trans>
</Text> </Text>
</View> </View>
) )
@@ -94,7 +94,8 @@ export function MessagesListGroupInfoPanel({
/> />
{convo.details.name ? ( {convo.details.name ? (
<Text <Text
style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}> style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}
emoji>
{convo.details.name} {convo.details.name}
</Text> </Text>
) : null} ) : null}
@@ -107,7 +108,8 @@ export function MessagesListGroupInfoPanel({
a.text_sm, a.text_sm,
t.atoms.text_contrast_high, t.atoms.text_contrast_high,
showButtons ? null : a.mb_4xl, showButtons ? null : a.mb_4xl,
]}> ]}
emoji>
{names} {names}
</Text> </Text>
) : null} ) : null}
@@ -60,7 +60,8 @@ export function MessagesListInfoPanel({
]}> ]}>
<Text <Text
style={[a.text_2xl, a.font_bold, a.text_center, a.flex_shrink]} style={[a.text_2xl, a.font_bold, a.text_center, a.flex_shrink]}
numberOfLines={1}> numberOfLines={1}
emoji>
{displayName} {displayName}
</Text> </Text>
<ProfileBadges profile={profile} size="lg" /> <ProfileBadges profile={profile} size="lg" />
@@ -76,7 +76,14 @@ export function OutgoingRequestListItem({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
/> />
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.pb_2xs]}> <View
style={[
a.w_full,
a.flex_row,
a.align_center,
a.gap_xs,
a.pb_2xs,
]}>
<View style={[a.flex_shrink]}> <View style={[a.flex_shrink]}>
<Text <Text
emoji emoji
@@ -85,8 +92,8 @@ export function OutgoingRequestListItem({
{convoView.name} {convoView.name}
</Text> </Text>
</View> </View>
<View style={[a.pl_xs]}> {convoView.viewer?.requestedAt ? (
<TimeElapsed timestamp={convoView.requestedAt}> <TimeElapsed timestamp={convoView.viewer.requestedAt}>
{({timeElapsed}) => ( {({timeElapsed}) => (
<Text <Text
style={[ style={[
@@ -98,7 +105,7 @@ export function OutgoingRequestListItem({
</Text> </Text>
)} )}
</TimeElapsed> </TimeElapsed>
</View> ) : null}
</View> </View>
<Text <Text
numberOfLines={1} numberOfLines={1}
+1 -1
View File
@@ -24,9 +24,9 @@ import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate' import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {MIN_ACCESS_AGE} from '#/ageAssurance/const'
import { import {
isUnderAge, isUnderAge,
MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback, useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
+7 -2
View File
@@ -1,10 +1,11 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {preferencesQueryKey} from '#/state/queries/preferences' import {preferencesQueryKey} from '#/state/queries/preferences'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance' import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
import {isUnderAge, maybeRestrictChatSettings} from '#/ageAssurance/util' import {isUnderAge} from '#/ageAssurance/util'
import {IS_DEV} from '#/env' import {IS_DEV} from '#/env'
import {account} from '#/storage' import {account} from '#/storage'
@@ -66,7 +67,11 @@ export function useBirthdateMutation() {
}) })
if (isUnderAge(birthDate.toISOString(), 18)) { if (isUnderAge(birthDate.toISOString(), 18)) {
maybeRestrictChatSettings({agent}) await restrictChatSettings({
agent,
restrictIncoming: true,
restrictGroupInvites: true,
})
} }
/** /**
+1 -1
View File
@@ -2,7 +2,7 @@ import {createContext, useContext, useMemo} from 'react'
import {AtpAgent, type ModerationOpts} from '@atproto/api' import {AtpAgent, type ModerationOpts} from '@atproto/api'
import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences' import {useHiddenPosts, useLabelDefinitions} from '#/state/preferences'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {usePreferencesQuery} from '../queries/preferences' import {usePreferencesQuery} from '../queries/preferences'
+60 -5
View File
@@ -1,17 +1,48 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import { import {
type $Typed,
AtpAgent, AtpAgent,
type ChatBskyGroupDefs, ChatBskyGroupDefs,
type ChatBskyGroupGetJoinLinkPreviews, type ChatBskyGroupGetJoinLinkPreviews,
} from '@atproto/api' } from '@atproto/api'
import {useQuery, useQueryClient} from '@tanstack/react-query' import {type QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants' import {CHAT_SERVICE, DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {STALE} from '#/state/queries/index' import {STALE} from '#/state/queries/index'
import {createQueryKey} from '#/state/queries/util' import {createQueryKey, type StructuredQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
/**
* The three preview shapes we currently support. Excludes the `{$type: string}`
* open-union fallback for unrecognized future variants - use
* `ChatInvitePreview` for that.
*/
export type KnownChatInvitePreview =
| $Typed<ChatBskyGroupDefs.JoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.DisabledJoinLinkPreviewView>
| $Typed<ChatBskyGroupDefs.InvalidJoinLinkPreviewView>
/**
* The full open-union shape, including the `{$type: string}` fallback for
* future variants.
*/
export type ChatInvitePreview = KnownChatInvitePreview | {$type: string}
/**
* Narrows a preview to one of the three known variants, filtering out the
* `{$type: string}` open-union fallback for unrecognized future shapes.
*/
export function isKnownJoinLinkPreview(
preview: unknown,
): preview is KnownChatInvitePreview {
return (
ChatBskyGroupDefs.isJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(preview) ||
ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(preview)
)
}
const joinLinkPreviewQueryKeyRoot = 'join-link-preview' const joinLinkPreviewQueryKeyRoot = 'join-link-preview'
export const createJoinLinkPreviewQueryKey = (args: { export const createJoinLinkPreviewQueryKey = (args: {
@@ -22,6 +53,29 @@ export const createJoinLinkPreviewQueryKey = (args: {
persistedVersion: 1, persistedVersion: 1,
}) })
/**
* Invalidate any join link preview queries whose `codes` include the given
* code. Use this when a link's state changes (e.g. it's disabled) so cached
* previews refetch and reflect the new state.
*/
export function invalidateJoinLinkPreviewsForCode(
queryClient: QueryClient,
code: string,
) {
return queryClient.invalidateQueries({
predicate: query => {
const [root, args] = query.queryKey as Partial<
StructuredQueryKey<{codes?: string[]}>
>
return (
root === joinLinkPreviewQueryKeyRoot &&
Array.isArray(args?.codes) &&
args.codes.includes(code)
)
},
})
}
async function fetchJoinLinkPreviews({ async function fetchJoinLinkPreviews({
agent, agent,
codes, codes,
@@ -104,7 +158,7 @@ export function useGetJoinLinkPreview() {
}: { }: {
code: string code: string
hasSession: boolean hasSession: boolean
}): Promise<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => { }): Promise<KnownChatInvitePreview | undefined> => {
try { try {
const data = await queryClient.fetchQuery({ const data = await queryClient.fetchQuery({
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}), queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
@@ -112,7 +166,8 @@ export function useGetJoinLinkPreview() {
fetchJoinLinkPreviews({agent, codes: [code], hasSession}), fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
staleTime: STALE.SECONDS.FIFTEEN, staleTime: STALE.SECONDS.FIFTEEN,
}) })
return data.joinLinkPreviews[0] const found = data.joinLinkPreviews[0]
return isKnownJoinLinkPreview(found) ? found : undefined
} catch (error) { } catch (error) {
logger.error('Failed to fetch join link preview', {safeMessage: error}) logger.error('Failed to fetch join link preview', {safeMessage: error})
return undefined return undefined
@@ -6,6 +6,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import { import {
rollbackConvoOptimistic, rollbackConvoOptimistic,
@@ -59,6 +60,7 @@ export function useDisableJoinLink(
} }
}) })
} }
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (e, _variables, context) => { onError: (e, _variables, context) => {
@@ -3,6 +3,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants' import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger' import {logger} from '#/logger'
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import { import {
rollbackConvoOptimistic, rollbackConvoOptimistic,
@@ -56,6 +57,7 @@ export function useEnableJoinLink(
} }
}) })
} }
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
onSuccess?.(data) onSuccess?.(data)
}, },
onError: (e, _variables, context) => { onError: (e, _variables, context) => {
@@ -20,6 +20,8 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {parseConvoView} from '#/components/dms/util' import {parseConvoView} from '#/components/dms/util'
import {useAgeAssurance} from '#/ageAssurance'
import {type AgeAssuranceFlags} from '#/ageAssurance/types'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation' import {RQKEY as CONVO_KEY} from './conversation'
import {useLeftConvos} from './leave-conversation' import {useLeftConvos} from './leave-conversation'
@@ -122,10 +124,12 @@ export function ListConvosProviderInner({
}: { }: {
children: React.ReactNode children: React.ReactNode
}) { }) {
const aa = useAgeAssurance()
const {refetch, data} = useListConvosQuery({ const {refetch, data} = useListConvosQuery({
readState: 'unread', readState: 'unread',
limit: UNREAD_LIMIT, limit: UNREAD_LIMIT,
lockStatus: 'unlocked', lockStatus: 'unlocked',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
}) })
const messagesBus = useMessagesEventBus() const messagesBus = useMessagesEventBus()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -676,6 +680,7 @@ export function useUnreadMessageCount() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {accepted, request} = useListConvos() const {accepted, request} = useListConvos()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const aa = useAgeAssurance()
return useMemo<{ return useMemo<{
count: number count: number
@@ -687,12 +692,14 @@ export function useUnreadMessageCount() {
currentAccount?.did, currentAccount?.did,
currentConvoId, currentConvoId,
moderationOpts, moderationOpts,
aa.flags,
) )
const requestCount = calculateCount( const requestCount = calculateCount(
request, request,
currentAccount?.did, currentAccount?.did,
currentConvoId, currentConvoId,
moderationOpts, moderationOpts,
aa.flags,
) )
if (acceptedCount > 0) { if (acceptedCount > 0) {
const total = acceptedCount + Math.min(requestCount, 1) const total = acceptedCount + Math.min(requestCount, 1)
@@ -723,6 +730,7 @@ function calculateCount(
currentAccountDid: string | undefined, currentAccountDid: string | undefined,
currentConvoId: string | undefined, currentConvoId: string | undefined,
moderationOpts: ModerationOpts | undefined, moderationOpts: ModerationOpts | undefined,
flags: AgeAssuranceFlags,
) { ) {
return ( return (
convos convos
@@ -732,6 +740,8 @@ function calculateCount(
if (!convo || !moderationOpts) return acc if (!convo || !moderationOpts) return acc
if (convo.kind === 'group' && flags.groupChatDisabled) return acc
const shouldIgnore = const shouldIgnore =
convo.view.muted || convo.view.muted ||
!convo.primaryMember || !convo.primaryMember ||
@@ -0,0 +1,39 @@
import {useInfiniteQuery} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {createQueryKey} from '#/state/queries/util'
import {useAgent} from '#/state/session'
const listMutualGroupsQueryKeyRoot = 'list-mutual-groups'
export const createListMutualGroupsQueryKey = (args: {subject: string}) =>
createQueryKey(listMutualGroupsQueryKeyRoot, args)
export function useListMutualGroupsQuery({
subject,
enabled,
limit = 20,
}: {
subject: string | undefined
enabled?: boolean
limit?: number
}) {
const agent = useAgent()
const isEnabled = enabled !== false && !!subject
return useInfiniteQuery({
gcTime: 0,
staleTime: 0,
enabled: isEnabled,
queryKey: createListMutualGroupsQueryKey({subject: subject ?? ''}),
queryFn: async ({pageParam}) => {
const {data} = await agent.chat.bsky.group.listMutualGroups(
{subject: subject!, cursor: pageParam, limit},
{headers: DM_SERVICE_HEADERS},
)
return data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: page => page.cursor,
})
}
@@ -3,23 +3,71 @@ import {type ChatBskyActorDeclaration} from '@atproto/api'
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {logger} from '#/logger' import {logger} from '#/logger'
import {setOtherRequiredDataActorDeclarationCache} from '#/ageAssurance/data' import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
setOtherRequiredDataActorDeclarationCache,
} from '#/ageAssurance/data'
/** /**
* Helper to update the chat settings record. * Updates the chat actor declaration record to restrict who can contact the
* user. Both restrictions write to the same record (`rkey: 'self'`), so this
* is a single helper to avoid two concurrent `putRecord` calls racing each
* other and clobbering one another's changes.
*
* - `restrictIncoming`: sets `allowIncoming: 'none'` (used when a user isn't
* age-assured).
* - `restrictGroupInvites`: sets `allowGroupInvites: 'none'` (used for under-18
* users, who per spec cannot participate in group chats).
*
* Dimensions that aren't being restricted preserve their cached value, falling
* back to the lexicon defaults when the cache is empty.
*/ */
export async function restrictChatSettings({ export async function restrictChatSettings({
agent, agent,
did, restrictIncoming = false,
restrictGroupInvites = false,
}: { }: {
agent: AtpAgent agent: AtpAgent
did: string restrictIncoming?: boolean
restrictGroupInvites?: boolean
}): Promise<void> { }): Promise<void> {
try { const did = getDidFromAgentSession(agent)
if (!did) return
const cached = getOtherRequiredDataFromCache({did})?.actorDeclaration
// When the cache is empty we fall back to defaults for any dimension we're
// not explicitly restricting, which could drop/downgrade a value the user
// has actually set server-side. The cache should be hydrated by
// prefetchOtherRequiredData before any of these paths fire, so log if that
// assumption ever breaks. (Restricting both dimensions is unaffected, so
// don't warn for the common signup/birthdate-change case.)
if (!cached && (!restrictIncoming || !restrictGroupInvites)) {
logger.warn(
`restrictChatSettings: cache miss, falling back to defaults for unrestricted dimensions`,
)
}
const record: ChatBskyActorDeclaration.Main = { const record: ChatBskyActorDeclaration.Main = {
$type: 'chat.bsky.actor.declaration', $type: 'chat.bsky.actor.declaration',
allowIncoming: 'none', allowIncoming: restrictIncoming
? 'none'
: (cached?.allowIncoming ?? 'following'),
allowGroupInvites: restrictGroupInvites
? 'none'
: cached?.allowGroupInvites,
} }
// Nothing to do if the record already reflects the desired restrictions.
if (
cached?.allowIncoming === record.allowIncoming &&
cached?.allowGroupInvites === record.allowGroupInvites
) {
return
}
try {
await networkRetry(3, () => await networkRetry(3, () =>
agent.com.atproto.repo.putRecord({ agent.com.atproto.repo.putRecord({
repo: did, repo: did,
+14 -1
View File
@@ -1,9 +1,22 @@
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation' import {DEFAULT_LABEL_SETTINGS} from '@atproto/api'
import { import {
type ThreadViewPreferences, type ThreadViewPreferences,
type UsePreferencesQueryResponse, type UsePreferencesQueryResponse,
} from '#/state/queries/preferences/types' } from '#/state/queries/preferences/types'
/**
* More strict than our default settings for logged in users.
*
* Defined here rather than in `./moderation` to avoid a module-init cycle:
* `moderation` imports `./index`, which re-exports this file, so reading the
* value from `moderation` at init time lands in its temporal dead zone.
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
Object.fromEntries(
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
)
export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] = export const DEFAULT_HOME_FEED_PREFS: UsePreferencesQueryResponse['feedViewPrefs'] =
{ {
hideReplies: false, hideReplies: false,
+1 -13
View File
@@ -1,22 +1,10 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import { import {BskyAgent, interpretLabelValueDefinitions} from '@atproto/api'
BskyAgent,
DEFAULT_LABEL_SETTINGS,
interpretLabelValueDefinitions,
} from '@atproto/api'
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
import {useLabelersDetailedInfoQuery} from '../labeler' import {useLabelersDetailedInfoQuery} from '../labeler'
import {usePreferencesQuery} from './index' import {usePreferencesQuery} from './index'
/**
* More strict than our default settings for logged in users.
*/
export const DEFAULT_LOGGED_OUT_LABEL_PREFERENCES: typeof DEFAULT_LABEL_SETTINGS =
Object.fromEntries(
Object.entries(DEFAULT_LABEL_SETTINGS).map(([key, _pref]) => [key, 'hide']),
)
export function useMyLabelersQuery({ export function useMyLabelersQuery({
excludeNonConfigurableLabelers = false, excludeNonConfigurableLabelers = false,
}: { }: {
+8 -5
View File
@@ -29,7 +29,6 @@ import {
setCreatedAtForDid, setCreatedAtForDid,
} from '#/ageAssurance/data' } from '#/ageAssurance/data'
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state' import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {features} from '#/analytics' import {features} from '#/analytics'
import {emitNetworkConfirmed, emitNetworkLost} from '../events' import {emitNetworkConfirmed, emitNetworkLost} from '../events'
import {addSessionErrorLog} from './logging' import {addSessionErrorLog} from './logging'
@@ -218,10 +217,14 @@ export async function createAgentAndCreateAccount(
throw e throw e
}), }),
// wait for AA data to load first, then check state // wait for AA data to load first, then check state
aa.then(async () => { aa.then(() => {
const {state} = unsafeGetAndComputeAgeAssurance({did: account.did}) const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
if (state.access !== AgeAssuranceAccess.Full) { if (flags?.chatDisabled || flags?.groupChatDisabled) {
restrictChatSettings({agent, did: account.did}) void restrictChatSettings({
agent,
restrictIncoming: flags.chatDisabled,
restrictGroupInvites: flags.groupChatDisabled,
})
} }
}), }),
]).then(promises => { ]).then(promises => {
+35 -2
View File
@@ -52,6 +52,7 @@ import {
type AppBskyUnspeccedGetPostThreadV2, type AppBskyUnspeccedGetPostThreadV2,
AtUri, AtUri,
type BskyAgent, type BskyAgent,
ChatBskyGroupDefs,
type RichText, type RichText,
} from '@atproto/api' } from '@atproto/api'
import {plural} from '@lingui/core/macro' import {plural} from '@lingui/core/macro'
@@ -134,7 +135,14 @@ import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast' import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {
IS_ANDROID,
IS_IOS,
IS_LIQUID_GLASS,
IS_NATIVE,
IS_WEB,
IS_WEB_SAFARI,
} from '#/env'
import {type Gif} from '#/features/gifPicker/types' import {type Gif} from '#/features/gifPicker/types'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import { import {
@@ -879,7 +887,9 @@ export const ComposePost = ({
})), })),
}) })
const hasUnavailableChatInvite = linkQueries.some( const hasUnavailableChatInvite = linkQueries.some(
q => q.data?.type === 'chat-invite' && !q.data.view, q =>
q.data?.type === 'chat-invite' &&
!ChatBskyGroupDefs.isJoinLinkPreviewView(q.data.view),
) )
const canPost = const canPost =
@@ -1219,6 +1229,24 @@ export const ComposePost = ({
} }
}, [composerState]) }, [composerState])
useEffect(() => {
// Safari ignores `overscroll-behavior`, so horizontal trackpad swipes over
// the composer (e.g. on a quote post) can still trigger the browser's
// back/forward navigation gesture. Suppress predominantly-horizontal wheel
// events so the history-nav gesture never fires. Chrome and Firefox are
// covered by the `overscrollBehaviorX: 'contain'` style on the ScrollView.
if (!IS_WEB_SAFARI) return
const el =
scrollViewRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return
e.preventDefault()
}
el.addEventListener('wheel', onWheel, {passive: false})
return () => el.removeEventListener('wheel', onWheel)
}, [scrollViewRef])
const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined const isLastThreadedPost = thread.posts.length > 1 && nextPost === undefined
const { const {
scrollHandler, scrollHandler,
@@ -1324,6 +1352,11 @@ export const ComposePost = ({
web({ web({
scrollbarGutter: 'stable', scrollbarGutter: 'stable',
scrollbarColor: `${t.palette.contrast_200} transparent`, scrollbarColor: `${t.palette.contrast_200} transparent`,
// Prevent horizontal trackpad swipes from triggering the
// browser's back/forward overscroll-navigation gesture.
// Handles Chrome and Firefox; Safari is handled separately
// via a wheel listener since it ignores overscroll-behavior.
overscrollBehaviorX: 'contain',
}), }),
]} ]}
keyboardShouldPersistTaps="always" keyboardShouldPersistTaps="always"
+65 -94
View File
@@ -1,8 +1,6 @@
import {memo, useCallback, useMemo} from 'react' import {memo, useCallback, useMemo} from 'react'
import {type AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -46,6 +44,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {StarterPack} from '#/components/icons/StarterPack' import {StarterPack} from '#/components/icons/StarterPack'
import * as Menu from '#/components/Menu' import * as Menu from '#/components/Menu'
import {BlockDialog} from '#/components/moderation/BlockDialog'
import { import {
ReportDialog, ReportDialog,
useReportDialogControl, useReportDialogControl,
@@ -72,7 +71,7 @@ let ProfileMenu = ({
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const ax = useAnalytics() const ax = useAnalytics()
const {_} = useLingui() const {t: l} = useLingui()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const {openModal} = useModalControls() const {openModal} = useModalControls()
const reportDialogControl = useReportDialogControl() const reportDialogControl = useReportDialogControl()
@@ -116,7 +115,7 @@ let ProfileMenu = ({
}, [currentAccount, profile]) }, [currentAccount, profile])
const invalidateProfileQuery = useCallback(() => { const invalidateProfileQuery = useCallback(() => {
queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: profileQueryKey(profile.did), queryKey: profileQueryKey(profile.did),
}) })
}, [queryClient, profile.did]) }, [queryClient, profile.did])
@@ -124,10 +123,10 @@ let ProfileMenu = ({
const onPressAddToStarterPacks = useCallback(() => { const onPressAddToStarterPacks = useCallback(() => {
ax.metric('profile:addToStarterPack', {}) ax.metric('profile:addToStarterPack', {})
addToStarterPacksDialogControl.open() addToStarterPacksDialogControl.open()
}, [addToStarterPacksDialogControl]) }, [addToStarterPacksDialogControl, ax])
const onPressShare = useCallback(() => { const onPressShare = useCallback(() => {
shareUrl(toShareUrl(makeProfileLink(profile))) void shareUrl(toShareUrl(makeProfileLink(profile)))
}, [profile]) }, [profile])
const onPressAddRemoveLists = useCallback(() => { const onPressAddRemoveLists = useCallback(() => {
@@ -145,11 +144,12 @@ let ProfileMenu = ({
if (profile.viewer?.muted) { if (profile.viewer?.muted) {
try { try {
await queueUnmute() await queueUnmute()
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) Toast.show(l({message: 'Account unmuted', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to unmute account', {message: e}) ax.logger.error('Failed to unmute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
@@ -157,27 +157,29 @@ let ProfileMenu = ({
} else { } else {
try { try {
await queueMute() await queueMute()
Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) Toast.show(l({message: 'Account muted', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to mute account', {message: e}) ax.logger.error('Failed to mute account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
} }
} }
}, [ax, profile.viewer?.muted, queueUnmute, _, queueMute]) }, [ax, profile.viewer?.muted, queueUnmute, l, queueMute])
const blockAccount = useCallback(async () => { const blockAccount = useCallback(async () => {
if (profile.viewer?.blocking) { if (profile.viewer?.blocking) {
try { try {
await queueUnblock() await queueUnblock()
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) Toast.show(l({message: 'Account unblocked', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to unblock account', {message: e}) ax.logger.error('Failed to unblock account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
@@ -185,56 +187,59 @@ let ProfileMenu = ({
} else { } else {
try { try {
await queueBlock() await queueBlock()
Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) Toast.show(l({message: 'Account blocked', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to block account', {message: e}) ax.logger.error('Failed to block account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
} }
} }
}, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock]) }, [ax, profile.viewer?.blocking, l, queueUnblock, queueBlock])
const onPressFollowAccount = useCallback(async () => { const onPressFollowAccount = useCallback(async () => {
try { try {
await queueFollow() await queueFollow()
Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) Toast.show(l({message: 'Account followed', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to follow account', {message: e}) ax.logger.error('Failed to follow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
} }
}, [_, ax, queueFollow]) }, [l, ax, queueFollow])
const onPressUnfollowAccount = useCallback(async () => { const onPressUnfollowAccount = useCallback(async () => {
try { try {
await queueUnfollow() await queueUnfollow()
Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) Toast.show(l({message: 'Account unfollowed', context: 'toast'}))
} catch (e: any) { } catch (err) {
const e = err as Error
if (e?.name !== 'AbortError') { if (e?.name !== 'AbortError') {
ax.logger.error('Failed to unfollow account', {message: e}) ax.logger.error('Failed to unfollow account', {message: e})
Toast.show(_(msg`There was an issue! ${e.toString()}`), { Toast.show(l`There was an issue! ${e.toString()}`, {
type: 'error', type: 'error',
}) })
} }
} }
}, [_, ax, queueUnfollow]) }, [l, ax, queueUnfollow])
const onPressReportAccount = useCallback(() => { const onPressReportAccount = useCallback(() => {
reportDialogControl.open() reportDialogControl.open()
}, [reportDialogControl]) }, [reportDialogControl])
const onPressShareATUri = useCallback(() => { const onPressShareATUri = useCallback(() => {
shareText(`at://${profile.did}`) void shareText(`at://${profile.did}`)
}, [profile.did]) }, [profile.did])
const onPressShareDID = useCallback(() => { const onPressShareDID = useCallback(() => {
shareText(profile.did) void shareText(profile.did)
}, [profile.did]) }, [profile.did])
const onPressSearch = useCallback(() => { const onPressSearch = useCallback(() => {
@@ -251,14 +256,14 @@ let ProfileMenu = ({
return ( return (
<EventStopper onKeyDown={false}> <EventStopper onKeyDown={false}>
<Menu.Root> <Menu.Root>
<Menu.Trigger label={_(msg`More options`)}> <Menu.Trigger label={l`More options`}>
{({props}) => { {({props}) => {
return ( return (
<> <>
<Button <Button
{...props} {...props}
testID="profileHeaderDropdownBtn" testID="profileHeaderDropdownBtn"
label={_(msg`More options`)} label={l`More options`}
hitSlop={HITSLOP_20} hitSlop={HITSLOP_20}
variant="solid" variant="solid"
color="secondary" color="secondary"
@@ -267,7 +272,6 @@ let ProfileMenu = ({
{statusNudgeActive && <Gradient style={[a.rounded_full]} />} {statusNudgeActive && <Gradient style={[a.rounded_full]} />}
<ButtonIcon icon={Ellipsis} size="sm" /> <ButtonIcon icon={Ellipsis} size="sm" />
</Button> </Button>
{statusNudgeActive && <Dot top={1} right={1} />} {statusNudgeActive && <Dot top={1} right={1} />}
</> </>
) )
@@ -278,9 +282,7 @@ let ProfileMenu = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="profileHeaderDropdownShareBtn" testID="profileHeaderDropdownShareBtn"
label={ label={IS_WEB ? l`Copy link to profile` : l`Share via...`}
IS_WEB ? _(msg`Copy link to profile`) : _(msg`Share via...`)
}
onPress={() => { onPress={() => {
if (showLoggedOutWarning) { if (showLoggedOutWarning) {
loggedOutWarningPromptControl.open() loggedOutWarningPromptControl.open()
@@ -301,7 +303,7 @@ let ProfileMenu = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="profileHeaderDropdownSearchBtn" testID="profileHeaderDropdownSearchBtn"
label={_(msg`Search posts`)} label={l`Search posts`}
onPress={onPressSearch}> onPress={onPressSearch}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Search posts</Trans> <Trans>Search posts</Trans>
@@ -320,14 +322,12 @@ let ProfileMenu = ({
<Menu.Item <Menu.Item
testID="profileHeaderDropdownFollowBtn" testID="profileHeaderDropdownFollowBtn"
label={ label={
isFollowing isFollowing ? l`Unfollow account` : l`Follow account`
? _(msg`Unfollow account`)
: _(msg`Follow account`)
} }
onPress={ onPress={
isFollowing isFollowing
? onPressUnfollowAccount ? () => void onPressUnfollowAccount()
: onPressFollowAccount : () => void onPressFollowAccount()
}> }>
<Menu.ItemText> <Menu.ItemText>
{isFollowing ? ( {isFollowing ? (
@@ -343,7 +343,7 @@ let ProfileMenu = ({
)} )}
<Menu.Item <Menu.Item
testID="profileHeaderDropdownStarterPackAddRemoveBtn" testID="profileHeaderDropdownStarterPackAddRemoveBtn"
label={_(msg`Add to starter packs`)} label={l`Add to starter packs`}
onPress={onPressAddToStarterPacks}> onPress={onPressAddToStarterPacks}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Add to starter packs</Trans> <Trans>Add to starter packs</Trans>
@@ -352,7 +352,7 @@ let ProfileMenu = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="profileHeaderDropdownListAddRemoveBtn" testID="profileHeaderDropdownListAddRemoveBtn"
label={_(msg`Add to lists`)} label={l`Add to lists`}
onPress={onPressAddRemoveLists}> onPress={onPressAddRemoveLists}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Add to lists</Trans> <Trans>Add to lists</Trans>
@@ -364,10 +364,10 @@ let ProfileMenu = ({
testID="profileHeaderDropdownListAddRemoveBtn" testID="profileHeaderDropdownListAddRemoveBtn"
label={ label={
status.isDisabled status.isDisabled
? _(msg`Go live (disabled)`) ? l`Go live (disabled)`
: status.isActive : status.isActive
? _(msg`Edit live status`) ? l`Edit live status`
: _(msg`Go live`) : l`Go live`
} }
onPress={() => { onPress={() => {
if (status.isDisabled) { if (status.isDisabled) {
@@ -418,7 +418,7 @@ let ProfileMenu = ({
(verification.viewer.hasIssuedVerification ? ( (verification.viewer.hasIssuedVerification ? (
<Menu.Item <Menu.Item
testID="profileHeaderDropdownVerificationRemoveButton" testID="profileHeaderDropdownVerificationRemoveButton"
label={_(msg`Remove verification`)} label={l`Remove verification`}
onPress={() => verificationRemovePromptControl.open()}> onPress={() => verificationRemovePromptControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Remove verification</Trans> <Trans>Remove verification</Trans>
@@ -428,7 +428,7 @@ let ProfileMenu = ({
) : ( ) : (
<Menu.Item <Menu.Item
testID="profileHeaderDropdownVerificationCreateButton" testID="profileHeaderDropdownVerificationCreateButton"
label={_(msg`Verify account`)} label={l`Verify account`}
onPress={() => verificationCreatePromptControl.open()}> onPress={() => verificationCreatePromptControl.open()}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Verify account</Trans> <Trans>Verify account</Trans>
@@ -444,10 +444,10 @@ let ProfileMenu = ({
testID="profileHeaderDropdownMuteBtn" testID="profileHeaderDropdownMuteBtn"
label={ label={
profile.viewer?.muted profile.viewer?.muted
? _(msg`Unmute account`) ? l`Unmute account`
: _(msg`Mute account`) : l`Mute account`
} }
onPress={onPressMuteAccount}> onPress={() => void onPressMuteAccount()}>
<Menu.ItemText> <Menu.ItemText>
{profile.viewer?.muted ? ( {profile.viewer?.muted ? (
<Trans>Unmute account</Trans> <Trans>Unmute account</Trans>
@@ -464,9 +464,9 @@ let ProfileMenu = ({
<Menu.Item <Menu.Item
testID="profileHeaderDropdownBlockBtn" testID="profileHeaderDropdownBlockBtn"
label={ label={
profile.viewer profile.viewer?.blocking
? _(msg`Unblock account`) ? l`Unblock account`
: _(msg`Block account`) : l`Block account`
} }
onPress={() => blockPromptControl.open()}> onPress={() => blockPromptControl.open()}>
<Menu.ItemText> <Menu.ItemText>
@@ -485,7 +485,7 @@ let ProfileMenu = ({
)} )}
<Menu.Item <Menu.Item
testID="profileHeaderDropdownReportBtn" testID="profileHeaderDropdownReportBtn"
label={_(msg`Report account`)} label={l`Report account`}
onPress={onPressReportAccount}> onPress={onPressReportAccount}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Report account</Trans> <Trans>Report account</Trans>
@@ -503,7 +503,7 @@ let ProfileMenu = ({
<Menu.Group> <Menu.Group>
<Menu.Item <Menu.Item
testID="profileHeaderDropdownShareATURIBtn" testID="profileHeaderDropdownShareATURIBtn"
label={_(msg`Copy at:// URI`)} label={l`Copy at:// URI`}
onPress={onPressShareATUri}> onPress={onPressShareATUri}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy at:// URI</Trans> <Trans>Copy at:// URI</Trans>
@@ -512,7 +512,7 @@ let ProfileMenu = ({
</Menu.Item> </Menu.Item>
<Menu.Item <Menu.Item
testID="profileHeaderDropdownShareDIDBtn" testID="profileHeaderDropdownShareDIDBtn"
label={_(msg`Copy DID`)} label={l`Copy DID`}
onPress={onPressShareDID}> onPress={onPressShareDID}>
<Menu.ItemText> <Menu.ItemText>
<Trans>Copy DID</Trans> <Trans>Copy DID</Trans>
@@ -524,12 +524,10 @@ let ProfileMenu = ({
) : null} ) : null}
</Menu.Outer> </Menu.Outer>
</Menu.Root> </Menu.Root>
<StarterPackDialog <StarterPackDialog
control={addToStarterPacksDialogControl} control={addToStarterPacksDialogControl}
targetDid={profile.did} targetDid={profile.did}
/> />
<ReportDialog <ReportDialog
control={reportDialogControl} control={reportDialogControl}
subject={{ subject={{
@@ -537,44 +535,18 @@ let ProfileMenu = ({
$type: 'app.bsky.actor.defs#profileViewDetailed', $type: 'app.bsky.actor.defs#profileViewDetailed',
}} }}
/> />
<BlockDialog
<Prompt.Basic
control={blockPromptControl} control={blockPromptControl}
title={ profile={profile}
profile.viewer?.blocking onBlock={blockAccount}
? _(msg`Unblock Account?`)
: _(msg`Block Account?`)
}
description={
profile.viewer?.blocking
? _(
msg`The account will be able to interact with you after unblocking.`,
)
: profile.associated?.labeler
? _(
msg`Blocking will not prevent labels from being applied on your account, but it will stop this account from replying in your threads or interacting with you.`,
)
: _(
msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
)
}
onConfirm={blockAccount}
confirmButtonCta={
profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
}
confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'}
/> />
<Prompt.Basic <Prompt.Basic
control={loggedOutWarningPromptControl} control={loggedOutWarningPromptControl}
title={_(msg`Note about sharing`)} title={l`Note about sharing`}
description={_( description={l`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`}
msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`,
)}
onConfirm={onPressShare} onConfirm={onPressShare}
confirmButtonCta={_(msg`Share anyway`)} confirmButtonCta={l`Share anyway`}
/> />
<VerificationCreatePrompt <VerificationCreatePrompt
control={verificationCreatePromptControl} control={verificationCreatePromptControl}
profile={profile} profile={profile}
@@ -584,7 +556,6 @@ let ProfileMenu = ({
profile={profile} profile={profile}
verifications={currentAccountVerifications} verifications={currentAccountVerifications}
/> />
{status.isDisabled ? ( {status.isDisabled ? (
<GoLiveDisabledDialog <GoLiveDisabledDialog
control={goLiveDisabledDialogControl} control={goLiveDisabledDialogControl}