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

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