Merge remote-tracking branch 'origin/main' into app-2279
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.6",
|
||||
"@atproto/api": "0.20.11",
|
||||
"@atproto/common": "^0.6.1",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"express": "^4.19.2",
|
||||
|
||||
Generated
+5
-5
@@ -208,8 +208,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.6
|
||||
version: 0.20.6
|
||||
specifier: 0.20.11
|
||||
version: 0.20.11
|
||||
'@atproto/common':
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1
|
||||
@@ -259,8 +259,8 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
resolution: {integrity: sha512-WnFPcUl+qZdXmt27+Tg93BDIvBt/WpXfLIiBzBTp3ms9aszM5hAsfc7G8KEsnsmnRvcm0xRfiKEjIt5FxTKdYg==}
|
||||
'@atproto/api@0.20.11':
|
||||
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -1154,7 +1154,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@atproto/api@0.20.6':
|
||||
'@atproto/api@0.20.11':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert'
|
||||
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import resvg from '@resvg/resvg-js'
|
||||
import {type Express} from 'express'
|
||||
import satori from 'satori'
|
||||
@@ -32,7 +32,7 @@ export default function (ctx: AppContext, app: Express) {
|
||||
codes: [code],
|
||||
})
|
||||
const found = result.data.joinLinkPreviews[0]
|
||||
if (!found) {
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(found)) {
|
||||
return res.status(404).end('not found')
|
||||
}
|
||||
preview = found
|
||||
|
||||
@@ -177,9 +177,9 @@ func bskyProfileURL(handle string) string {
|
||||
return fmt.Sprintf("https://bsky.app/profile/%s", handle)
|
||||
}
|
||||
|
||||
// extractPostMedia returns thumbnail URLs for the post's image or video
|
||||
// embed, byte-identical to what we put in og:image. Callers derive
|
||||
// thumbnailUrl from urls[0].
|
||||
// extractPostMedia returns thumbnail URLs for the post's image, gallery,
|
||||
// or video embed, byte-identical to what we put in og:image. Callers
|
||||
// derive thumbnailUrl from urls[0].
|
||||
func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string {
|
||||
if pv == nil || pv.Embed == nil || embedHidden {
|
||||
return nil
|
||||
@@ -188,6 +188,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if pv.Embed.EmbedImages_View != nil {
|
||||
return imageThumbs(pv.Embed.EmbedImages_View.Images)
|
||||
}
|
||||
if pv.Embed.EmbedGallery_View != nil {
|
||||
return galleryThumbs(pv.Embed.EmbedGallery_View.Items)
|
||||
}
|
||||
if pv.Embed.EmbedVideo_View != nil && pv.Embed.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*pv.Embed.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -196,6 +199,9 @@ func extractPostMedia(pv *appbsky.FeedDefs_PostView, embedHidden bool) []string
|
||||
if media.EmbedImages_View != nil {
|
||||
return imageThumbs(media.EmbedImages_View.Images)
|
||||
}
|
||||
if media.EmbedGallery_View != nil {
|
||||
return galleryThumbs(media.EmbedGallery_View.Items)
|
||||
}
|
||||
if media.EmbedVideo_View != nil && media.EmbedVideo_View.Thumbnail != nil {
|
||||
return []string{*media.EmbedVideo_View.Thumbnail}
|
||||
}
|
||||
@@ -215,6 +221,31 @@ func imageThumbs(images []*appbsky.EmbedImages_ViewImage) []string {
|
||||
return urls
|
||||
}
|
||||
|
||||
// galleryThumbs returns the thumbnail URLs of image items in a gallery
|
||||
// embed, or nil if empty. Items_Elem is a union; non-image variants and
|
||||
// nil entries are skipped so future gallery item types don't break SEO
|
||||
// extraction. Empty Thumbnail strings are also skipped to avoid emitting
|
||||
// <meta property="og:image" content=""> if the appview ever returns one.
|
||||
func galleryThumbs(items []*appbsky.EmbedGallery_View_Items_Elem) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.EmbedGallery_ViewImage == nil {
|
||||
continue
|
||||
}
|
||||
if item.EmbedGallery_ViewImage.Thumbnail == "" {
|
||||
continue
|
||||
}
|
||||
urls = append(urls, item.EmbedGallery_ViewImage.Thumbnail)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return urls
|
||||
}
|
||||
|
||||
// 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
|
||||
// JSON-LD VideoObject stay in sync.
|
||||
|
||||
@@ -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.
|
||||
func withVideo(thumb string) func(*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) {
|
||||
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))
|
||||
@@ -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) {
|
||||
// Includes ", \, newline, </script>, and a unicode char.
|
||||
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
|
||||
|
||||
@@ -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) {
|
||||
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
|
||||
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ module github.com/bluesky-social/social-app/bskyweb
|
||||
go 1.26
|
||||
|
||||
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/ipfs/go-log v1.0.5
|
||||
github.com/joho/godotenv v1.5.1
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0 h1:eijBaF59A5c+kPqufH7YO1GOqDMkyUhtM9P9aAWtfJY=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260529183052-5368f55344e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c h1:Jr82+1HUmwwZzDpt/eeU4sieya27iXjuPMdXZkOXoBc=
|
||||
github.com/bluesky-social/indigo v0.0.0-20260605210604-af2fec94f34c/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
|
||||
+109
-41
@@ -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": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -19,11 +128,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/ageAssurance/util.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/alf/util/flatten.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -603,11 +707,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/dms/MessageItem.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/forms/DateField/index.web.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -650,14 +749,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useFullscreen.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/hooks/useLandingEntry.native.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
@@ -1903,12 +1994,6 @@
|
||||
"src/state/session/agent.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/state/shell/color-mode.tsx": {
|
||||
@@ -2271,23 +2356,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/profile/ProfileMenu.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 3
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 6
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 12
|
||||
}
|
||||
},
|
||||
"src/view/com/testing/TestCtrls.e2e.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 1
|
||||
|
||||
@@ -67,7 +67,18 @@ export class GifView extends PureComponent<GifViewProps> {
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 {VisibilityViewProps} from './types'
|
||||
import {type VisibilityViewProps} from './types'
|
||||
const NativeView: React.ComponentType<{
|
||||
onChangeStatus: (e: {nativeEvent: {isActive: boolean}}) => void
|
||||
children: React.ReactNode
|
||||
enabled: Boolean
|
||||
enabled: boolean
|
||||
style: StyleProp<ViewStyle>
|
||||
}> = requireNativeViewManager('ExpoBlueskyVisibilityView')
|
||||
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.123.0",
|
||||
"version": "1.124.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.15.0"
|
||||
@@ -59,7 +59,7 @@
|
||||
"test-watch": "NODE_ENV=test jest --watchAll",
|
||||
"test-ci": "NODE_ENV=test jest --ci --forceExit --reporters=default --reporters=jest-junit",
|
||||
"test-coverage": "NODE_ENV=test jest --coverage",
|
||||
"lint": "eslint --cache --quiet src",
|
||||
"lint": "eslint --cache --quiet src modules",
|
||||
"lint-native": "swiftlint ./modules && ktlint ./modules",
|
||||
"lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules",
|
||||
"typecheck": "tsgo --project ./tsconfig.check.json",
|
||||
@@ -93,7 +93,7 @@
|
||||
"prettier": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.9",
|
||||
"@atproto/api": "0.20.11",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
|
||||
Generated
+5
-5
@@ -242,8 +242,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.9
|
||||
version: 0.20.9
|
||||
specifier: 0.20.11
|
||||
version: 0.20.11
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -877,8 +877,8 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.9':
|
||||
resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
|
||||
'@atproto/api@0.20.11':
|
||||
resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
@@ -9493,7 +9493,7 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.9':
|
||||
'@atproto/api@0.20.11':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import {createContext, useCallback, useContext, useMemo} from 'react'
|
||||
|
||||
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
|
||||
import {
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from '#/ageAssurance/types'
|
||||
import {
|
||||
computeAgeAssuranceFlags,
|
||||
maybeRestrictChatSettings,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
|
||||
@@ -32,7 +32,6 @@ export {
|
||||
usePatchServerState as usePatchAgeAssuranceServerState,
|
||||
} from '#/ageAssurance/data'
|
||||
export {logger} from '#/ageAssurance/logger'
|
||||
export {MIN_ACCESS_AGE} from '#/ageAssurance/util'
|
||||
|
||||
const AgeAssuranceStateContext = createContext<{
|
||||
Access: typeof AgeAssuranceAccess
|
||||
@@ -48,8 +47,10 @@ const AgeAssuranceStateContext = createContext<{
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
flags: {
|
||||
isAgeRestricted: false,
|
||||
adultContentDisabled: false,
|
||||
chatDisabled: false,
|
||||
groupChatDisabled: false,
|
||||
isDeclaredUnderAdultAge: false,
|
||||
isOverRegionMinAccessAge: false,
|
||||
isOverAppMinAccessAge: false,
|
||||
@@ -84,13 +85,25 @@ function InnerProvider({children}: {children: React.ReactNode}) {
|
||||
|
||||
const handleAccessUpdate = useCallback(
|
||||
(s: AgeAssuranceState) => {
|
||||
const isAgeRestricted = s.access !== AgeAssuranceAccess.Full
|
||||
if (isAgeRestricted) {
|
||||
void getAndRegisterPushToken({isAgeRestricted})
|
||||
maybeRestrictChatSettings({agent})
|
||||
const flags = computeAgeAssuranceFlags({
|
||||
state: s,
|
||||
regionConfig,
|
||||
metadata,
|
||||
})
|
||||
if (flags.isAgeRestricted) {
|
||||
void getAndRegisterPushToken({
|
||||
isAgeRestricted: true,
|
||||
})
|
||||
}
|
||||
if (flags.chatDisabled || flags.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
},
|
||||
[agent, getAndRegisterPushToken],
|
||||
[agent, getAndRegisterPushToken, regionConfig, metadata],
|
||||
)
|
||||
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@ export type AgeAssuranceState = {
|
||||
}
|
||||
|
||||
export type AgeAssuranceFlags = {
|
||||
isAgeRestricted: boolean
|
||||
adultContentDisabled: boolean
|
||||
chatDisabled: boolean
|
||||
groupChatDisabled: boolean
|
||||
isDeclaredUnderAdultAge: boolean
|
||||
isOverRegionMinAccessAge: boolean
|
||||
isOverAppMinAccessAge: boolean
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
ageAssuranceRuleIDs as ids,
|
||||
type AppBskyAgeassuranceDefs,
|
||||
type AtpAgent,
|
||||
getAgeAssuranceRegionConfig,
|
||||
type ModerationPrefs,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getAge} from '#/lib/strings/time'
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {
|
||||
getDidFromAgentSession,
|
||||
getOtherRequiredDataFromCache,
|
||||
useAgeAssuranceServerDataContext,
|
||||
} from '#/ageAssurance/data'
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
|
||||
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
|
||||
import {
|
||||
AgeAssuranceAccess,
|
||||
type AgeAssuranceFlags,
|
||||
@@ -23,24 +17,6 @@ import {
|
||||
} from '#/ageAssurance/types'
|
||||
import {type Geolocation, useGeolocation} from '#/geolocation'
|
||||
|
||||
export const MIN_ACCESS_AGE = 13
|
||||
const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = {
|
||||
countryCode: '*',
|
||||
regionCode: undefined,
|
||||
minAccessAge: MIN_ACCESS_AGE,
|
||||
rules: [
|
||||
{
|
||||
$type: ids.IfDeclaredOverAge,
|
||||
age: MIN_ACCESS_AGE,
|
||||
access: AgeAssuranceAccess.Full,
|
||||
},
|
||||
{
|
||||
$type: ids.Default,
|
||||
access: AgeAssuranceAccess.None,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get age assurance region config based on geolocation, with fallback to
|
||||
* app defaults if no region config is found.
|
||||
@@ -121,19 +97,6 @@ export const makeAgeRestrictedModerationPrefs = (
|
||||
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({
|
||||
state,
|
||||
regionConfig,
|
||||
@@ -143,10 +106,12 @@ export function computeAgeAssuranceFlags({
|
||||
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
|
||||
metadata?: AgeAssuranceMetadata
|
||||
}): AgeAssuranceFlags {
|
||||
const chatDisabled = state.access !== AgeAssuranceAccess.Full
|
||||
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
|
||||
const chatDisabled = isAgeRestricted
|
||||
const isDeclaredUnderAdultAge = metadata?.declaredAge
|
||||
? metadata.declaredAge < 18
|
||||
: true
|
||||
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
|
||||
const isOverRegionMinAccessAge = metadata?.declaredAge
|
||||
? metadata.declaredAge >= regionConfig.minAccessAge
|
||||
: false
|
||||
@@ -157,8 +122,10 @@ export function computeAgeAssuranceFlags({
|
||||
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
|
||||
|
||||
return {
|
||||
isAgeRestricted,
|
||||
adultContentDisabled,
|
||||
chatDisabled,
|
||||
groupChatDisabled,
|
||||
isDeclaredUnderAdultAge,
|
||||
isOverRegionMinAccessAge,
|
||||
isOverAppMinAccessAge,
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} from 'react-native-reanimated'
|
||||
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 {type Props as IconProps} from '#/components/icons/common'
|
||||
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}
|
||||
|
||||
const MENU_WIDTH = 160
|
||||
const GAP = 6
|
||||
const CARD_BG = '#000000'
|
||||
const CARD_BORDER = '#232e3e'
|
||||
@@ -124,9 +123,8 @@ function MenuCard({
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.self_start,
|
||||
styles.card,
|
||||
android({alignSelf: 'flex-start'}),
|
||||
ios({width: MENU_WIDTH}),
|
||||
{
|
||||
top: anchor.y + anchor.height + GAP,
|
||||
left: anchor.x,
|
||||
@@ -186,7 +184,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
},
|
||||
itemText: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
lineHeight: 19.5,
|
||||
|
||||
@@ -154,7 +154,7 @@ export function ImageItem({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1, a.aspect_square, {maxWidth}]}>
|
||||
<View style={[a.relative, a.aspect_square, {maxWidth}]}>
|
||||
<Image
|
||||
key={thumbnail}
|
||||
source={{uri: thumbnail}}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 {
|
||||
type ChatInvitePreview,
|
||||
isKnownJoinLinkPreview,
|
||||
} from '#/state/queries/join-links'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
@@ -23,11 +27,12 @@ export function JoinRequestEmbed({
|
||||
onOpen,
|
||||
}: {
|
||||
code?: string
|
||||
preview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
preview?: ChatInvitePreview
|
||||
style?: StyleProp<ViewStyle>
|
||||
onOpen?: () => void
|
||||
}) {
|
||||
const resolvedCode = code ?? preview?.code
|
||||
const resolvedCode =
|
||||
code ?? (isKnownJoinLinkPreview(preview) ? preview.code : undefined)
|
||||
if (!resolvedCode) return null
|
||||
|
||||
return (
|
||||
@@ -73,7 +78,7 @@ export function JoinRequestEmbedBody({
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -108,10 +108,15 @@ export function useActiveVideoWeb() {
|
||||
|
||||
return {
|
||||
active: activeViewId === id,
|
||||
setActive: () => {
|
||||
setActive: useCallback(() => {
|
||||
setActiveView(id)
|
||||
},
|
||||
}, [setActiveView, id]),
|
||||
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 {Loader} from '#/components/Loader'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||
import {
|
||||
ReportDialog,
|
||||
useReportDialogControl,
|
||||
@@ -845,13 +846,10 @@ let PostMenuItems = ({
|
||||
onConfirm={() => void onToggleReplyVisibility()}
|
||||
confirmButtonCta={l`Yes, hide`}
|
||||
/>
|
||||
<Prompt.Basic
|
||||
<BlockDialog
|
||||
control={blockPromptControl}
|
||||
title={l`Block Account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={() => void onBlockAuthor()}
|
||||
confirmButtonCta={l`Block`}
|
||||
confirmButtonColor="negative"
|
||||
profile={postAuthor}
|
||||
onBlock={onBlockAuthor}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type ChatBskyConvoDefs, type ModerationOpts} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -72,7 +74,18 @@ export function ActionsWrapper({
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} 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(() =>
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
|
||||
@@ -209,6 +209,7 @@ export function AddMembersFlow({
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (!canBeAddedToGroup(profile)) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
@@ -216,12 +217,6 @@ export function AddMembersFlow({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items.sort(item => {
|
||||
return item.type === 'profile' && canBeAddedToGroup(item.profile)
|
||||
? -1
|
||||
: 1
|
||||
})
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
_items.push({type: 'placeholder', key: i + ''})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {View} from 'react-native'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {Plural, Trans} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -6,7 +7,7 @@ import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {SimpleInlineLinkText} from '#/components/Link'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useChatInvite} from './Context'
|
||||
@@ -20,7 +21,7 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
const t = useTheme()
|
||||
const {preview, hasFixedHeight} = useChatInvite()
|
||||
|
||||
if (!preview) return null
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) return null
|
||||
|
||||
const ownerDisplayName = createSanitizedDisplayName(preview.owner)
|
||||
const ownerHandle = sanitizeHandle(preview.owner.handle, '@')
|
||||
@@ -77,12 +78,12 @@ export function Card({size}: {size: 'large' | 'small'}) {
|
||||
allowFontScaling={!hasFixedHeight}>
|
||||
<Trans comment="The group chat creator, in the format 'By {displayName}'.">
|
||||
By{' '}
|
||||
<SimpleInlineLinkText
|
||||
<InlineLinkText
|
||||
to={makeProfileLink(preview.owner)}
|
||||
label={ownerDisplayName}
|
||||
style={[a.font_medium, t.atoms.text]}>
|
||||
{ownerDisplayName}
|
||||
</SimpleInlineLinkText>
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Text>
|
||||
<ProfileBadges
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createContext, useContext} from 'react'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
|
||||
import {type ChatInvitePreview} from '#/state/queries/join-links'
|
||||
import {type ButtonColor} from '#/components/Button'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
|
||||
@@ -26,7 +26,7 @@ export type ChatInviteContextValue = {
|
||||
code: string
|
||||
loading: boolean
|
||||
error: boolean
|
||||
preview: ChatBskyGroupDefs.JoinLinkPreviewView | undefined
|
||||
preview: ChatInvitePreview | undefined
|
||||
/**
|
||||
* The derived action descriptor. Undefined while loading or when there's no
|
||||
* preview to act on.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {setStringAsync} from 'expo-clipboard'
|
||||
import {type ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {ChatBskyGroupDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
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 {type ButtonColor} from '#/components/Button'
|
||||
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 {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
|
||||
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
|
||||
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {type ChatInviteAction, ChatInviteProvider} from './Context'
|
||||
@@ -34,7 +36,7 @@ export function Root({
|
||||
children,
|
||||
}: {
|
||||
code: string
|
||||
initialPreview?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
initialPreview?: ChatInvitePreview
|
||||
/**
|
||||
* 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
|
||||
@@ -62,7 +64,7 @@ export function Root({
|
||||
const loading = isPending && !preview
|
||||
|
||||
let action: ChatInviteAction | undefined
|
||||
if (preview) {
|
||||
if (ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
const convoId = preview.convo?.id
|
||||
const isFollowing = preview.owner.viewer?.following ?? false
|
||||
const hasRequested = !convoId && preview.viewer?.requestedAt != null
|
||||
@@ -99,12 +101,7 @@ export function Root({
|
||||
let icon: React.ComponentType<SVGIconProps> = JoinIcon
|
||||
let label = preview.requireApproval ? l`Request to join` : l`Join`
|
||||
let color: ButtonColor = 'primary'
|
||||
if (preview.enabledStatus !== 'enabled') {
|
||||
canJoin = false
|
||||
icon = WarningIcon
|
||||
label = l`Chat invite link no longer available`
|
||||
color = 'secondary'
|
||||
} else if (preview.memberCount >= preview.memberLimit) {
|
||||
if (preview.memberCount >= preview.memberLimit) {
|
||||
canJoin = false
|
||||
icon = HandIcon
|
||||
label = l`This chat is full`
|
||||
|
||||
@@ -36,6 +36,7 @@ import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Ti
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {ChatProfileTabs} from './ChatProfileTabs'
|
||||
@@ -209,6 +210,7 @@ export function InitiateChatFlow({
|
||||
const [footerHeight, setFooterHeight] = useState(0)
|
||||
const listRef = useRef<ListMethods>(null)
|
||||
const {currentAccount} = useSession()
|
||||
const aa = useAgeAssurance()
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const accountTooNewPromptControl = Dialog.useDialogControl()
|
||||
|
||||
@@ -301,6 +303,7 @@ export function InitiateChatFlow({
|
||||
if (follows) {
|
||||
for (const page of follows.pages) {
|
||||
for (const profile of page.follows) {
|
||||
if (!checker(profile)) continue
|
||||
_items.push({
|
||||
type: 'profile',
|
||||
key: profile.did,
|
||||
@@ -308,10 +311,6 @@ export function InitiateChatFlow({
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_items = _items.sort(item => {
|
||||
return item.type === 'profile' && checker(item.profile) ? -1 : 1
|
||||
})
|
||||
} else {
|
||||
_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'})
|
||||
}
|
||||
|
||||
@@ -344,6 +347,7 @@ export function InitiateChatFlow({
|
||||
results,
|
||||
currentAccount?.did,
|
||||
follows,
|
||||
aa.flags.groupChatDisabled,
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
@@ -510,7 +514,6 @@ export function InitiateChatFlow({
|
||||
a.relative,
|
||||
a.align_center,
|
||||
a.justify_between,
|
||||
web(a.pb_lg),
|
||||
]}>
|
||||
{IS_NATIVE ? (
|
||||
<Button
|
||||
@@ -546,7 +549,7 @@ export function InitiateChatFlow({
|
||||
color="secondary"
|
||||
style={[a.absolute, a.z_20, {right: -4}]}
|
||||
onPress={() => control.close()}>
|
||||
<ButtonIcon icon={XIcon} size="lg" />
|
||||
<ButtonIcon icon={XIcon} size="md" />
|
||||
</Button>
|
||||
) : showButton ? (
|
||||
<Button
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
type ModerationOpts,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate'
|
||||
import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||
import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'
|
||||
@@ -98,7 +100,18 @@ export let MessageContextMenu = ({
|
||||
.removeReaction(message.id, emoji)
|
||||
.catch(() => Toast.show(l`Failed to remove emoji reaction`))
|
||||
} 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(() =>
|
||||
Toast.show(l`Failed to add emoji reaction`, {
|
||||
type: 'error',
|
||||
|
||||
@@ -58,7 +58,7 @@ const AVATAR_SIZE = 28
|
||||
const CLUSTERED_MESSAGE_GAP = 2
|
||||
const BORDER_RADIUS = 18
|
||||
const SQUARED_BORDER_RADIUS = 4
|
||||
const DISPLAY_NAME_INSET = 22
|
||||
const DISPLAY_NAME_INSET = 20
|
||||
|
||||
function isWithinClusterBoundary({
|
||||
isPending,
|
||||
@@ -641,7 +641,7 @@ function BlockedPlaceholder({
|
||||
<Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" />
|
||||
{profile.viewer?.blocking && !profile.viewer.blockingByList && (
|
||||
<Prompt.Action
|
||||
onPress={() => queueUnblock()}
|
||||
onPress={() => void queueUnblock()}
|
||||
cta={l`Unblock`}
|
||||
color="secondary"
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {useWindowDimensions, View} from 'react-native'
|
||||
import {type $Typed, type ChatBskyEmbedJoinLink} from '@atproto/api'
|
||||
|
||||
import {useConvoActive} from '#/state/messages/convo'
|
||||
import {isKnownJoinLinkPreview} from '#/state/queries/join-links'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
import {MessageContextProvider} from './MessageContext'
|
||||
@@ -27,6 +28,11 @@ let MessageItemInviteEmbed = ({
|
||||
const screen = useWindowDimensions()
|
||||
const convo = useConvoActive()
|
||||
|
||||
const code = isKnownJoinLinkPreview(embed.joinLinkPreview)
|
||||
? embed.joinLinkPreview.code
|
||||
: undefined
|
||||
if (!code) return null
|
||||
|
||||
return (
|
||||
<MessageContextProvider>
|
||||
<View
|
||||
@@ -72,7 +78,7 @@ let MessageItemInviteEmbed = ({
|
||||
},
|
||||
]}>
|
||||
<ChatInvite.Root
|
||||
code={embed.joinLinkPreview.code}
|
||||
code={code}
|
||||
initialPreview={embed.joinLinkPreview}
|
||||
currentConvoId={convo.convo.view.id}
|
||||
hasFixedHeight={false}>
|
||||
|
||||
@@ -121,7 +121,8 @@ function ProfileHeaderReady({
|
||||
<View style={[a.flex_row, a.align_center, a.flex_1, web(a.mb_2xs)]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
||||
numberOfLines={1}>
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{displayName}
|
||||
</Text>
|
||||
<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]}>
|
||||
<Text
|
||||
style={[a.text_lg, a.font_semi_bold, a.flex_shrink]}
|
||||
numberOfLines={1}>
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{convo.details.name}
|
||||
</Text>
|
||||
<MuteStatus muted={convo.view.muted} />
|
||||
|
||||
@@ -45,7 +45,8 @@ export function SystemMessageItem({
|
||||
a.text_center,
|
||||
t.atoms.text_contrast_medium,
|
||||
{includeFontPadding: false, textAlignVertical: 'center'},
|
||||
]}>
|
||||
]}
|
||||
emoji>
|
||||
{text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import {AppBskyEmbedRecord, ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
} from '@atproto/api'
|
||||
import {type I18n} from '@lingui/core'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {
|
||||
postUriToRelativePath,
|
||||
@@ -13,6 +18,7 @@ export type UserMessageInfo = {
|
||||
message: string | null
|
||||
sentAt: string
|
||||
reportableMessage?: ChatBskyConvoDefs.MessageView
|
||||
isBlockedMessage: boolean
|
||||
}
|
||||
|
||||
export function getMessageInfo({
|
||||
@@ -36,6 +42,7 @@ export function getMessageInfo({
|
||||
const isGroup = ChatBskyConvoDefs.isGroupConvo(convo.kind)
|
||||
|
||||
const reportableMessage = isFromMe ? undefined : lastMessage
|
||||
const isBlockedMessage = sender ? isBlockedOrBlocking(sender) : false
|
||||
|
||||
const prefix = (message: string) => {
|
||||
if (isFromMe) {
|
||||
@@ -80,6 +87,8 @@ export function getMessageInfo({
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
} else if (ChatBskyEmbedJoinLink.isView(lastMessage.embed)) {
|
||||
message = prefix(i18n._(msg`(chat invite link)`))
|
||||
} else {
|
||||
message = prefix(defaultEmbeddedContentMessage)
|
||||
}
|
||||
@@ -89,5 +98,6 @@ export function getMessageInfo({
|
||||
message,
|
||||
sentAt: lastMessage.sentAt,
|
||||
reportableMessage,
|
||||
isBlockedMessage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {useCallback, useEffect, useRef, useSyncExternalStore} from 'react'
|
||||
|
||||
import {IS_WEB, IS_WEB_FIREFOX, IS_WEB_SAFARI} from '#/env'
|
||||
|
||||
@@ -13,28 +7,38 @@ function fullscreenSubscribe(onChange: () => void) {
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}
|
||||
|
||||
function getFullscreenSnapshot() {
|
||||
return Boolean(document.fullscreenElement)
|
||||
}
|
||||
|
||||
export function useFullscreen(ref?: React.RefObject<HTMLElement | null>) {
|
||||
if (!IS_WEB) throw new Error("'useFullscreen' is a web-only hook")
|
||||
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
|
||||
Boolean(document.fullscreenElement),
|
||||
const isFullscreen = useSyncExternalStore(
|
||||
fullscreenSubscribe,
|
||||
getFullscreenSnapshot,
|
||||
)
|
||||
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(() => {
|
||||
if (isFullscreen) {
|
||||
document.exitFullscreen()
|
||||
void document.exitFullscreen()
|
||||
} else {
|
||||
if (!ref) throw new Error('No ref provided')
|
||||
if (!ref.current) return
|
||||
scrollYRef.current = window.scrollY
|
||||
ref.current.requestFullscreen()
|
||||
void ref.current.requestFullscreen()
|
||||
}
|
||||
}, [isFullscreen, ref])
|
||||
|
||||
useEffect(() => {
|
||||
const prevIsFullscreen = prevIsFullscreenRef.current
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}, [isFullscreen, prevIsFullscreen])
|
||||
}, [isFullscreen])
|
||||
|
||||
return [isFullscreen, toggleFullscreen] as const
|
||||
}
|
||||
|
||||
@@ -523,7 +523,11 @@ function GalleryImage({
|
||||
a.font_bold,
|
||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||
]}>
|
||||
{index + 1}/{imageCount}
|
||||
<Trans
|
||||
context="gallery-badge-image-position-numbers"
|
||||
comment="Badge showing the current image position out of the total number of images in a gallery.">
|
||||
{index + 1}/{imageCount}
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
import {
|
||||
ChatBskyGroupDefs,
|
||||
ChatBskyGroupRequestJoin,
|
||||
ChatBskyGroupWithdrawJoinRequest,
|
||||
moderateProfile,
|
||||
@@ -211,7 +212,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
|
||||
const joinLinkPreview = data.joinLinkPreviews[0]
|
||||
|
||||
if (!joinLinkPreview) {
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
|
||||
return (
|
||||
<>
|
||||
<View style={[a.py_lg, a.align_center]}>
|
||||
@@ -253,12 +254,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
? l`Request to join`
|
||||
: l`Join`
|
||||
let buttonColor: ButtonColor = 'primary'
|
||||
if (joinLinkPreview.enabledStatus !== 'enabled') {
|
||||
canJoin = false
|
||||
ButtonIconImage = WarningIcon
|
||||
buttonText = l`Chat invite link no longer available`
|
||||
buttonColor = 'secondary'
|
||||
} else if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
|
||||
if (joinLinkPreview.memberCount >= joinLinkPreview.memberLimit) {
|
||||
canJoin = false
|
||||
ButtonIconImage = HandIcon
|
||||
buttonText = l`This chat is full`
|
||||
@@ -311,10 +307,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
</Trans>
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.ml_md]}>
|
||||
<PersonGroupIcon
|
||||
size="xs"
|
||||
style={[a.mr_xs, t.atoms.text, {marginTop: -2}]}
|
||||
/>
|
||||
<PersonGroupIcon size="xs" style={[a.mr_xs, t.atoms.text]} />
|
||||
</View>
|
||||
<Text
|
||||
style={[a.text_center, a.text_xs, a.leading_snug, t.atoms.text]}>
|
||||
@@ -371,7 +364,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) {
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
<ProfileBadges
|
||||
profile={data.joinLinkPreviews[0].owner}
|
||||
profile={joinLinkPreview.owner}
|
||||
size="sm"
|
||||
style={{marginTop: -3}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AppBskyFeedPost,
|
||||
BlobRef,
|
||||
type BskyAgent,
|
||||
ChatBskyGroupDefs,
|
||||
type ComAtprotoLabelDefs,
|
||||
type ComAtprotoRepoApplyWrites,
|
||||
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 {
|
||||
$type: 'app.bsky.embed.external',
|
||||
external: {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import {
|
||||
type AppBskyFeedDefs,
|
||||
type AppBskyGraphDefs,
|
||||
type BskyAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
type AtpAgent,
|
||||
type ComAtprotoRepoStrongRef,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
} from '#/lib/strings/url-helpers'
|
||||
import {type ComposerImage} from '#/state/gallery'
|
||||
import {createComposerImage} from '#/state/gallery'
|
||||
import {type ChatInvitePreview} from '#/state/queries/join-links'
|
||||
import {type Gif} from '#/features/gifPicker/types'
|
||||
import {createGIFDescription} from '../gif-alt-text'
|
||||
|
||||
@@ -77,7 +77,7 @@ type ResolvedChatInvite = {
|
||||
type: 'chat-invite'
|
||||
uri: string
|
||||
code: string
|
||||
view?: ChatBskyGroupDefs.JoinLinkPreviewView
|
||||
view?: ChatInvitePreview
|
||||
}
|
||||
|
||||
export type ResolvedLink =
|
||||
@@ -95,7 +95,7 @@ export class EmbeddingDisabledError extends Error {
|
||||
}
|
||||
|
||||
export async function resolveLink(
|
||||
agent: BskyAgent,
|
||||
agent: AtpAgent,
|
||||
uri: string,
|
||||
): Promise<ResolvedLink> {
|
||||
if (isShortLink(uri)) {
|
||||
@@ -217,7 +217,7 @@ export async function resolveLink(
|
||||
}
|
||||
|
||||
export async function resolveGif(
|
||||
agent: BskyAgent,
|
||||
agent: AtpAgent,
|
||||
gif: Gif,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const gifUrl = gif.media_formats.gif.url
|
||||
@@ -259,7 +259,7 @@ function getFileSlug(url: string | undefined): string | undefined {
|
||||
}
|
||||
|
||||
async function resolveExternal(
|
||||
agent: BskyAgent,
|
||||
agent: AtpAgent,
|
||||
uri: string,
|
||||
): Promise<ResolvedExternalLink> {
|
||||
const result = await getLinkMeta(agent, uri)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
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) {
|
||||
|
||||
+290
-271
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
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 {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
@@ -198,6 +198,7 @@ export function ChatList({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const aa = useAgeAssurance()
|
||||
const scrollElRef: ListRef = useAnimatedRef()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
@@ -230,6 +231,7 @@ export function ChatList({
|
||||
|
||||
const {refetch: refetchInbox} = useListConvosQuery({
|
||||
status: 'request',
|
||||
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
||||
})
|
||||
|
||||
useRefreshOnFocus(refetch)
|
||||
@@ -449,6 +451,7 @@ export function Header({
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const aa = useAgeAssurance()
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
const leftConvos = useLeftConvos()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
@@ -462,6 +465,7 @@ export function Header({
|
||||
useListConvosQuery({
|
||||
status: 'request',
|
||||
readState: 'unread',
|
||||
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
||||
})
|
||||
|
||||
const inboxAllConvos =
|
||||
@@ -471,7 +475,10 @@ export function Header({
|
||||
convo =>
|
||||
!leftConvos.includes(convo.id) &&
|
||||
!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(() => {
|
||||
|
||||
@@ -7,18 +7,22 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
|
||||
import {logger} from '#/logger'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {useRequireAuth, useSession} from '#/state/session'
|
||||
import {atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {
|
||||
type ConvoWithDetails,
|
||||
type GroupConvoMember,
|
||||
} from '#/components/dms/util'
|
||||
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {MemberMenu} from './MemberMenu'
|
||||
import {RemoveMemberPrompt} from './prompts'
|
||||
import {StatusBadge} from './StatusBadge'
|
||||
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
|
||||
|
||||
@@ -45,6 +49,14 @@ export function Member({
|
||||
const [queueFollow] = useProfileFollowMutationQueue(profile, 'GroupChat')
|
||||
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 handleFollow = () => {
|
||||
@@ -101,6 +113,9 @@ export function Member({
|
||||
)}`
|
||||
: l`Added by invite link`
|
||||
|
||||
// Surface a prominent remove button to the owner for blocked members.
|
||||
const showRemoveButton = isOwner && !isSelf && !!isBlockedOrBlocking(profile)
|
||||
|
||||
return (
|
||||
<SubtleHoverWrapper>
|
||||
<View style={outerStyles}>
|
||||
@@ -137,7 +152,17 @@ export function Member({
|
||||
</ProfileCard.Header>
|
||||
</ProfileCard.Outer>
|
||||
</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
|
||||
label={l`Follow ${displayName}`}
|
||||
{...createStaticClick(handleFollow)}
|
||||
@@ -147,6 +172,14 @@ export function Member({
|
||||
)}
|
||||
{statusBadge}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,11 +22,12 @@ import {
|
||||
PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
|
||||
} from '#/components/icons/Person'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {BlockMemberPrompt} from './prompts'
|
||||
import {RemoveMemberPrompt} from './prompts'
|
||||
import {StatusBadge} from './StatusBadge'
|
||||
|
||||
export function MemberMenu({
|
||||
@@ -50,6 +51,7 @@ export function MemberMenu({
|
||||
const requireEmailVerification = useRequireEmailVerification()
|
||||
|
||||
const blockMemberPrompt = Prompt.usePromptControl()
|
||||
const removeMemberPrompt = Prompt.usePromptControl()
|
||||
|
||||
const [menuDidOpen, setMenuDidOpen] = useState(false)
|
||||
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
|
||||
@@ -227,7 +229,7 @@ export function MemberMenu({
|
||||
<Menu.Item
|
||||
destructive
|
||||
label={l`Remove ${displayName} from this group chat`}
|
||||
onPress={() => removeMembers({members: [profile.did]})}>
|
||||
onPress={removeMemberPrompt.open}>
|
||||
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove from chat</Trans>
|
||||
@@ -237,9 +239,16 @@ export function MemberMenu({
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
<BlockMemberPrompt
|
||||
<BlockDialog
|
||||
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 {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
|
||||
import {
|
||||
type CommonNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
@@ -54,6 +55,7 @@ import {Loader} from '#/components/Loader'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {InviteLinkDialog} from '../components/InviteLinkDialog'
|
||||
import {AddMembersLink} from './AddMembersLink'
|
||||
@@ -86,12 +88,23 @@ type Props = NativeStackScreenProps<
|
||||
>
|
||||
|
||||
export function MessagesConversationSettingsScreen({route}: Props) {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const convoId = route.params.conversation
|
||||
|
||||
return (
|
||||
<Layout.Screen>
|
||||
<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.TitleText>
|
||||
<Trans>Group chat settings</Trans>
|
||||
@@ -214,6 +227,12 @@ function GroupSettings({
|
||||
const bIsSelf = b.did === currentAccount?.did
|
||||
if (aIsOwner !== bIsOwner) return aIsOwner ? -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
|
||||
})
|
||||
|
||||
|
||||
@@ -151,11 +151,13 @@ export function LeaveAndLockChatPrompt({
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockMemberPrompt({
|
||||
export function RemoveMemberPrompt({
|
||||
control,
|
||||
displayName,
|
||||
onConfirm,
|
||||
}: {
|
||||
control: Dialog.DialogOuterProps['control']
|
||||
displayName: string
|
||||
onConfirm: () => void
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
@@ -163,11 +165,12 @@ export function BlockMemberPrompt({
|
||||
return (
|
||||
<Prompt.Basic
|
||||
control={control}
|
||||
title={l`Block account?`}
|
||||
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonCta={l`Block`}
|
||||
title={l`Remove ${displayName}?`}
|
||||
description={l`They won’t be able to rejoin unless you invite them again.`}
|
||||
confirmButtonCta={l`Remove`}
|
||||
confirmButtonColor="negative"
|
||||
cancelButtonCta={l`Cancel`}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {ChatBskyGroupDefs, moderateProfile} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
@@ -52,9 +52,7 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
? mobileDarkBg
|
||||
: mobileLightBg
|
||||
|
||||
const requiresApproval = data?.joinLinkPreviews[0]?.requireApproval
|
||||
const requiresFollow =
|
||||
data?.joinLinkPreviews[0]?.joinRule === 'followedByOwner'
|
||||
const joinLinkPreview = data?.joinLinkPreviews[0]
|
||||
|
||||
return (
|
||||
<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.align_center,
|
||||
]}>
|
||||
{error ? (
|
||||
{error ||
|
||||
(data &&
|
||||
!ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) ? (
|
||||
<Wrapper>
|
||||
<ChainLinkBrokenIcon fill={t.palette.primary_500} size="3xl" />
|
||||
<Text
|
||||
@@ -80,20 +80,19 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
a.font_semi_bold,
|
||||
t.atoms.text,
|
||||
]}>
|
||||
{l`This invite link has expired`}
|
||||
<Trans>Chat invite link no longer available</Trans>
|
||||
</Text>
|
||||
<ActionButtons setScreenState={setScreenState} />
|
||||
</Wrapper>
|
||||
) : data && moderationOpts ? (
|
||||
) : data &&
|
||||
moderationOpts &&
|
||||
ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview) ? (
|
||||
<Wrapper>
|
||||
<AvatarBubbles
|
||||
profiles={[
|
||||
data.joinLinkPreviews[0].owner,
|
||||
joinLinkPreview.owner,
|
||||
...Array(
|
||||
Math.min(
|
||||
3,
|
||||
Math.max(0, data.joinLinkPreviews[0].memberCount - 1),
|
||||
),
|
||||
Math.min(3, Math.max(0, joinLinkPreview.memberCount - 1)),
|
||||
).fill(undefined),
|
||||
]}
|
||||
size={135}
|
||||
@@ -127,9 +126,11 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans comment="The number of active group chat members out of the total number allowed.">
|
||||
{data.joinLinkPreviews[0].memberCount}/
|
||||
{data.joinLinkPreviews[0].memberLimit}
|
||||
<Trans
|
||||
context="group-chat-member-count"
|
||||
comment="The number of active group chat members out of the total number allowed.">
|
||||
{joinLinkPreview.memberCount}/
|
||||
{joinLinkPreview.memberLimit}
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -142,7 +143,7 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
a.font_bold,
|
||||
t.atoms.text,
|
||||
]}>
|
||||
{data.joinLinkPreviews[0].name}
|
||||
{joinLinkPreview.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.w_full]}>
|
||||
@@ -166,17 +167,17 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
<Trans comment="The owner (creator) of a group chat.">
|
||||
By{' '}
|
||||
{createSanitizedDisplayName(
|
||||
data.joinLinkPreviews[0].owner,
|
||||
joinLinkPreview.owner,
|
||||
true,
|
||||
moderateProfile(
|
||||
data.joinLinkPreviews[0].owner,
|
||||
joinLinkPreview.owner,
|
||||
moderationOpts,
|
||||
).ui('displayName'),
|
||||
)}
|
||||
</Trans>
|
||||
</Text>
|
||||
<ProfileBadges
|
||||
profile={data.joinLinkPreviews[0].owner}
|
||||
profile={joinLinkPreview.owner}
|
||||
size="sm"
|
||||
style={{marginTop: -4}}
|
||||
/>
|
||||
@@ -190,7 +191,7 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
t.atoms.text_contrast_medium,
|
||||
a.max_w_full,
|
||||
]}>
|
||||
{sanitizeHandle(data.joinLinkPreviews[0].owner.handle, '@')}
|
||||
{sanitizeHandle(joinLinkPreview.owner.handle, '@')}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
@@ -200,17 +201,16 @@ export function JoinRequest({setScreenState}: Props) {
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
{requiresApproval
|
||||
{joinLinkPreview.requireApproval
|
||||
? l`Sign in to request access to this group chat.`
|
||||
: l`Sign in to accept invite.`}{' '}
|
||||
{requiresFollow &&
|
||||
{joinLinkPreview.joinRule === 'followedByOwner' &&
|
||||
l`Only people ${createSanitizedDisplayName(
|
||||
data.joinLinkPreviews[0].owner,
|
||||
joinLinkPreview.owner,
|
||||
true,
|
||||
moderateProfile(
|
||||
data.joinLinkPreviews[0].owner,
|
||||
moderationOpts,
|
||||
).ui('displayName'),
|
||||
moderateProfile(joinLinkPreview.owner, moderationOpts).ui(
|
||||
'displayName',
|
||||
),
|
||||
)} follows can join.`}
|
||||
</Text>
|
||||
<ActionButtons setScreenState={setScreenState} />
|
||||
|
||||
@@ -474,14 +474,14 @@ function RejectButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
label={l`Ignore join request`}
|
||||
label={l`Reject join request`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
disabled={disabled}
|
||||
onPress={onPress}>
|
||||
<ButtonText>
|
||||
<Trans comment="Ignore a request to join a chat" context="button">
|
||||
Ignore
|
||||
<Trans comment="Reject a request to join a chat" context="button">
|
||||
Reject
|
||||
</Trans>
|
||||
</ButtonText>
|
||||
</Button>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/compon
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
@@ -46,6 +47,7 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const aa = useAgeAssurance()
|
||||
const {currentAccount} = useSession()
|
||||
const {data: profile} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
@@ -55,6 +57,7 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
const exportCarControl = Dialog.useDialogControl()
|
||||
|
||||
const isGroupChatEnabled = ax.features.enabled(ax.features.GroupChatsEnable)
|
||||
const groupInvitesLocked = aa.flags.groupChatDisabled
|
||||
|
||||
const allowMessagesFromOptions: {name: AllowIncoming; label: string}[] = [
|
||||
{
|
||||
@@ -192,15 +195,26 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
a.leading_snug,
|
||||
t.atoms.text_contrast_high,
|
||||
]}>
|
||||
<Trans>
|
||||
You can continue ongoing conversations regardless of which
|
||||
setting you choose.
|
||||
</Trans>
|
||||
{groupInvitesLocked ? (
|
||||
<Trans>
|
||||
Group chats are only available to users 18 and over.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
You can continue ongoing conversations regardless of which
|
||||
setting you choose.
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
<Toggle.Group
|
||||
disabled={groupInvitesLocked}
|
||||
label={l`Allow group chat invites from`}
|
||||
type="radio"
|
||||
values={[resolveAllowGroupInvites(profile?.associated?.chat)]}
|
||||
values={[
|
||||
groupInvitesLocked
|
||||
? 'none'
|
||||
: resolveAllowGroupInvites(profile?.associated?.chat),
|
||||
]}
|
||||
onChange={onSelectGroupInvitesFrom}>
|
||||
<View>
|
||||
{allowGroupInvitesFromOptions.map(option => (
|
||||
|
||||
@@ -40,11 +40,11 @@ import {getReactionInfo} from '#/components/dms/getReactionInfo'
|
||||
import {getSystemMessageInfo} from '#/components/dms/getSystemMessageInfo'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
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 {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 {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 {useMenuControl} from '#/components/Menu'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
@@ -331,7 +331,9 @@ function BaseChatItem({
|
||||
i18n,
|
||||
})
|
||||
if (info) {
|
||||
lastMessage = info.message ?? lastMessage
|
||||
lastMessage = info.isBlockedMessage
|
||||
? l`This message is hidden`
|
||||
: (info.message ?? lastMessage)
|
||||
lastMessageSentAt = info.sentAt
|
||||
}
|
||||
}
|
||||
@@ -421,7 +423,7 @@ function BaseChatItem({
|
||||
const markReadAction = {
|
||||
threshold: 120,
|
||||
color: t.palette.primary_500,
|
||||
icon: EnvelopeOpen,
|
||||
icon: EnvelopeOpenIcon,
|
||||
action: () => {
|
||||
markAsRead({
|
||||
convoId: convo.view.id,
|
||||
@@ -432,7 +434,7 @@ function BaseChatItem({
|
||||
const deleteAction = {
|
||||
threshold: 225,
|
||||
color: t.palette.negative_500,
|
||||
icon: Trash_Stroke2_Corner0_Rounded,
|
||||
icon: TrashIcon,
|
||||
action: () => {
|
||||
leaveConvoControl.open()
|
||||
},
|
||||
@@ -507,7 +509,7 @@ function BaseChatItem({
|
||||
a.px_lg,
|
||||
a.py_md,
|
||||
a.gap_md,
|
||||
isWithinLeftPanel && a.rounded_sm,
|
||||
isWithinLeftPanel && [a.rounded_sm, a.mt_2xs],
|
||||
{
|
||||
backgroundColor: hasUnread
|
||||
? t.palette.primary_25
|
||||
@@ -572,7 +574,7 @@ function BaseChatItem({
|
||||
web({whiteSpace: 'preserve nowrap'}),
|
||||
]}>
|
||||
{' '}
|
||||
<BellStroke
|
||||
<BellStrokeIcon
|
||||
size="xs"
|
||||
style={[t.atoms.text_contrast_medium]}
|
||||
/>
|
||||
|
||||
@@ -516,7 +516,8 @@ export function InviteLinkDialog({
|
||||
</View>
|
||||
}
|
||||
label={l`Group chat invite link dialog`}
|
||||
style={web({maxWidth: 400})}>
|
||||
style={web({maxWidth: 400})}
|
||||
contentContainerStyle={web(a.pt_0)}>
|
||||
{content}
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
AppBskyFeedPost,
|
||||
AppBskyRichtextFacet,
|
||||
AtUri,
|
||||
ChatBskyGroupDefs,
|
||||
moderatePost,
|
||||
RichText as RichTextAPI,
|
||||
} from '@atproto/api'
|
||||
@@ -30,6 +31,7 @@ import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import * as ChatInvite from '#/components/dms/ChatInvite'
|
||||
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 * as MediaPreview from '#/components/MediaPreview'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
@@ -52,12 +54,12 @@ export function useMessageEmbed() {
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const embedFromParams = route.params.embed
|
||||
|
||||
const [embed, setEmbedState] = useState<MessageEmbedState | undefined>(
|
||||
const [embed, setEmbed] = useState<MessageEmbedState | undefined>(
|
||||
embedFromParams ? {type: 'post', uri: embedFromParams} : undefined,
|
||||
)
|
||||
|
||||
if (embedFromParams && embed?.type !== 'post') {
|
||||
setEmbedState({type: 'post', uri: embedFromParams})
|
||||
setEmbed({type: 'post', uri: embedFromParams})
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -68,7 +70,7 @@ export function useMessageEmbed() {
|
||||
// Only the post embed is reflected in the route param (used by the
|
||||
// share-to-DM intent flow); invites are local-only.
|
||||
navigation.setParams({embed: ''})
|
||||
setEmbedState(undefined)
|
||||
setEmbed(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,7 +79,7 @@ export function useMessageEmbed() {
|
||||
if (isBskyChatInviteUrl(embedUrl)) {
|
||||
const code = getChatInviteCodeFromUrl(embedUrl)
|
||||
if (code) {
|
||||
setEmbedState({type: 'invite', code})
|
||||
setEmbed({type: 'invite', code})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -86,7 +88,7 @@ export function useMessageEmbed() {
|
||||
const url = convertBskyAppUrlIfNeeded(embedUrl)
|
||||
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
|
||||
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
|
||||
setEmbedState({type: 'post', uri})
|
||||
setEmbed({type: 'post', uri})
|
||||
}
|
||||
},
|
||||
[embedFromParams, navigation],
|
||||
@@ -314,11 +316,19 @@ function MessageInputInviteEmbedBody() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
if (!ChatBskyGroupDefs.isJoinLinkPreviewView(preview)) {
|
||||
return (
|
||||
<View style={[{minHeight: 64}, a.justify_center, a.align_center]}>
|
||||
<Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
|
||||
<Trans>Could not load invite</Trans>
|
||||
<View
|
||||
style={[
|
||||
{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>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -94,7 +94,8 @@ export function MessagesListGroupInfoPanel({
|
||||
/>
|
||||
{convo.details.name ? (
|
||||
<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}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -107,7 +108,8 @@ export function MessagesListGroupInfoPanel({
|
||||
a.text_sm,
|
||||
t.atoms.text_contrast_high,
|
||||
showButtons ? null : a.mb_4xl,
|
||||
]}>
|
||||
]}
|
||||
emoji>
|
||||
{names}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
@@ -60,7 +60,8 @@ export function MessagesListInfoPanel({
|
||||
]}>
|
||||
<Text
|
||||
style={[a.text_2xl, a.font_bold, a.text_center, a.flex_shrink]}
|
||||
numberOfLines={1}>
|
||||
numberOfLines={1}
|
||||
emoji>
|
||||
{displayName}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="lg" />
|
||||
|
||||
@@ -76,7 +76,14 @@ export function OutgoingRequestListItem({
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
<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]}>
|
||||
<Text
|
||||
emoji
|
||||
@@ -85,8 +92,8 @@ export function OutgoingRequestListItem({
|
||||
{convoView.name}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[a.pl_xs]}>
|
||||
<TimeElapsed timestamp={convoView.requestedAt}>
|
||||
{convoView.viewer?.requestedAt ? (
|
||||
<TimeElapsed timestamp={convoView.viewer.requestedAt}>
|
||||
{({timeElapsed}) => (
|
||||
<Text
|
||||
style={[
|
||||
@@ -98,7 +105,7 @@ export function OutgoingRequestListItem({
|
||||
</Text>
|
||||
)}
|
||||
</TimeElapsed>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
|
||||
@@ -24,9 +24,9 @@ import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {MIN_ACCESS_AGE} from '#/ageAssurance/const'
|
||||
import {
|
||||
isUnderAge,
|
||||
MIN_ACCESS_AGE,
|
||||
useAgeAssuranceRegionConfigWithFallback,
|
||||
} from '#/ageAssurance/util'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {useMemo} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
|
||||
import {preferencesQueryKey} from '#/state/queries/preferences'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {usePatchAgeAssuranceOtherRequiredData} from '#/ageAssurance'
|
||||
import {isUnderAge, maybeRestrictChatSettings} from '#/ageAssurance/util'
|
||||
import {isUnderAge} from '#/ageAssurance/util'
|
||||
import {IS_DEV} from '#/env'
|
||||
import {account} from '#/storage'
|
||||
|
||||
@@ -66,7 +67,11 @@ export function useBirthdateMutation() {
|
||||
})
|
||||
|
||||
if (isUnderAge(birthDate.toISOString(), 18)) {
|
||||
maybeRestrictChatSettings({agent})
|
||||
await restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: true,
|
||||
restrictGroupInvites: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@ import {createContext, useContext, useMemo} from 'react'
|
||||
import {AtpAgent, type ModerationOpts} from '@atproto/api'
|
||||
|
||||
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 {usePreferencesQuery} from '../queries/preferences'
|
||||
|
||||
|
||||
@@ -1,17 +1,48 @@
|
||||
import {useCallback} from 'react'
|
||||
import {
|
||||
type $Typed,
|
||||
AtpAgent,
|
||||
type ChatBskyGroupDefs,
|
||||
ChatBskyGroupDefs,
|
||||
type ChatBskyGroupGetJoinLinkPreviews,
|
||||
} 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 {logger} from '#/logger'
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export const createJoinLinkPreviewQueryKey = (args: {
|
||||
@@ -22,6 +53,29 @@ export const createJoinLinkPreviewQueryKey = (args: {
|
||||
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({
|
||||
agent,
|
||||
codes,
|
||||
@@ -104,7 +158,7 @@ export function useGetJoinLinkPreview() {
|
||||
}: {
|
||||
code: string
|
||||
hasSession: boolean
|
||||
}): Promise<ChatBskyGroupDefs.JoinLinkPreviewView | undefined> => {
|
||||
}): Promise<KnownChatInvitePreview | undefined> => {
|
||||
try {
|
||||
const data = await queryClient.fetchQuery({
|
||||
queryKey: createJoinLinkPreviewQueryKey({codes: [code], hasSession}),
|
||||
@@ -112,7 +166,8 @@ export function useGetJoinLinkPreview() {
|
||||
fetchJoinLinkPreviews({agent, codes: [code], hasSession}),
|
||||
staleTime: STALE.SECONDS.FIFTEEN,
|
||||
})
|
||||
return data.joinLinkPreviews[0]
|
||||
const found = data.joinLinkPreviews[0]
|
||||
return isKnownJoinLinkPreview(found) ? found : undefined
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch join link preview', {safeMessage: error})
|
||||
return undefined
|
||||
|
||||
@@ -6,6 +6,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
rollbackConvoOptimistic,
|
||||
@@ -59,6 +60,7 @@ export function useDisableJoinLink(
|
||||
}
|
||||
})
|
||||
}
|
||||
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (e, _variables, context) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {invalidateJoinLinkPreviewsForCode} from '#/state/queries/join-links'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
rollbackConvoOptimistic,
|
||||
@@ -56,6 +57,7 @@ export function useEnableJoinLink(
|
||||
}
|
||||
})
|
||||
}
|
||||
void invalidateJoinLinkPreviewsForCode(queryClient, data.joinLink.code)
|
||||
onSuccess?.(data)
|
||||
},
|
||||
onError: (e, _variables, context) => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import {useMessagesEventBus} from '#/state/messages/events'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {parseConvoView} from '#/components/dms/util'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {type AgeAssuranceFlags} from '#/ageAssurance/types'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import {RQKEY as CONVO_KEY} from './conversation'
|
||||
import {useLeftConvos} from './leave-conversation'
|
||||
@@ -122,10 +124,12 @@ export function ListConvosProviderInner({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const aa = useAgeAssurance()
|
||||
const {refetch, data} = useListConvosQuery({
|
||||
readState: 'unread',
|
||||
limit: UNREAD_LIMIT,
|
||||
lockStatus: 'unlocked',
|
||||
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
||||
})
|
||||
const messagesBus = useMessagesEventBus()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -676,6 +680,7 @@ export function useUnreadMessageCount() {
|
||||
const {currentAccount} = useSession()
|
||||
const {accepted, request} = useListConvos()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const aa = useAgeAssurance()
|
||||
|
||||
return useMemo<{
|
||||
count: number
|
||||
@@ -687,12 +692,14 @@ export function useUnreadMessageCount() {
|
||||
currentAccount?.did,
|
||||
currentConvoId,
|
||||
moderationOpts,
|
||||
aa.flags,
|
||||
)
|
||||
const requestCount = calculateCount(
|
||||
request,
|
||||
currentAccount?.did,
|
||||
currentConvoId,
|
||||
moderationOpts,
|
||||
aa.flags,
|
||||
)
|
||||
if (acceptedCount > 0) {
|
||||
const total = acceptedCount + Math.min(requestCount, 1)
|
||||
@@ -723,6 +730,7 @@ function calculateCount(
|
||||
currentAccountDid: string | undefined,
|
||||
currentConvoId: string | undefined,
|
||||
moderationOpts: ModerationOpts | undefined,
|
||||
flags: AgeAssuranceFlags,
|
||||
) {
|
||||
return (
|
||||
convos
|
||||
@@ -732,6 +740,8 @@ function calculateCount(
|
||||
|
||||
if (!convo || !moderationOpts) return acc
|
||||
|
||||
if (convo.kind === 'group' && flags.groupChatDisabled) return acc
|
||||
|
||||
const shouldIgnore =
|
||||
convo.view.muted ||
|
||||
!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 {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({
|
||||
agent,
|
||||
did,
|
||||
restrictIncoming = false,
|
||||
restrictGroupInvites = false,
|
||||
}: {
|
||||
agent: AtpAgent
|
||||
did: string
|
||||
restrictIncoming?: boolean
|
||||
restrictGroupInvites?: boolean
|
||||
}): Promise<void> {
|
||||
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 = {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
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 {
|
||||
const record: ChatBskyActorDeclaration.Main = {
|
||||
$type: 'chat.bsky.actor.declaration',
|
||||
allowIncoming: 'none',
|
||||
}
|
||||
await networkRetry(3, () =>
|
||||
agent.com.atproto.repo.putRecord({
|
||||
repo: did,
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/moderation'
|
||||
import {DEFAULT_LABEL_SETTINGS} from '@atproto/api'
|
||||
|
||||
import {
|
||||
type ThreadViewPreferences,
|
||||
type UsePreferencesQueryResponse,
|
||||
} 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'] =
|
||||
{
|
||||
hideReplies: false,
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import {useMemo} from 'react'
|
||||
import {
|
||||
BskyAgent,
|
||||
DEFAULT_LABEL_SETTINGS,
|
||||
interpretLabelValueDefinitions,
|
||||
} from '@atproto/api'
|
||||
import {BskyAgent, interpretLabelValueDefinitions} from '@atproto/api'
|
||||
|
||||
import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities'
|
||||
import {useLabelersDetailedInfoQuery} from '../labeler'
|
||||
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({
|
||||
excludeNonConfigurableLabelers = false,
|
||||
}: {
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
setCreatedAtForDid,
|
||||
} from '#/ageAssurance/data'
|
||||
import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
|
||||
import {AgeAssuranceAccess} from '#/ageAssurance/types'
|
||||
import {features} from '#/analytics'
|
||||
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
|
||||
import {addSessionErrorLog} from './logging'
|
||||
@@ -218,10 +217,14 @@ export async function createAgentAndCreateAccount(
|
||||
throw e
|
||||
}),
|
||||
// wait for AA data to load first, then check state
|
||||
aa.then(async () => {
|
||||
const {state} = unsafeGetAndComputeAgeAssurance({did: account.did})
|
||||
if (state.access !== AgeAssuranceAccess.Full) {
|
||||
restrictChatSettings({agent, did: account.did})
|
||||
aa.then(() => {
|
||||
const {flags} = unsafeGetAndComputeAgeAssurance({did: account.did})
|
||||
if (flags?.chatDisabled || flags?.groupChatDisabled) {
|
||||
void restrictChatSettings({
|
||||
agent,
|
||||
restrictIncoming: flags.chatDisabled,
|
||||
restrictGroupInvites: flags.groupChatDisabled,
|
||||
})
|
||||
}
|
||||
}),
|
||||
]).then(promises => {
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type AppBskyUnspeccedGetPostThreadV2,
|
||||
AtUri,
|
||||
type BskyAgent,
|
||||
ChatBskyGroupDefs,
|
||||
type RichText,
|
||||
} from '@atproto/api'
|
||||
import {plural} from '@lingui/core/macro'
|
||||
@@ -134,7 +135,14 @@ import * as Prompt from '#/components/Prompt'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
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 {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||
import {
|
||||
@@ -879,7 +887,9 @@ export const ComposePost = ({
|
||||
})),
|
||||
})
|
||||
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 =
|
||||
@@ -1219,6 +1229,24 @@ export const ComposePost = ({
|
||||
}
|
||||
}, [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 {
|
||||
scrollHandler,
|
||||
@@ -1324,6 +1352,11 @@ export const ComposePost = ({
|
||||
web({
|
||||
scrollbarGutter: 'stable',
|
||||
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"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import {memo, useCallback, useMemo} from 'react'
|
||||
import {type AppBskyActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
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 {StarterPack} from '#/components/icons/StarterPack'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {BlockDialog} from '#/components/moderation/BlockDialog'
|
||||
import {
|
||||
ReportDialog,
|
||||
useReportDialogControl,
|
||||
@@ -72,7 +71,7 @@ let ProfileMenu = ({
|
||||
}): React.ReactNode => {
|
||||
const t = useTheme()
|
||||
const ax = useAnalytics()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {currentAccount, hasSession} = useSession()
|
||||
const {openModal} = useModalControls()
|
||||
const reportDialogControl = useReportDialogControl()
|
||||
@@ -116,7 +115,7 @@ let ProfileMenu = ({
|
||||
}, [currentAccount, profile])
|
||||
|
||||
const invalidateProfileQuery = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: profileQueryKey(profile.did),
|
||||
})
|
||||
}, [queryClient, profile.did])
|
||||
@@ -124,10 +123,10 @@ let ProfileMenu = ({
|
||||
const onPressAddToStarterPacks = useCallback(() => {
|
||||
ax.metric('profile:addToStarterPack', {})
|
||||
addToStarterPacksDialogControl.open()
|
||||
}, [addToStarterPacksDialogControl])
|
||||
}, [addToStarterPacksDialogControl, ax])
|
||||
|
||||
const onPressShare = useCallback(() => {
|
||||
shareUrl(toShareUrl(makeProfileLink(profile)))
|
||||
void shareUrl(toShareUrl(makeProfileLink(profile)))
|
||||
}, [profile])
|
||||
|
||||
const onPressAddRemoveLists = useCallback(() => {
|
||||
@@ -145,11 +144,12 @@ let ProfileMenu = ({
|
||||
if (profile.viewer?.muted) {
|
||||
try {
|
||||
await queueUnmute()
|
||||
Toast.show(_(msg({message: 'Account unmuted', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account unmuted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -157,27 +157,29 @@ let ProfileMenu = ({
|
||||
} else {
|
||||
try {
|
||||
await queueMute()
|
||||
Toast.show(_(msg({message: 'Account muted', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account muted', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ax, profile.viewer?.muted, queueUnmute, _, queueMute])
|
||||
}, [ax, profile.viewer?.muted, queueUnmute, l, queueMute])
|
||||
|
||||
const blockAccount = useCallback(async () => {
|
||||
if (profile.viewer?.blocking) {
|
||||
try {
|
||||
await queueUnblock()
|
||||
Toast.show(_(msg({message: 'Account unblocked', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account unblocked', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
@@ -185,56 +187,59 @@ let ProfileMenu = ({
|
||||
} else {
|
||||
try {
|
||||
await queueBlock()
|
||||
Toast.show(_(msg({message: 'Account blocked', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account blocked', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock])
|
||||
}, [ax, profile.viewer?.blocking, l, queueUnblock, queueBlock])
|
||||
|
||||
const onPressFollowAccount = useCallback(async () => {
|
||||
try {
|
||||
await queueFollow()
|
||||
Toast.show(_(msg({message: 'Account followed', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account followed', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [_, ax, queueFollow])
|
||||
}, [l, ax, queueFollow])
|
||||
|
||||
const onPressUnfollowAccount = useCallback(async () => {
|
||||
try {
|
||||
await queueUnfollow()
|
||||
Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'})))
|
||||
} catch (e: any) {
|
||||
Toast.show(l({message: 'Account unfollowed', context: 'toast'}))
|
||||
} catch (err) {
|
||||
const e = err as Error
|
||||
if (e?.name !== 'AbortError') {
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [_, ax, queueUnfollow])
|
||||
}, [l, ax, queueUnfollow])
|
||||
|
||||
const onPressReportAccount = useCallback(() => {
|
||||
reportDialogControl.open()
|
||||
}, [reportDialogControl])
|
||||
|
||||
const onPressShareATUri = useCallback(() => {
|
||||
shareText(`at://${profile.did}`)
|
||||
void shareText(`at://${profile.did}`)
|
||||
}, [profile.did])
|
||||
|
||||
const onPressShareDID = useCallback(() => {
|
||||
shareText(profile.did)
|
||||
void shareText(profile.did)
|
||||
}, [profile.did])
|
||||
|
||||
const onPressSearch = useCallback(() => {
|
||||
@@ -251,14 +256,14 @@ let ProfileMenu = ({
|
||||
return (
|
||||
<EventStopper onKeyDown={false}>
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={_(msg`More options`)}>
|
||||
<Menu.Trigger label={l`More options`}>
|
||||
{({props}) => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
{...props}
|
||||
testID="profileHeaderDropdownBtn"
|
||||
label={_(msg`More options`)}
|
||||
label={l`More options`}
|
||||
hitSlop={HITSLOP_20}
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -267,7 +272,6 @@ let ProfileMenu = ({
|
||||
{statusNudgeActive && <Gradient style={[a.rounded_full]} />}
|
||||
<ButtonIcon icon={Ellipsis} size="sm" />
|
||||
</Button>
|
||||
|
||||
{statusNudgeActive && <Dot top={1} right={1} />}
|
||||
</>
|
||||
)
|
||||
@@ -278,9 +282,7 @@ let ProfileMenu = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownShareBtn"
|
||||
label={
|
||||
IS_WEB ? _(msg`Copy link to profile`) : _(msg`Share via...`)
|
||||
}
|
||||
label={IS_WEB ? l`Copy link to profile` : l`Share via...`}
|
||||
onPress={() => {
|
||||
if (showLoggedOutWarning) {
|
||||
loggedOutWarningPromptControl.open()
|
||||
@@ -301,7 +303,7 @@ let ProfileMenu = ({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownSearchBtn"
|
||||
label={_(msg`Search posts`)}
|
||||
label={l`Search posts`}
|
||||
onPress={onPressSearch}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Search posts</Trans>
|
||||
@@ -320,14 +322,12 @@ let ProfileMenu = ({
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownFollowBtn"
|
||||
label={
|
||||
isFollowing
|
||||
? _(msg`Unfollow account`)
|
||||
: _(msg`Follow account`)
|
||||
isFollowing ? l`Unfollow account` : l`Follow account`
|
||||
}
|
||||
onPress={
|
||||
isFollowing
|
||||
? onPressUnfollowAccount
|
||||
: onPressFollowAccount
|
||||
? () => void onPressUnfollowAccount()
|
||||
: () => void onPressFollowAccount()
|
||||
}>
|
||||
<Menu.ItemText>
|
||||
{isFollowing ? (
|
||||
@@ -343,7 +343,7 @@ let ProfileMenu = ({
|
||||
)}
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownStarterPackAddRemoveBtn"
|
||||
label={_(msg`Add to starter packs`)}
|
||||
label={l`Add to starter packs`}
|
||||
onPress={onPressAddToStarterPacks}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Add to starter packs</Trans>
|
||||
@@ -352,7 +352,7 @@ let ProfileMenu = ({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownListAddRemoveBtn"
|
||||
label={_(msg`Add to lists`)}
|
||||
label={l`Add to lists`}
|
||||
onPress={onPressAddRemoveLists}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Add to lists</Trans>
|
||||
@@ -364,10 +364,10 @@ let ProfileMenu = ({
|
||||
testID="profileHeaderDropdownListAddRemoveBtn"
|
||||
label={
|
||||
status.isDisabled
|
||||
? _(msg`Go live (disabled)`)
|
||||
? l`Go live (disabled)`
|
||||
: status.isActive
|
||||
? _(msg`Edit live status`)
|
||||
: _(msg`Go live`)
|
||||
? l`Edit live status`
|
||||
: l`Go live`
|
||||
}
|
||||
onPress={() => {
|
||||
if (status.isDisabled) {
|
||||
@@ -418,7 +418,7 @@ let ProfileMenu = ({
|
||||
(verification.viewer.hasIssuedVerification ? (
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownVerificationRemoveButton"
|
||||
label={_(msg`Remove verification`)}
|
||||
label={l`Remove verification`}
|
||||
onPress={() => verificationRemovePromptControl.open()}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Remove verification</Trans>
|
||||
@@ -428,7 +428,7 @@ let ProfileMenu = ({
|
||||
) : (
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownVerificationCreateButton"
|
||||
label={_(msg`Verify account`)}
|
||||
label={l`Verify account`}
|
||||
onPress={() => verificationCreatePromptControl.open()}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Verify account</Trans>
|
||||
@@ -444,10 +444,10 @@ let ProfileMenu = ({
|
||||
testID="profileHeaderDropdownMuteBtn"
|
||||
label={
|
||||
profile.viewer?.muted
|
||||
? _(msg`Unmute account`)
|
||||
: _(msg`Mute account`)
|
||||
? l`Unmute account`
|
||||
: l`Mute account`
|
||||
}
|
||||
onPress={onPressMuteAccount}>
|
||||
onPress={() => void onPressMuteAccount()}>
|
||||
<Menu.ItemText>
|
||||
{profile.viewer?.muted ? (
|
||||
<Trans>Unmute account</Trans>
|
||||
@@ -464,9 +464,9 @@ let ProfileMenu = ({
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownBlockBtn"
|
||||
label={
|
||||
profile.viewer
|
||||
? _(msg`Unblock account`)
|
||||
: _(msg`Block account`)
|
||||
profile.viewer?.blocking
|
||||
? l`Unblock account`
|
||||
: l`Block account`
|
||||
}
|
||||
onPress={() => blockPromptControl.open()}>
|
||||
<Menu.ItemText>
|
||||
@@ -485,7 +485,7 @@ let ProfileMenu = ({
|
||||
)}
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownReportBtn"
|
||||
label={_(msg`Report account`)}
|
||||
label={l`Report account`}
|
||||
onPress={onPressReportAccount}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Report account</Trans>
|
||||
@@ -503,7 +503,7 @@ let ProfileMenu = ({
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownShareATURIBtn"
|
||||
label={_(msg`Copy at:// URI`)}
|
||||
label={l`Copy at:// URI`}
|
||||
onPress={onPressShareATUri}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy at:// URI</Trans>
|
||||
@@ -512,7 +512,7 @@ let ProfileMenu = ({
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
testID="profileHeaderDropdownShareDIDBtn"
|
||||
label={_(msg`Copy DID`)}
|
||||
label={l`Copy DID`}
|
||||
onPress={onPressShareDID}>
|
||||
<Menu.ItemText>
|
||||
<Trans>Copy DID</Trans>
|
||||
@@ -524,12 +524,10 @@ let ProfileMenu = ({
|
||||
) : null}
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
|
||||
<StarterPackDialog
|
||||
control={addToStarterPacksDialogControl}
|
||||
targetDid={profile.did}
|
||||
/>
|
||||
|
||||
<ReportDialog
|
||||
control={reportDialogControl}
|
||||
subject={{
|
||||
@@ -537,44 +535,18 @@ let ProfileMenu = ({
|
||||
$type: 'app.bsky.actor.defs#profileViewDetailed',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
<BlockDialog
|
||||
control={blockPromptControl}
|
||||
title={
|
||||
profile.viewer?.blocking
|
||||
? _(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'}
|
||||
profile={profile}
|
||||
onBlock={blockAccount}
|
||||
/>
|
||||
|
||||
<Prompt.Basic
|
||||
control={loggedOutWarningPromptControl}
|
||||
title={_(msg`Note about sharing`)}
|
||||
description={_(
|
||||
msg`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`,
|
||||
)}
|
||||
title={l`Note about sharing`}
|
||||
description={l`This profile is only visible to logged-in users. It won't be visible to people who aren't signed in.`}
|
||||
onConfirm={onPressShare}
|
||||
confirmButtonCta={_(msg`Share anyway`)}
|
||||
confirmButtonCta={l`Share anyway`}
|
||||
/>
|
||||
|
||||
<VerificationCreatePrompt
|
||||
control={verificationCreatePromptControl}
|
||||
profile={profile}
|
||||
@@ -584,7 +556,6 @@ let ProfileMenu = ({
|
||||
profile={profile}
|
||||
verifications={currentAccountVerifications}
|
||||
/>
|
||||
|
||||
{status.isDisabled ? (
|
||||
<GoLiveDisabledDialog
|
||||
control={goLiveDisabledDialogControl}
|
||||
|
||||
Reference in New Issue
Block a user