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