diff --git a/bskyweb/cmd/bskyweb/main.go b/bskyweb/cmd/bskyweb/main.go index c3ded18e7..dba852f79 100644 --- a/bskyweb/cmd/bskyweb/main.go +++ b/bskyweb/cmd/bskyweb/main.go @@ -59,6 +59,13 @@ func run(args []string) { Value: ":8100", EnvVars: []string{"HTTP_ADDRESS"}, }, + &cli.StringFlag{ + Name: "metrics-address", + Usage: "Specify the local IP/port to bind the metrics server to", + Required: false, + Value: ":9090", + EnvVars: []string{"METRICS_HTTP_ADDRESS"}, + }, &cli.StringFlag{ Name: "link-host", Usage: "scheme, hostname, and port of link service", diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 3a7e903f9..6f6a5c080 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -12,6 +12,7 @@ import ( "io" "io/fs" "log/slog" + "net" "net/http" "net/netip" "net/url" @@ -35,15 +36,17 @@ import ( "github.com/labstack/echo-contrib/echoprometheus" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/urfave/cli/v2" ) type Server struct { - echo *echo.Echo - httpd *http.Server - xrpcc *xrpc.Client - chatXrpcc *xrpc.Client - cfg *Config + echo *echo.Echo + httpd *http.Server + metricsHttpd *http.Server + xrpcc *xrpc.Client + chatXrpcc *xrpc.Client + cfg *Config ipccClient http.Client @@ -67,6 +70,7 @@ type Config struct { func serve(cctx *cli.Context) error { debug := cctx.Bool("debug") httpAddress := cctx.String("http-address") + metricsAddress := cctx.String("metrics-address") appviewHost := cctx.String("appview-host") chatHost := cctx.String("chat-host") ogcardHost := cctx.String("ogcard-host") @@ -387,6 +391,19 @@ func serve(cctx *cli.Context) error { e.Group("/:linkId", server.LinkProxyMiddleware(linkUrl)) } + metricsHttpd, metricsListener, err := newMetricsHTTPServer(metricsAddress) + if err != nil { + return err + } + server.metricsHttpd = metricsHttpd + + log.Infof("starting metrics server address=%s", metricsAddress) + go func() { + if err := metricsHttpd.Serve(metricsListener); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Errorf("metrics HTTP server shutting down unexpectedly: %s", err) + } + }() + // Start the server. log.Infof("starting server address=%s", httpAddress) go func() { @@ -419,6 +436,24 @@ func serve(cctx *cli.Context) error { return nil } +func newMetricsHTTPServer(address string) (*http.Server, net.Listener, error) { + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", promhttp.Handler()) + + metricsHttpd := &http.Server{ + Addr: address, + Handler: metricsMux, + ReadHeaderTimeout: 5 * time.Second, + } + + metricsListener, err := net.Listen("tcp", address) + if err != nil { + return nil, nil, fmt.Errorf("listen metrics address %s: %w", address, err) + } + + return metricsHttpd, metricsListener, nil +} + func (srv *Server) ServeHTTP(rw http.ResponseWriter, req *http.Request) { srv.echo.ServeHTTP(rw, req) } @@ -429,7 +464,18 @@ func (srv *Server) Shutdown() error { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - return srv.httpd.Shutdown(ctx) + var shutdownErr error + if srv.metricsHttpd != nil { + if err := srv.metricsHttpd.Shutdown(ctx); err != nil { + shutdownErr = fmt.Errorf("metrics HTTP server shutdown error: %w", err) + } + } + + if err := srv.httpd.Shutdown(ctx); err != nil { + return errors.Join(shutdownErr, err) + } + + return shutdownErr } // NewTemplateContext returns a new pongo2 context with some default values. -- 2.51.2 From fbd81ad025ff23dd0c3d206df9f98f280c33821f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:45:16 -0700 Subject: [PATCH 02/64] Change Users I follow to People I follow (#10860) --- src/screens/Messages/Settings.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx index d2778ce5e..6c407a830 100644 --- a/src/screens/Messages/Settings.tsx +++ b/src/screens/Messages/Settings.tsx @@ -66,7 +66,7 @@ export function MessagesSettingsScreenInner({}: Props) { }, { name: 'following', - label: l({context: 'allow messages from', message: `Users I follow`}), + label: l({context: 'allow messages from', message: `People I follow`}), }, { name: 'none', @@ -83,7 +83,7 @@ export function MessagesSettingsScreenInner({}: Props) { name: 'following', label: l({ context: 'allow group chat invites from', - message: `Users I follow`, + message: `People I follow`, }), }, { -- 2.51.2 From e3e7db2350e80a8876617fbb0efc3a706c9ddf61 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:45:37 -0700 Subject: [PATCH 03/64] Redirect to messages after leaving a chat (#10861) --- src/screens/Messages/components/ChatEnded.tsx | 5 ++--- src/screens/Messages/components/ChatLocked.tsx | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/screens/Messages/components/ChatEnded.tsx b/src/screens/Messages/components/ChatEnded.tsx index 1ee13b68d..ccc08f1d9 100644 --- a/src/screens/Messages/components/ChatEnded.tsx +++ b/src/screens/Messages/components/ChatEnded.tsx @@ -1,6 +1,6 @@ import {Pressable} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' -import {StackActions, useNavigation} from '@react-navigation/native' +import {useNavigation} from '@react-navigation/native' import {HITSLOP_10} from '#/lib/constants' import {type NavigationProp} from '#/lib/routes/types' @@ -34,8 +34,7 @@ export function ChatEnded({ const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { onSuccess: () => { - // Settings > Chat > Chat list - navigation.dispatch(StackActions.pop(2)) + navigation.replace('Messages', {animation: 'pop'}) }, onError: e => { logger.error('Failed to leave group chat', {message: e}) diff --git a/src/screens/Messages/components/ChatLocked.tsx b/src/screens/Messages/components/ChatLocked.tsx index bd67ac257..8718667e9 100644 --- a/src/screens/Messages/components/ChatLocked.tsx +++ b/src/screens/Messages/components/ChatLocked.tsx @@ -1,7 +1,7 @@ import {Pressable} from 'react-native' import {ChatBskyConvoUnlockConvo} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' -import {StackActions, useNavigation} from '@react-navigation/native' +import {useNavigation} from '@react-navigation/native' import {HITSLOP_10} from '#/lib/constants' import {type NavigationProp} from '#/lib/routes/types' @@ -54,8 +54,7 @@ export function ChatLocked({ const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, { onSuccess: () => { - // Settings > Chat > Chat list - navigation.dispatch(StackActions.pop(2)) + navigation.replace('Messages', {animation: 'pop'}) }, onError: e => { logger.error('Failed to leave group chat', {message: e}) -- 2.51.2 From c8634105c1ef6dcd52622f1ed19adf153f0f5eb2 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 11 Jun 2026 08:46:01 -0700 Subject: [PATCH 04/64] Run codemod for replacing BskyAgent with AtpAgent (#10862) --- .jscodeshift/repo/bsky-agent.js | 62 +++++++++++++++++++ .../forms/DateField/index.android.tsx | 1 - src/lib/api/feed/author.ts | 6 +- src/lib/api/feed/custom.ts | 8 +-- src/lib/api/feed/demo.ts | 6 +- src/lib/api/feed/following.ts | 6 +- src/lib/api/feed/home.ts | 6 +- src/lib/api/feed/likes.ts | 6 +- src/lib/api/feed/merge.ts | 14 ++--- src/lib/api/index.ts | 14 ++--- src/lib/api/upload-blob.ts | 4 +- src/lib/api/upload-blob.web.ts | 4 +- src/lib/generate-starterpack.ts | 6 +- src/lib/link-meta/link-meta.ts | 4 +- src/lib/media/video/upload.shared.ts | 6 +- src/lib/media/video/upload.ts | 4 +- src/lib/media/video/upload.web.ts | 4 +- src/lib/moderation.ts | 6 +- src/screens/Onboarding/util.ts | 6 +- src/state/messages/convo/types.ts | 4 +- src/state/messages/events/agent.ts | 4 +- src/state/messages/events/types.ts | 4 +- src/state/queries/list-members.ts | 4 +- src/state/queries/list.ts | 4 +- src/state/queries/notifications/util.ts | 6 +- src/state/queries/post-feed.ts | 4 +- src/state/queries/postgate/index.ts | 8 +-- src/state/queries/preferences/moderation.ts | 4 +- src/state/queries/resolve-uri.ts | 4 +- src/state/queries/starter-packs.ts | 4 +- src/state/queries/threadgate/index.ts | 10 +-- src/state/session/__tests__/session-test.ts | 56 ++++++++--------- .../additional-moderation-authorities.ts | 6 +- src/state/session/agent.ts | 16 ++--- src/state/session/moderation.ts | 10 +-- src/view/com/composer/Composer.tsx | 4 +- src/view/com/composer/state/video.ts | 4 +- 37 files changed, 195 insertions(+), 134 deletions(-) create mode 100644 .jscodeshift/repo/bsky-agent.js diff --git a/.jscodeshift/repo/bsky-agent.js b/.jscodeshift/repo/bsky-agent.js new file mode 100644 index 000000000..57e501959 --- /dev/null +++ b/.jscodeshift/repo/bsky-agent.js @@ -0,0 +1,62 @@ +/** + * Codemod to replace BskyAgent with AtpAgent + * + * Before: + * import {BskyAgent} from '@atproto/api` + * BskyAgent.appLabelers.includes(labeler) + * + * After: + * import {AtpAgent} from '@atproto/api` + * AtpAgent.appLabelers.includes(labeler) + * + * Handles import specifiers, type annotations, static member access + * (BskyAgent.configure), `extends BskyAgent`, and `new BskyAgent()`. Whole + * identifiers only, so names like `OpaqueBskyAgent` are left untouched. + * + * Usage: jscodeshift -t .jscodeshift/repo/bsky-agent.js + * Example: jscodeshift -t .jscodeshift/repo/bsky-agent.js src/lib/moderation.ts + */ + +/* eslint-disable */ + +export const parser = 'tsx' + +export default function transformer(file, api) { + const j = api.jscodeshift + const root = j(file.source) + + // Replace every standalone `BskyAgent` identifier with `AtpAgent`. This + // covers imports, type references, member expressions, `extends`, and `new`. + root + .find(j.Identifier, {name: 'BskyAgent'}) + .replaceWith(() => j.identifier('AtpAgent')) + + // Renaming can leave a duplicate `AtpAgent` specifier on the @atproto/api + // import if the file already imported it. Dedupe by imported name, keeping + // the type-only modifier only if every duplicate was type-only. + root + .find(j.ImportDeclaration, {source: {value: '@atproto/api'}}) + .forEach(path => { + const seen = new Map() + for (const spec of path.value.specifiers) { + if (spec.type !== 'ImportSpecifier') { + seen.set(Symbol(), spec) + continue + } + const name = spec.imported.name + const existing = seen.get(name) + if (!existing) { + seen.set(name, spec) + } else if ( + existing.importKind === 'type' && + spec.importKind !== 'type' + ) { + // Prefer the value (non-type) import if either usage needs it. + seen.set(name, spec) + } + } + path.value.specifiers = Array.from(seen.values()) + }) + + return root.toSource() +} diff --git a/src/components/forms/DateField/index.android.tsx b/src/components/forms/DateField/index.android.tsx index 2a89be7d3..d8d95aac7 100644 --- a/src/components/forms/DateField/index.android.tsx +++ b/src/components/forms/DateField/index.android.tsx @@ -67,7 +67,6 @@ export function DateField({ isInvalid={isInvalid} accessibilityHint={accessibilityHint} /> - {open && ( // Android implementation of DatePicker currently does not change default button colors according to theme and only takes hex values for buttonColor // Can remove the buttonColor setting if/when this PR is merged: https://github.com/henninghall/react-native-date-picker/pull/871 diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index cc19f0f7a..3b97b8ef7 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -1,20 +1,20 @@ import { AppBskyFeedDefs, type AppBskyFeedGetAuthorFeed as GetAuthorFeed, - type BskyAgent, + type AtpAgent, } from '@atproto/api' import {type FeedAPI, type FeedAPIResponse} from './types' export class AuthorFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent _params: GetAuthorFeed.QueryParams constructor({ agent, feedParams, }: { - agent: BskyAgent + agent: AtpAgent feedParams: GetAuthorFeed.QueryParams }) { this.agent = agent diff --git a/src/lib/api/feed/custom.ts b/src/lib/api/feed/custom.ts index 18bb8c8f0..54d9dc906 100644 --- a/src/lib/api/feed/custom.ts +++ b/src/lib/api/feed/custom.ts @@ -1,7 +1,7 @@ import { type AppBskyFeedDefs, type AppBskyFeedGetFeed as GetCustomFeed, - BskyAgent, + AtpAgent, jsonStringToLex, } from '@atproto/api' @@ -13,7 +13,7 @@ import {type FeedAPI, type FeedAPIResponse} from './types' import {createBskyTopicsHeader, isBlueskyOwnedFeed} from './utils' export class CustomFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent params: GetCustomFeed.QueryParams userInterests?: string @@ -22,7 +22,7 @@ export class CustomFeedAPI implements FeedAPI { feedParams, userInterests, }: { - agent: BskyAgent + agent: AtpAgent feedParams: GetCustomFeed.QueryParams userInterests?: string }) { @@ -113,7 +113,7 @@ async function loggedOutFetch({ * @see https://github.com/bluesky-social/atproto/blob/60df3fc652b00cdff71dd9235d98a7a4bb828f05/packages/api/src/agent.ts#L120 */ const labelersHeader = { - 'atproto-accept-labelers': BskyAgent.appLabelers + 'atproto-accept-labelers': AtpAgent.appLabelers .map(l => `${l};redact`) .join(', '), } diff --git a/src/lib/api/feed/demo.ts b/src/lib/api/feed/demo.ts index 049e0f116..42d1046bd 100644 --- a/src/lib/api/feed/demo.ts +++ b/src/lib/api/feed/demo.ts @@ -1,12 +1,12 @@ -import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api' +import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api' import {DEMO_FEED} from '#/lib/demo' import {type FeedAPI, type FeedAPIResponse} from './types' export class DemoFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent - constructor({agent}: {agent: BskyAgent}) { + constructor({agent}: {agent: AtpAgent}) { this.agent = agent } diff --git a/src/lib/api/feed/following.ts b/src/lib/api/feed/following.ts index 26de7f8a0..17e96d8e1 100644 --- a/src/lib/api/feed/following.ts +++ b/src/lib/api/feed/following.ts @@ -1,11 +1,11 @@ -import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api' +import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api' import {type FeedAPI, type FeedAPIResponse} from './types' export class FollowingFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent - constructor({agent}: {agent: BskyAgent}) { + constructor({agent}: {agent: AtpAgent}) { this.agent = agent } diff --git a/src/lib/api/feed/home.ts b/src/lib/api/feed/home.ts index 7a0d72d91..aa13c70bf 100644 --- a/src/lib/api/feed/home.ts +++ b/src/lib/api/feed/home.ts @@ -1,4 +1,4 @@ -import {type AppBskyFeedDefs, type BskyAgent} from '@atproto/api' +import {type AppBskyFeedDefs, type AtpAgent} from '@atproto/api' import {PROD_DEFAULT_FEED} from '#/lib/constants' import {CustomFeedAPI} from './custom' @@ -27,7 +27,7 @@ export const FALLBACK_MARKER_POST: AppBskyFeedDefs.FeedViewPost = { } export class HomeFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent following: FollowingFeedAPI discover: CustomFeedAPI usingDiscover = false @@ -39,7 +39,7 @@ export class HomeFeedAPI implements FeedAPI { agent, }: { userInterests?: string - agent: BskyAgent + agent: AtpAgent }) { this.agent = agent this.following = new FollowingFeedAPI({agent}) diff --git a/src/lib/api/feed/likes.ts b/src/lib/api/feed/likes.ts index c970fa72f..1511dc833 100644 --- a/src/lib/api/feed/likes.ts +++ b/src/lib/api/feed/likes.ts @@ -1,20 +1,20 @@ import { type AppBskyFeedDefs, type AppBskyFeedGetActorLikes as GetActorLikes, - type BskyAgent, + type AtpAgent, } from '@atproto/api' import {type FeedAPI, type FeedAPIResponse} from './types' export class LikesFeedAPI implements FeedAPI { - agent: BskyAgent + agent: AtpAgent params: GetActorLikes.QueryParams constructor({ agent, feedParams, }: { - agent: BskyAgent + agent: AtpAgent feedParams: GetActorLikes.QueryParams }) { this.agent = agent diff --git a/src/lib/api/feed/merge.ts b/src/lib/api/feed/merge.ts index b3f9575de..c341dd53a 100644 --- a/src/lib/api/feed/merge.ts +++ b/src/lib/api/feed/merge.ts @@ -1,7 +1,7 @@ import { type AppBskyFeedDefs, type AppBskyFeedGetTimeline, - type BskyAgent, + type AtpAgent, } from '@atproto/api' import shuffle from 'lodash.shuffle' @@ -24,7 +24,7 @@ const POST_AGE_CUTOFF = 60e3 * 60 * 24 // 24hours export class MergeFeedAPI implements FeedAPI { userInterests?: string - agent: BskyAgent + agent: AtpAgent params: FeedParams feedTuners: FeedTunerFn[] following: MergeFeedSource_Following @@ -39,7 +39,7 @@ export class MergeFeedAPI implements FeedAPI { feedTuners, userInterests, }: { - agent: BskyAgent + agent: AtpAgent feedParams: FeedParams feedTuners: FeedTunerFn[] userInterests?: string @@ -175,7 +175,7 @@ export class MergeFeedAPI implements FeedAPI { } class MergeFeedSource { - agent: BskyAgent + agent: AtpAgent feedTuners: FeedTunerFn[] sourceInfo: ReasonFeedSource | undefined cursor: string | undefined = undefined @@ -186,7 +186,7 @@ class MergeFeedSource { agent, feedTuners, }: { - agent: BskyAgent + agent: AtpAgent feedTuners: FeedTunerFn[] }) { this.agent = agent @@ -253,7 +253,7 @@ class MergeFeedSource_Following extends MergeFeedSource { } class MergeFeedSource_Custom extends MergeFeedSource { - agent: BskyAgent + agent: AtpAgent minDate: Date feedUri: string userInterests?: string @@ -264,7 +264,7 @@ class MergeFeedSource_Custom extends MergeFeedSource { feedTuners, userInterests, }: { - agent: BskyAgent + agent: AtpAgent feedUri: string feedTuners: FeedTunerFn[] userInterests?: string diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 40e6743b9..5a278aeba 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -7,8 +7,8 @@ import { type AppBskyEmbedRecordWithMedia, type AppBskyEmbedVideo, AppBskyFeedPost, + type AtpAgent, BlobRef, - type BskyAgent, ChatBskyGroupDefs, type ComAtprotoLabelDefs, type ComAtprotoRepoApplyWrites, @@ -55,7 +55,7 @@ interface PostOpts { } export async function post( - agent: BskyAgent, + agent: AtpAgent, queryClient: QueryClient, opts: PostOpts, ) { @@ -197,7 +197,7 @@ export async function post( return {uris} } -async function resolveRT(agent: BskyAgent, richtext: RichText) { +async function resolveRT(agent: AtpAgent, richtext: RichText) { const trimmedText = richtext.text // Trim leading whitespace-only lines (but don't break ASCII art). .replace(/^(\s*\n)+/, '') @@ -217,7 +217,7 @@ export class ReplyDeletedError extends Error { } } -async function resolveReply(agent: BskyAgent, replyTo: string) { +async function resolveReply(agent: AtpAgent, replyTo: string) { const {data} = await agent.app.bsky.feed.getPosts({ uris: [replyTo], }) @@ -250,7 +250,7 @@ async function resolveReply(agent: BskyAgent, replyTo: string) { } async function resolveEmbed( - agent: BskyAgent, + agent: AtpAgent, queryClient: QueryClient, draft: PostDraft, onStateChange: ((state: string) => void) | undefined, @@ -309,7 +309,7 @@ async function resolveEmbed( } async function resolveMedia( - agent: BskyAgent, + agent: AtpAgent, queryClient: QueryClient, embedDraft: EmbedDraft, onStateChange: ((state: string) => void) | undefined, @@ -482,7 +482,7 @@ async function resolveMedia( } async function resolveRecord( - agent: BskyAgent, + agent: AtpAgent, queryClient: QueryClient, uri: string, ): Promise { diff --git a/src/lib/api/upload-blob.ts b/src/lib/api/upload-blob.ts index 260ba770b..0bee39b9b 100644 --- a/src/lib/api/upload-blob.ts +++ b/src/lib/api/upload-blob.ts @@ -1,5 +1,5 @@ import {copyAsync} from 'expo-file-system/legacy' -import {type BskyAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' import {safeDeleteAsync} from '#/lib/media/manip' @@ -7,7 +7,7 @@ import {safeDeleteAsync} from '#/lib/media/manip' * @param encoding Allows overriding the blob's type */ export async function uploadBlob( - agent: BskyAgent, + agent: AtpAgent, input: string | Blob, encoding?: string, ): Promise { diff --git a/src/lib/api/upload-blob.web.ts b/src/lib/api/upload-blob.web.ts index 9f21be567..d74e83464 100644 --- a/src/lib/api/upload-blob.web.ts +++ b/src/lib/api/upload-blob.web.ts @@ -1,4 +1,4 @@ -import {type BskyAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' +import {type AtpAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' /** * @note It is recommended, on web, to use the `file` instance of the file @@ -7,7 +7,7 @@ import {type BskyAgent, type ComAtprotoRepoUploadBlob} from '@atproto/api' * be passed directly to this function. */ export async function uploadBlob( - agent: BskyAgent, + agent: AtpAgent, input: string | Blob, encoding?: string, ): Promise { diff --git a/src/lib/generate-starterpack.ts b/src/lib/generate-starterpack.ts index 1f4265a17..a53e95a6a 100644 --- a/src/lib/generate-starterpack.ts +++ b/src/lib/generate-starterpack.ts @@ -2,7 +2,7 @@ import { type $Typed, type AppBskyActorDefs, type AppBskyGraphGetStarterPack, - type BskyAgent, + type AtpAgent, type ComAtprotoRepoApplyWrites, type Facet, } from '@atproto/api' @@ -28,7 +28,7 @@ export const createStarterPackList = async ({ description?: string descriptionFacets?: Facet[] profiles: bsky.profile.AnyProfileView[] - agent: BskyAgent + agent: AtpAgent }): Promise<{uri: string; cid: string}> => { if (profiles.length === 0) throw new Error('No profiles given') @@ -152,7 +152,7 @@ function createListItem({ } async function whenAppViewReady( - agent: BskyAgent, + agent: AtpAgent, uri: string, fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, ) { diff --git a/src/lib/link-meta/link-meta.ts b/src/lib/link-meta/link-meta.ts index e72bab9f0..c282a0c4c 100644 --- a/src/lib/link-meta/link-meta.ts +++ b/src/lib/link-meta/link-meta.ts @@ -1,4 +1,4 @@ -import {type AppBskyEmbedExternal, type BskyAgent} from '@atproto/api' +import {type AppBskyEmbedExternal, type AtpAgent} from '@atproto/api' import {LINK_META_PROXY} from '#/lib/constants' import {getGiphyMetaUri} from '#/lib/strings/embed-player' @@ -31,7 +31,7 @@ export interface LinkMeta { } export async function getLinkMeta( - agent: BskyAgent, + agent: AtpAgent, url: string, timeout = 15e3, ): Promise { diff --git a/src/lib/media/video/upload.shared.ts b/src/lib/media/video/upload.shared.ts index fd46e2786..f8aaa1249 100644 --- a/src/lib/media/video/upload.shared.ts +++ b/src/lib/media/video/upload.shared.ts @@ -1,4 +1,4 @@ -import {type BskyAgent} from '@atproto/api' +import {type AtpAgent} from '@atproto/api' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' @@ -13,7 +13,7 @@ export async function getServiceAuthToken({ lxm, exp, }: { - agent: BskyAgent + agent: AtpAgent aud?: string lxm: string exp?: number @@ -30,7 +30,7 @@ export async function getServiceAuthToken({ return serviceAuth.token } -export async function getVideoUploadLimits(agent: BskyAgent, i18n: I18n) { +export async function getVideoUploadLimits(agent: AtpAgent, i18n: I18n) { const token = await getServiceAuthToken({ agent, lxm: 'app.bsky.video.getUploadLimits', diff --git a/src/lib/media/video/upload.ts b/src/lib/media/video/upload.ts index 503577a76..721ee7f94 100644 --- a/src/lib/media/video/upload.ts +++ b/src/lib/media/video/upload.ts @@ -1,5 +1,5 @@ import {createUploadTask, FileSystemUploadType} from 'expo-file-system/legacy' -import {type AppBskyVideoDefs, type BskyAgent} from '@atproto/api' +import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' import {nanoid} from 'nanoid/non-secure' @@ -19,7 +19,7 @@ export async function uploadVideo({ i18n, }: { video: CompressedVideo - agent: BskyAgent + agent: AtpAgent did: string setProgress: (progress: number) => void signal: AbortSignal diff --git a/src/lib/media/video/upload.web.ts b/src/lib/media/video/upload.web.ts index 98d329a70..e88a04707 100644 --- a/src/lib/media/video/upload.web.ts +++ b/src/lib/media/video/upload.web.ts @@ -1,4 +1,4 @@ -import {type AppBskyVideoDefs, type BskyAgent} from '@atproto/api' +import {type AppBskyVideoDefs, type AtpAgent} from '@atproto/api' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' import {nanoid} from 'nanoid/non-secure' @@ -18,7 +18,7 @@ export async function uploadVideo({ i18n, }: { video: CompressedVideo - agent: BskyAgent + agent: AtpAgent did: string setProgress: (progress: number) => void signal: AbortSignal diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 9b0ac0ea5..2fb37009e 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -1,7 +1,7 @@ import {useMemo} from 'react' import { type AppBskyLabelerDefs, - BskyAgent, + AtpAgent, type ComAtprotoLabelDefs, type InterpretedLabelValueDefinition, LABELS, @@ -91,9 +91,9 @@ export function isAppLabeler( | AppBskyLabelerDefs.LabelerViewDetailed, ): boolean { if (typeof labeler === 'string') { - return BskyAgent.appLabelers.includes(labeler) + return AtpAgent.appLabelers.includes(labeler) } - return BskyAgent.appLabelers.includes(labeler.creator.did) + return AtpAgent.appLabelers.includes(labeler.creator.did) } export function isLabelerSubscribed( diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts index acb96ee91..f4ecde3c5 100644 --- a/src/screens/Onboarding/util.ts +++ b/src/screens/Onboarding/util.ts @@ -2,7 +2,7 @@ import { type $Typed, type AppBskyGraphFollow, type AppBskyGraphGetFollows, - type BskyAgent, + type AtpAgent, type ComAtprotoRepoApplyWrites, type ComAtprotoRepoStrongRef, } from '@atproto/api' @@ -12,7 +12,7 @@ import chunk from 'lodash.chunk' import {until} from '#/lib/async/until' export async function bulkWriteFollows( - agent: BskyAgent, + agent: AtpAgent, dids: string[], via?: ComAtprotoRepoStrongRef.Main, ) { @@ -59,7 +59,7 @@ export async function bulkWriteFollows( } async function whenFollowsIndexed( - agent: BskyAgent, + agent: AtpAgent, actor: string, fn: (res: AppBskyGraphGetFollows.Response) => boolean, ) { diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index 269f32c51..c83fbfb81 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -1,7 +1,7 @@ import { type $Typed, type AppBskyEmbedRecord, - type BskyAgent, + type AtpAgent, type ChatBskyActorDefs, type ChatBskyConvoDefs, type ChatBskyConvoSendMessage, @@ -13,7 +13,7 @@ import {type ConvoWithDetails} from '#/components/dms/util' export type ConvoParams = { convoId: string - agent: BskyAgent + agent: AtpAgent events: MessagesEventBus placeholderData?: { convo: ChatBskyConvoDefs.ConvoView diff --git a/src/state/messages/events/agent.ts b/src/state/messages/events/agent.ts index ce9518212..636261bd2 100644 --- a/src/state/messages/events/agent.ts +++ b/src/state/messages/events/agent.ts @@ -1,4 +1,4 @@ -import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api' +import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api' import {EventEmitter} from 'eventemitter3' import {nanoid} from 'nanoid/non-secure' @@ -27,7 +27,7 @@ const logger = Logger.create(Logger.Context.DMsAgent) export class MessagesEventBus { private id: string - private agent: BskyAgent + private agent: AtpAgent private emitter = new EventEmitter<{event: [MessagesEventBusEvent]}>() private status: MessagesEventBusStatus = MessagesEventBusStatus.Initializing diff --git a/src/state/messages/events/types.ts b/src/state/messages/events/types.ts index 038684319..67d6bdd45 100644 --- a/src/state/messages/events/types.ts +++ b/src/state/messages/events/types.ts @@ -1,7 +1,7 @@ -import {type BskyAgent, type ChatBskyConvoGetLog} from '@atproto/api' +import {type AtpAgent, type ChatBskyConvoGetLog} from '@atproto/api' export type MessagesEventBusParams = { - agent: BskyAgent + agent: AtpAgent } export enum MessagesEventBusStatus { diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts index 152c7a5be..c43c7bb98 100644 --- a/src/state/queries/list-members.ts +++ b/src/state/queries/list-members.ts @@ -2,7 +2,7 @@ import { type AppBskyActorDefs, type AppBskyGraphDefs, type AppBskyGraphGetList, - type BskyAgent, + type AtpAgent, } from '@atproto/api' import { type InfiniteData, @@ -60,7 +60,7 @@ export function useAllListMembersQuery(uri?: string) { }) } -export async function getAllListMembers(agent: BskyAgent, uri: string) { +export async function getAllListMembers(agent: AtpAgent, uri: string) { let hasMore = true let cursor: string | undefined const listItems: AppBskyGraphDefs.ListItemView[] = [] diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 462cc4919..b5deb087c 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -3,8 +3,8 @@ import { type AppBskyGraphDefs, type AppBskyGraphGetList, type AppBskyGraphList, + type AtpAgent, AtUri, - type BskyAgent, type ComAtprotoRepoApplyWrites, type Facet, type Un$Typed, @@ -305,7 +305,7 @@ export function useListBlockMutation() { } async function whenAppViewReady( - agent: BskyAgent, + agent: AtpAgent, uri: string, fn: (res: AppBskyGraphGetList.Response) => boolean, ) { diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index a8c15e82c..ded66fb62 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -6,7 +6,7 @@ import { type AppBskyGraphDefs, AppBskyGraphStarterpack, type AppBskyNotificationListNotifications, - type BskyAgent, + type AtpAgent, hasMutedWord, moderateNotification, type ModerationOpts, @@ -46,7 +46,7 @@ export async function fetchPage({ fetchAdditionalData, reasons, }: { - agent: BskyAgent + agent: AtpAgent cursor: string | undefined limit: number queryClient: QueryClient @@ -204,7 +204,7 @@ export function groupNotifications( } async function fetchSubjects( - agent: BskyAgent, + agent: AtpAgent, groupedNotifs: FeedNotification[], ): Promise<{ posts: Map diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 2fbb9e7cb..959ed81c2 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -4,8 +4,8 @@ import { type AppBskyActorDefs, AppBskyFeedDefs, type AppBskyFeedPost, + type AtpAgent, AtUri, - type BskyAgent, moderatePost, type ModerationDecision, type ModerationPrefs, @@ -450,7 +450,7 @@ function createApi({ feedParams: FeedParams feedTuners: FeedTunerFn[] userInterests?: string - agent: BskyAgent + agent: AtpAgent enableFollowingToDiscoverFallback: boolean }) { if (feedDesc === 'following') { diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts index 926bb0ba0..82a52730b 100644 --- a/src/state/queries/postgate/index.ts +++ b/src/state/queries/postgate/index.ts @@ -4,8 +4,8 @@ import { AppBskyEmbedRecordWithMedia, type AppBskyFeedDefs, AppBskyFeedPostgate, + type AtpAgent, AtUri, - type BskyAgent, } from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' @@ -27,7 +27,7 @@ export async function getPostgateRecord({ agent, postUri, }: { - agent: BskyAgent + agent: AtpAgent postUri: string }): Promise { const urip = new AtUri(postUri) @@ -89,7 +89,7 @@ export async function writePostgateRecord({ postUri, postgate, }: { - agent: BskyAgent + agent: AtpAgent postUri: string postgate: AppBskyFeedPostgate.Record }) { @@ -110,7 +110,7 @@ export async function upsertPostgate( agent, postUri, }: { - agent: BskyAgent + agent: AtpAgent postUri: string }, callback: ( diff --git a/src/state/queries/preferences/moderation.ts b/src/state/queries/preferences/moderation.ts index c23e995e4..55c155dc5 100644 --- a/src/state/queries/preferences/moderation.ts +++ b/src/state/queries/preferences/moderation.ts @@ -1,5 +1,5 @@ import {useMemo} from 'react' -import {BskyAgent, interpretLabelValueDefinitions} from '@atproto/api' +import {AtpAgent, interpretLabelValueDefinitions} from '@atproto/api' import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' import {useLabelersDetailedInfoQuery} from '../labeler' @@ -13,7 +13,7 @@ export function useMyLabelersQuery({ const prefs = usePreferencesQuery() let dids = Array.from( new Set( - BskyAgent.appLabelers.concat( + AtpAgent.appLabelers.concat( prefs.data?.moderationPrefs.labelers.map(l => l.did) || [], ), ), diff --git a/src/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index a6ca192be..45ef4e228 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -1,4 +1,4 @@ -import {AtUri, type BskyAgent} from '@atproto/api' +import {type AtpAgent, AtUri} from '@atproto/api' import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query' import {STALE} from '#/state/queries' @@ -9,7 +9,7 @@ const RQKEY_ROOT = 'resolved-did' export const RQKEY = (didOrHandle: string) => [RQKEY_ROOT, didOrHandle] const resolvedDidQueryOptions = ( - agent: BskyAgent, + agent: AtpAgent, getUnstableProfile: (did: string) => {did: string} | undefined, didOrHandle: string | undefined, ) => diff --git a/src/state/queries/starter-packs.ts b/src/state/queries/starter-packs.ts index 53d668d89..ddf8365dd 100644 --- a/src/state/queries/starter-packs.ts +++ b/src/state/queries/starter-packs.ts @@ -4,8 +4,8 @@ import { type AppBskyGraphGetStarterPack, AppBskyGraphStarterpack, type AppBskyRichtextFacet, + type AtpAgent, AtUri, - type BskyAgent, RichText, } from '@atproto/api' import { @@ -340,7 +340,7 @@ export function useDeleteStarterPackMutation({ } async function whenAppViewReady( - agent: BskyAgent, + agent: AtpAgent, uri: string, fn: (res?: AppBskyGraphGetStarterPack.Response) => boolean, ) { diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts index e760873fb..561275ca6 100644 --- a/src/state/queries/threadgate/index.ts +++ b/src/state/queries/threadgate/index.ts @@ -1,8 +1,8 @@ import { type AppBskyFeedDefs, AppBskyFeedThreadgate, + type AtpAgent, AtUri, - type BskyAgent, } from '@atproto/api' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' @@ -88,7 +88,7 @@ export async function getThreadgateRecord({ agent, postUri, }: { - agent: BskyAgent + agent: AtpAgent postUri: string }): Promise { const urip = new AtUri(postUri) @@ -150,7 +150,7 @@ export async function writeThreadgateRecord({ postUri, threadgate, }: { - agent: BskyAgent + agent: AtpAgent postUri: string threadgate: AppBskyFeedThreadgate.Record }) { @@ -176,7 +176,7 @@ export async function upsertThreadgate( agent, postUri, }: { - agent: BskyAgent + agent: AtpAgent postUri: string }, callback: ( @@ -205,7 +205,7 @@ export async function updateThreadgateAllow({ postUri, allow, }: { - agent: BskyAgent + agent: AtpAgent postUri: string allow: ThreadgateAllowUISetting[] }) { diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts index 4398a90a0..eebcfcf8d 100644 --- a/src/state/session/__tests__/session-test.ts +++ b/src/state/session/__tests__/session-test.ts @@ -1,4 +1,4 @@ -import {BskyAgent} from '@atproto/api' +import {AtpAgent} from '@atproto/api' import {describe, expect, it, jest} from '@jest/globals' import {agentToSessionAccountOrThrow} from '../agent' @@ -16,7 +16,7 @@ jest.mock('../../../ageAssurance/state', () => ({ unsafeGetAndComputeAgeAssurance: () => ({state: {}}), })) jest.mock('#/lib/notifications/notifications', () => ({ - unregisterPushToken(_agents: BskyAgent[]) { + unregisterPushToken(_agents: AtpAgent[]) { return Promise.resolve() }, })) @@ -37,7 +37,7 @@ describe('session', () => { } `) - const agent = new BskyAgent({service: 'https://alice.com'}) + const agent = new AtpAgent({service: 'https://alice.com'}) agent.sessionManager.session = { active: true, did: 'alice-did', @@ -130,7 +130,7 @@ describe('session', () => { it('switches to the latest account, stores all of them', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -179,7 +179,7 @@ describe('session', () => { } `) - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -245,7 +245,7 @@ describe('session', () => { } `) - const agent3 = new BskyAgent({service: 'https://alice.com'}) + const agent3 = new AtpAgent({service: 'https://alice.com'}) agent3.sessionManager.session = { active: true, did: 'alice-did', @@ -311,7 +311,7 @@ describe('session', () => { } `) - const agent4 = new BskyAgent({service: 'https://jay.com'}) + const agent4 = new AtpAgent({service: 'https://jay.com'}) agent4.sessionManager.session = { active: true, did: 'jay-did', @@ -468,7 +468,7 @@ describe('session', () => { it('can log back in after logging out', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -526,7 +526,7 @@ describe('session', () => { } `) - const agent2 = new BskyAgent({service: 'https://alice.com'}) + const agent2 = new AtpAgent({service: 'https://alice.com'}) agent2.sessionManager.session = { active: true, did: 'alice-did', @@ -578,7 +578,7 @@ describe('session', () => { it('can remove active account', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -623,7 +623,7 @@ describe('session', () => { it('can remove inactive account', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -631,7 +631,7 @@ describe('session', () => { accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', } - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -704,7 +704,7 @@ describe('session', () => { it('can log out of the current account', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -724,7 +724,7 @@ describe('session', () => { expect(state.accounts[0].refreshJwt).toBe('alice-refresh-jwt-1') expect(state.currentAgentState.did).toBe('alice-did') - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -803,7 +803,7 @@ describe('session', () => { it('updates stored account with refreshed tokens', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -987,7 +987,7 @@ describe('session', () => { it('bails out of update on identical objects', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1059,7 +1059,7 @@ describe('session', () => { it('accepts updates from a stale agent', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1068,7 +1068,7 @@ describe('session', () => { refreshJwt: 'alice-refresh-jwt-1', } - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -1258,7 +1258,7 @@ describe('session', () => { it('ignores updates from a removed agent', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1267,7 +1267,7 @@ describe('session', () => { refreshJwt: 'alice-refresh-jwt-1', } - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -1320,7 +1320,7 @@ describe('session', () => { it('ignores network errors', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1386,7 +1386,7 @@ describe('session', () => { it('resets tokens on expired event', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1452,7 +1452,7 @@ describe('session', () => { it('resets tokens on created-failed event', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1518,7 +1518,7 @@ describe('session', () => { it('replaces local accounts with synced accounts', () => { let state = getInitialState([]) - const agent1 = new BskyAgent({service: 'https://alice.com'}) + const agent1 = new AtpAgent({service: 'https://alice.com'}) agent1.sessionManager.session = { active: true, did: 'alice-did', @@ -1526,7 +1526,7 @@ describe('session', () => { accessJwt: 'alice-access-jwt-1', refreshJwt: 'alice-refresh-jwt-1', } - const agent2 = new BskyAgent({service: 'https://bob.com'}) + const agent2 = new AtpAgent({service: 'https://bob.com'}) agent2.sessionManager.session = { active: true, did: 'bob-did', @@ -1549,7 +1549,7 @@ describe('session', () => { expect(state.accounts.length).toBe(2) expect(state.currentAgentState.did).toBe('bob-did') - const anotherTabAgent1 = new BskyAgent({service: 'https://jay.com'}) + const anotherTabAgent1 = new AtpAgent({service: 'https://jay.com'}) anotherTabAgent1.sessionManager.session = { active: true, did: 'jay-did', @@ -1557,7 +1557,7 @@ describe('session', () => { accessJwt: 'jay-access-jwt-1', refreshJwt: 'jay-refresh-jwt-1', } - const anotherTabAgent2 = new BskyAgent({service: 'https://alice.com'}) + const anotherTabAgent2 = new AtpAgent({service: 'https://alice.com'}) anotherTabAgent2.sessionManager.session = { active: true, did: 'bob-did', @@ -1627,7 +1627,7 @@ describe('session', () => { } `) - const anotherTabAgent3 = new BskyAgent({service: 'https://clarence.com'}) + const anotherTabAgent3 = new AtpAgent({service: 'https://clarence.com'}) anotherTabAgent3.sessionManager.session = { active: true, did: 'clarence-did', diff --git a/src/state/session/additional-moderation-authorities.ts b/src/state/session/additional-moderation-authorities.ts index 8088db88e..63ada2cdd 100644 --- a/src/state/session/additional-moderation-authorities.ts +++ b/src/state/session/additional-moderation-authorities.ts @@ -1,4 +1,4 @@ -import {BskyAgent} from '@atproto/api' +import {AtpAgent} from '@atproto/api' import {device} from '#/storage' @@ -83,8 +83,8 @@ export function configureAdditionalModerationAuthorities() { } const appLabelers = Array.from( - new Set([...BskyAgent.appLabelers, ...additionalLabelers]), + new Set([...AtpAgent.appLabelers, ...additionalLabelers]), ) - BskyAgent.configure({appLabelers}) + AtpAgent.configure({appLabelers}) } diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index fe669dfe8..78e9f7961 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -1,10 +1,10 @@ import { Agent as BaseAgent, type AppBskyActorProfile, + AtpAgent, type AtprotoServiceType, type AtpSessionData, type AtpSessionEvent, - BskyAgent, type Did, type Un$Typed, } from '@atproto/api' @@ -52,7 +52,7 @@ export function createPublicAgent() { export async function createAgentAndResume( storedAccount: SessionAccount, onSessionChange: ( - agent: BskyAgent, + agent: AtpAgent, did: string, event: AtpSessionEvent, ) => void, @@ -96,7 +96,7 @@ export async function createAgentAndLogin( authFactorToken?: string }, onSessionChange: ( - agent: BskyAgent, + agent: AtpAgent, did: string, event: AtpSessionEvent, ) => void, @@ -143,7 +143,7 @@ export async function createAgentAndCreateAccount( verificationCode?: string }, onSessionChange: ( - agent: BskyAgent, + agent: AtpAgent, did: string, event: AtpSessionEvent, ) => void, @@ -282,7 +282,7 @@ export async function createAgentAndCreateAccount( }) } -export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { +export function agentToSessionAccountOrThrow(agent: AtpAgent): SessionAccount { const account = agentToSessionAccount(agent) if (!account) { throw Error('Expected an active session') @@ -291,7 +291,7 @@ export function agentToSessionAccountOrThrow(agent: BskyAgent): SessionAccount { } export function agentToSessionAccount( - agent: BskyAgent, + agent: AtpAgent, ): SessionAccount | undefined { if (!agent.session) { return undefined @@ -350,7 +350,7 @@ export class Agent extends BaseAgent { // Ideally, we wouldn't be doing this. However, since there is so much logic that requires making calls to the PDS right now, it // feels safer to just let those run as-is and set the header afterward. let realFetch = globalThis.fetch -class BskyAppAgent extends BskyAgent { +class BskyAppAgent extends AtpAgent { persistSessionHandler: ((event: AtpSessionEvent) => void) | undefined = undefined @@ -389,7 +389,7 @@ class BskyAppAgent extends BskyAgent { // Not awaited in the calling code so we can delay blocking on them. resolvers: Promise[] onSessionChange: ( - agent: BskyAgent, + agent: AtpAgent, did: string, event: AtpSessionEvent, ) => void diff --git a/src/state/session/moderation.ts b/src/state/session/moderation.ts index 64e36da9d..8fc234d73 100644 --- a/src/state/session/moderation.ts +++ b/src/state/session/moderation.ts @@ -1,4 +1,4 @@ -import {BSKY_LABELER_DID, BskyAgent} from '@atproto/api' +import {AtpAgent, BSKY_LABELER_DID} from '@atproto/api' import {IS_TEST_USER} from '#/lib/constants' import {configureAdditionalModerationAuthorities} from './additional-moderation-authorities' @@ -13,7 +13,7 @@ export function configureModerationForGuest() { } export async function configureModerationForAccount( - agent: BskyAgent, + agent: AtpAgent, account: SessionAccount, ) { // This global mutation is *only* OK because this code is only relevant for testing. @@ -38,10 +38,10 @@ export async function configureModerationForAccount( } function switchToBskyAppLabeler() { - BskyAgent.configure({appLabelers: [BSKY_LABELER_DID]}) + AtpAgent.configure({appLabelers: [BSKY_LABELER_DID]}) } -async function trySwitchToTestAppLabeler(agent: BskyAgent) { +async function trySwitchToTestAppLabeler(agent: AtpAgent) { const did = ( await agent .resolveHandle({handle: 'mod-authority.test'}) @@ -49,6 +49,6 @@ async function trySwitchToTestAppLabeler(agent: BskyAgent) { )?.data.did if (did) { console.warn('USING TEST ENV MODERATION') - BskyAgent.configure({appLabelers: [did]}) + AtpAgent.configure({appLabelers: [did]}) } } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 39c429289..3ddaf41ed 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -50,8 +50,8 @@ import { AppBskyDraftCreateDraft, AppBskyUnspeccedDefs, type AppBskyUnspeccedGetPostThreadV2, + type AtpAgent, AtUri, - type BskyAgent, ChatBskyGroupDefs, type RichText, } from '@atproto/api' @@ -2358,7 +2358,7 @@ function useKeyboardVerticalOffset() { } async function whenAppViewReady( - agent: BskyAgent, + agent: AtpAgent, uri: string, fn: (res: AppBskyUnspeccedGetPostThreadV2.Response) => boolean, ) { diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts index 6d73e846e..54bfcd67a 100644 --- a/src/view/com/composer/state/video.ts +++ b/src/view/com/composer/state/video.ts @@ -1,5 +1,5 @@ import {type ImagePickerAsset} from 'expo-image-picker' -import {type AppBskyVideoDefs, type BlobRef, type BskyAgent} from '@atproto/api' +import {type AppBskyVideoDefs, type AtpAgent, type BlobRef} from '@atproto/api' import {type I18n} from '@lingui/core' import {msg} from '@lingui/core/macro' @@ -261,7 +261,7 @@ function trunc2dp(num: number) { export async function processVideo( asset: ImagePickerAsset, dispatch: (action: VideoAction) => void, - agent: BskyAgent, + agent: AtpAgent, did: string, signal: AbortSignal, i18n: I18n, -- 2.51.2 From 302026d684962df5c7faf4533f62510cc8eeca2c Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 11 Jun 2026 12:02:20 -0400 Subject: [PATCH 05/64] [bskyembed] Add gallery embed support (#10818) --- bskyembed/package.json | 2 +- bskyembed/pnpm-lock.yaml | 123 +++++++++++++---------------- bskyembed/src/components/embed.tsx | 84 +++++++++++++++----- bskyembed/src/components/post.tsx | 4 +- 4 files changed, 126 insertions(+), 87 deletions(-) diff --git a/bskyembed/package.json b/bskyembed/package.json index 7d6b62cf6..5f8ab4e87 100644 --- a/bskyembed/package.json +++ b/bskyembed/package.json @@ -13,7 +13,7 @@ "format": "prettier -w src" }, "dependencies": { - "@atproto/api": "^0.15.25", + "@atproto/api": "0.20.11", "preact": "^10.4.8" }, "devDependencies": { diff --git a/bskyembed/pnpm-lock.yaml b/bskyembed/pnpm-lock.yaml index fe0c80db6..eb57826b1 100644 --- a/bskyembed/pnpm-lock.yaml +++ b/bskyembed/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@atproto/api': - specifier: ^0.15.25 - version: 0.15.27 + specifier: 0.20.11 + version: 0.20.11 preact: specifier: ^10.4.8 version: 10.29.1 @@ -73,32 +73,33 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@atproto/api@0.15.27': - resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==} + '@atproto/api@0.20.11': + resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==} + engines: {node: '>=22'} - '@atproto/common-web@0.4.21': - resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} + '@atproto/common-web@0.5.0': + resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==} + engines: {node: '>=22'} - '@atproto/lex-data@0.0.15': - resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} + '@atproto/lex-data@0.1.1': + resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==} + engines: {node: '>=22'} - '@atproto/lex-json@0.0.16': - resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} + '@atproto/lex-json@0.1.0': + resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==} + engines: {node: '>=22'} - '@atproto/lexicon@0.4.14': - resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} + '@atproto/lexicon@0.7.1': + resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==} + engines: {node: '>=22'} - '@atproto/lexicon@0.6.2': - resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + '@atproto/syntax@0.6.1': + resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==} + engines: {node: '>=22'} - '@atproto/syntax@0.4.3': - resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} - - '@atproto/syntax@0.5.4': - resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==} - - '@atproto/xrpc@0.7.7': - resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@atproto/xrpc@0.8.0': + resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==} + engines: {node: '>=22'} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -1101,8 +1102,8 @@ packages: peerDependencies: postcss: ^8.1.0 - await-lock@2.2.2: - resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + await-lock@3.0.0: + resolution: {integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==} babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} @@ -1787,8 +1788,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multiformats@9.9.0: - resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -2194,8 +2195,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - uint8arrays@3.0.0: - resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} @@ -2324,63 +2325,51 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@atproto/api@0.15.27': + '@atproto/api@0.20.11': dependencies: - '@atproto/common-web': 0.4.21 - '@atproto/lexicon': 0.4.14 - '@atproto/syntax': 0.4.3 - '@atproto/xrpc': 0.7.7 - await-lock: 2.2.2 - multiformats: 9.9.0 + '@atproto/common-web': 0.5.0 + '@atproto/lexicon': 0.7.1 + '@atproto/syntax': 0.6.1 + '@atproto/xrpc': 0.8.0 + await-lock: 3.0.0 + multiformats: 13.4.2 tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.4.21': + '@atproto/common-web@0.5.0': dependencies: - '@atproto/lex-data': 0.0.15 - '@atproto/lex-json': 0.0.16 - '@atproto/syntax': 0.5.4 + '@atproto/lex-data': 0.1.1 + '@atproto/lex-json': 0.1.0 + '@atproto/syntax': 0.6.1 zod: 3.25.76 - '@atproto/lex-data@0.0.15': + '@atproto/lex-data@0.1.1': dependencies: - multiformats: 9.9.0 + multiformats: 13.4.2 tslib: 2.8.1 - uint8arrays: 3.0.0 + uint8arrays: 5.1.1 unicode-segmenter: 0.14.5 - '@atproto/lex-json@0.0.16': + '@atproto/lex-json@0.1.0': dependencies: - '@atproto/lex-data': 0.0.15 + '@atproto/lex-data': 0.1.1 tslib: 2.8.1 - '@atproto/lexicon@0.4.14': + '@atproto/lexicon@0.7.1': dependencies: - '@atproto/common-web': 0.4.21 - '@atproto/syntax': 0.4.3 - iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 + '@atproto/common-web': 0.5.0 + '@atproto/syntax': 0.6.1 + multiformats: 13.4.2 zod: 3.25.76 - '@atproto/lexicon@0.6.2': + '@atproto/syntax@0.6.1': dependencies: - '@atproto/common-web': 0.4.21 - '@atproto/syntax': 0.5.4 iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 - zod: 3.25.76 - - '@atproto/syntax@0.4.3': - dependencies: - tslib: 2.8.1 - - '@atproto/syntax@0.5.4': - dependencies: tslib: 2.8.1 - '@atproto/xrpc@0.7.7': + '@atproto/xrpc@0.8.0': dependencies: - '@atproto/lexicon': 0.6.2 + '@atproto/lexicon': 0.7.1 zod: 3.25.76 '@babel/code-frame@7.29.0': @@ -3536,7 +3525,7 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 - await-lock@2.2.2: {} + await-lock@3.0.0: {} babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): dependencies: @@ -4142,7 +4131,7 @@ snapshots: ms@2.1.3: {} - multiformats@9.9.0: {} + multiformats@13.4.2: {} mz@2.7.0: dependencies: @@ -4546,9 +4535,9 @@ snapshots: typescript@6.0.3: {} - uint8arrays@3.0.0: + uint8arrays@5.1.1: dependencies: - multiformats: 9.9.0 + multiformats: 13.4.2 unicode-canonical-property-names-ecmascript@2.0.1: {} diff --git a/bskyembed/src/components/embed.tsx b/bskyembed/src/components/embed.tsx index 3afa7a611..af7a97b8f 100644 --- a/bskyembed/src/components/embed.tsx +++ b/bskyembed/src/components/embed.tsx @@ -1,5 +1,6 @@ import { AppBskyEmbedExternal, + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -43,6 +44,11 @@ export function Embed({ return } + // Case 1b: Gallery (Photos v2) + if (AppBskyEmbedGallery.isView(content)) { + return + } + // Case 2: External link if (AppBskyEmbedExternal.isView(content)) { return @@ -230,6 +236,8 @@ function Info({children}: {children: ComponentChildren}) { ) } +type GridImage = {thumb: string; alt: string} + function ImageEmbed({ content, labelInfo, @@ -240,20 +248,45 @@ function ImageEmbed({ if (labelInfo) { return {labelInfo} } + return ( + ({thumb: i.thumb, alt: i.alt}))} + /> + ) +} + +function GalleryEmbed({ + content, + labelInfo, +}: { + content: AppBskyEmbedGallery.View + labelInfo?: string +}) { + if (labelInfo) { + return {labelInfo} + } + const images = content.items + .filter(AppBskyEmbedGallery.isViewImage) + .map(i => ({thumb: i.thumbnail, alt: i.alt})) + return +} - switch (content.images.length) { +function ImageGrid({images}: {images: GridImage[]}) { + switch (images.length) { + case 0: + return null case 1: return ( {content.images[0].alt} ) case 2: return (
- {content.images.map((image, i) => ( + {images.map((image, i) => (
{content.images[0].alt}
- {content.images.slice(1).map((image, i) => ( + {images.slice(1).map((image, i) => (
) - case 4: + default: { + const remaining = images.length - 4 return (
- {content.images.map((image, i) => ( - {image.alt} - ))} + {images.slice(0, 4).map((image, i) => { + const isOverflowCell = i === 3 && remaining > 0 + return ( +
+ {image.alt} + {isOverflowCell && ( +
+ + +{remaining} + +
+ )} +
+ ) + })}
) - default: - return null + } } } diff --git a/bskyembed/src/components/post.tsx b/bskyembed/src/components/post.tsx index 4167dc086..46faebc4a 100644 --- a/bskyembed/src/components/post.tsx +++ b/bskyembed/src/components/post.tsx @@ -154,7 +154,9 @@ export function Post({thread}: Props) { } function PostContent({record}: {record: AppBskyFeedPost.Record | null}) { - if (!record) return null + // text-only check - posts with no text (e.g. gallery posts) would otherwise + // render an empty

that adds an extra flex gap above the embed + if (!record?.text) return null const rt = new RichText({ text: record.text, -- 2.51.2 From ba72110a123ac77841f42492ac36bf26343df7d7 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 11 Jun 2026 12:04:46 -0400 Subject: [PATCH 06/64] Fix overlapping touch targets for profile header buttons (#10868) --- src/screens/Profile/Header/ProfileHeaderStandard.tsx | 4 ++++ src/view/com/profile/ProfileMenu.tsx | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index e02375351..72f98d799 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -360,6 +360,10 @@ export function HeaderStandardButtons({ size="small" color="secondary" shape="round" + // expand the 33pt button toward a 44pt touch target, capped + // horizontally at half the 4pt row gap so the target cannot + // overlap the neighboring buttons' own targets + hitSlop={{top: 6, bottom: 6, left: 2, right: 2}} onPress={() => { playHaptic('Light') ax.metric('invite:dialog:open', {logContext: 'ProfileHeader'}) diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 5e6c266ea..9e42db2d6 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -4,7 +4,6 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' -import {HITSLOP_20} from '#/lib/constants' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' @@ -264,7 +263,10 @@ let ProfileMenu = ({ {...props} testID="profileHeaderDropdownBtn" label={l`More options`} - hitSlop={HITSLOP_20} + // hitSlop reaches outside parent views on iOS, so the + // left inset must stay within half of the 4pt row gap or + // it steals taps from the adjacent header button + hitSlop={{top: 6, bottom: 6, left: 2, right: 12}} variant="solid" color="secondary" size="small" -- 2.51.2 From bd034ac1a8430fee0076e94d9ae5eb48454cf2c5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 11 Jun 2026 19:08:28 +0300 Subject: [PATCH 07/64] [Chat] Update single-convo cache on firehose mute/unmute events (#10865) Co-authored-by: Claude Fable 5 --- src/state/queries/messages/list-conversations.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx index d6d34f34d..09410142b 100644 --- a/src/state/queries/messages/list-conversations.tsx +++ b/src/state/queries/messages/list-conversations.tsx @@ -510,13 +510,13 @@ export function ListConvosProviderInner({ }, ) } else if (ChatBskyConvoDefs.isLogMuteConvo(log)) { - updateConvoInAllLists(log.convoId, convo => ({ + mutateConvoView(log.convoId, convo => ({ ...convo, muted: true, rev: log.rev, })) } else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) { - updateConvoInAllLists(log.convoId, convo => ({ + mutateConvoView(log.convoId, convo => ({ ...convo, muted: false, rev: log.rev, -- 2.51.2 From c96d63fa5f375bd19baa6386791e614a060b8c09 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 11 Jun 2026 19:34:18 +0300 Subject: [PATCH 08/64] [Chat] Update bskyogcard lexicon version (#10871) --- Dockerfile.bskyogcard | 2 +- bskyogcard/package.json | 4 +- bskyogcard/pnpm-lock.yaml | 88 +++++++++++++++++++-------------------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/Dockerfile.bskyogcard b/Dockerfile.bskyogcard index 30839617f..dc9747f94 100644 --- a/Dockerfile.bskyogcard +++ b/Dockerfile.bskyogcard @@ -9,7 +9,7 @@ WORKDIR /app COPY ./bskyogcard/package.json ./ COPY ./bskyogcard/pnpm-lock.yaml ./ COPY ./bskyogcard/pnpm-workspace.yaml ./ -RUN npm install --global pnpm@11.1.3 +RUN npm install --global pnpm@11.5.2 RUN pnpm install --frozen-lockfile COPY ./bskyogcard ./ diff --git a/bskyogcard/package.json b/bskyogcard/package.json index 50248e4cf..b6c6342a5 100644 --- a/bskyogcard/package.json +++ b/bskyogcard/package.json @@ -6,7 +6,7 @@ "devEngines": { "packageManager": { "name": "pnpm", - "version": "11.1.3", + "version": "11.5.2", "onFail": "warn" } }, @@ -18,7 +18,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@atproto/api": "0.20.11", + "@atproto/api": "0.20.13", "@atproto/common": "^0.6.1", "@resvg/resvg-js": "^2.6.2", "express": "^4.19.2", diff --git a/bskyogcard/pnpm-lock.yaml b/bskyogcard/pnpm-lock.yaml index 2bebbe145..4c1b4452e 100644 --- a/bskyogcard/pnpm-lock.yaml +++ b/bskyogcard/pnpm-lock.yaml @@ -7,52 +7,52 @@ importers: configDependencies: {} packageManagerDependencies: '@pnpm/exe': - specifier: 11.1.3 - version: 11.1.3 + specifier: 11.5.2 + version: 11.5.2 pnpm: - specifier: 11.1.3 - version: 11.1.3 + specifier: 11.5.2 + version: 11.5.2 packages: - '@pnpm/exe@11.1.3': - resolution: {integrity: sha512-J6bSMpZlHVUKuMKtPT/+lrFPpvBiOIgk6HnNC3vmoM/fsMgFhao6ITFwdsUMzdCl2qHf9cnVYA4ZBdsGmVUnpg==} + '@pnpm/exe@11.5.2': + resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==} hasBin: true - '@pnpm/linux-arm64@11.1.3': - resolution: {integrity: sha512-sz3fc0hSqguk2eGe/InelpBD3LP82MzF+8pLzDpYNGuhasGY+VWkuxEo02HczQYRVXTsbpZVepk+Qs2CONc04Q==} + '@pnpm/linux-arm64@11.5.2': + resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==} cpu: [arm64] os: [linux] - '@pnpm/linux-x64@11.1.3': - resolution: {integrity: sha512-UadJh5fJZWa47OtdZTWLKWDj4z5WpZFB5pS2wOh/kfKypUmQyjmbOMClP7/yJGS2ZtrSjRgYjIEWAjndkWsMaA==} + '@pnpm/linux-x64@11.5.2': + resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==} cpu: [x64] os: [linux] - '@pnpm/linuxstatic-arm64@11.1.3': - resolution: {integrity: sha512-lWmGr96w+VrIRVsEfTXROB3GeQNxrX2Hy32j3USHr6WlqmpH1i7YjavJGpoXeVZbb77fCFjNFAtsJzGXSgy/Og==} + '@pnpm/linuxstatic-arm64@11.5.2': + resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==} cpu: [arm64] os: [linux] libc: [musl] - '@pnpm/linuxstatic-x64@11.1.3': - resolution: {integrity: sha512-I74GDBOPbr5TXgob3ct4hv6BQtLGdsjGJrII2qNl/e2xYHXekjIWE4Eh7RGN4C7xu+BZYKtvnOOr149yR7KxUw==} + '@pnpm/linuxstatic-x64@11.5.2': + resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==} cpu: [x64] os: [linux] libc: [musl] - '@pnpm/macos-arm64@11.1.3': - resolution: {integrity: sha512-nWn155BVa54iNyg4iolVhjMtqumXHPh8ul5CFjQFJXdwgr9MRUgc5EUBML1+7mS8C/7Wcex5HUgn/w3fliHCsQ==} + '@pnpm/macos-arm64@11.5.2': + resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==} cpu: [arm64] os: [darwin] - '@pnpm/win-arm64@11.1.3': - resolution: {integrity: sha512-4u6PQL7/WgwdweC2ZJcdCzhM1koRFG5ofcUSrIVsdyo9ePfGq2D9p1rkZbiLEfIGvl3GKOsfMB5DRTgs8es4AQ==} + '@pnpm/win-arm64@11.5.2': + resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==} cpu: [arm64] os: [win32] - '@pnpm/win-x64@11.1.3': - resolution: {integrity: sha512-JmhH7ljJ3MWjvWFz/YHbu/27ISCNLZsQb1JG+ib3uHlfUwIJuF6BudXYsbS5Uu0o7xUvC9sPPm+Uho4Or6R3vA==} + '@pnpm/win-x64@11.5.2': + resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==} cpu: [x64] os: [win32] @@ -116,45 +116,45 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - pnpm@11.1.3: - resolution: {integrity: sha512-yFNX/hfKEt0j3XBxgiZm39fjy3b+IU4zcLXqL7NPKiMRhVCbY+cX880KyzjdP42CvNXoFyQArmeLcOpPvtCJbQ==} + pnpm@11.5.2: + resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==} engines: {node: '>=22.13'} hasBin: true snapshots: - '@pnpm/exe@11.1.3': + '@pnpm/exe@11.5.2': dependencies: '@reflink/reflink': 0.1.19 detect-libc: 2.1.2 optionalDependencies: - '@pnpm/linux-arm64': 11.1.3 - '@pnpm/linux-x64': 11.1.3 - '@pnpm/linuxstatic-arm64': 11.1.3 - '@pnpm/linuxstatic-x64': 11.1.3 - '@pnpm/macos-arm64': 11.1.3 - '@pnpm/win-arm64': 11.1.3 - '@pnpm/win-x64': 11.1.3 - - '@pnpm/linux-arm64@11.1.3': + '@pnpm/linux-arm64': 11.5.2 + '@pnpm/linux-x64': 11.5.2 + '@pnpm/linuxstatic-arm64': 11.5.2 + '@pnpm/linuxstatic-x64': 11.5.2 + '@pnpm/macos-arm64': 11.5.2 + '@pnpm/win-arm64': 11.5.2 + '@pnpm/win-x64': 11.5.2 + + '@pnpm/linux-arm64@11.5.2': optional: true - '@pnpm/linux-x64@11.1.3': + '@pnpm/linux-x64@11.5.2': optional: true - '@pnpm/linuxstatic-arm64@11.1.3': + '@pnpm/linuxstatic-arm64@11.5.2': optional: true - '@pnpm/linuxstatic-x64@11.1.3': + '@pnpm/linuxstatic-x64@11.5.2': optional: true - '@pnpm/macos-arm64@11.1.3': + '@pnpm/macos-arm64@11.5.2': optional: true - '@pnpm/win-arm64@11.1.3': + '@pnpm/win-arm64@11.5.2': optional: true - '@pnpm/win-x64@11.1.3': + '@pnpm/win-x64@11.5.2': optional: true '@reflink/reflink-darwin-arm64@0.1.19': @@ -194,7 +194,7 @@ snapshots: detect-libc@2.1.2: {} - pnpm@11.1.3: {} + pnpm@11.5.2: {} --- lockfileVersion: '9.0' @@ -208,8 +208,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.11 - version: 0.20.11 + specifier: 0.20.13 + version: 0.20.13 '@atproto/common': specifier: ^0.6.1 version: 0.6.1 @@ -259,8 +259,8 @@ importers: packages: - '@atproto/api@0.20.11': - resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==} + '@atproto/api@0.20.13': + resolution: {integrity: sha512-AN7NTUsygHXnXs5Cnc6v7/BAjQydwAhE0P8hL21RpIAB4fmTksRKTU3TV//wPg4fk9ESsfNwGIAlwoybbyoZKg==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -1154,7 +1154,7 @@ packages: snapshots: - '@atproto/api@0.20.11': + '@atproto/api@0.20.13': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 -- 2.51.2 From b0eaecb01bff6848307e07a3883a283c634a33b4 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 11 Jun 2026 09:45:08 -0700 Subject: [PATCH 09/64] Fix Rescind request button (#10869) --- src/components/intents/GroupChatJoinDialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/components/intents/GroupChatJoinDialog.tsx b/src/components/intents/GroupChatJoinDialog.tsx index f9d6b1104..e3c20b0e5 100644 --- a/src/components/intents/GroupChatJoinDialog.tsx +++ b/src/components/intents/GroupChatJoinDialog.tsx @@ -271,9 +271,10 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { ) } - const convoId = joinLinkPreview.convo?.id + const convoId = joinLinkPreview.convoId const isFollowing = joinLinkPreview.owner.viewer?.following ?? false - const hasRequested = !convoId && joinLinkPreview.viewer?.requestedAt != null + const hasRequested = + !joinLinkPreview.convo && joinLinkPreview.viewer?.requestedAt != null let canJoin = true let ButtonIconImage = isJoinPending || isWithdrawPending ? Loader : JoinIcon @@ -417,7 +418,7 @@ function GroupChatJoinDialogContent({code}: {code?: string}) { - {convoId ? ( + {joinLinkPreview.convo ? ( + ) +} + +/** + * The nested quote of the original message, rendered at the top of a reply + * bubble. Tapping it scrolls to the original (if loaded). + */ +function ReplyQuote({ + replyTo, + isFromSelf, + relatedProfiles, + onPress, +}: { + replyTo: ChatBskyConvoDefs.MessageView | ChatBskyConvoDefs.DeletedMessageView + isFromSelf: boolean + relatedProfiles: Map + onPress: () => void +}) { + const t = useTheme() + const {t: l} = useLingui() + + const senderProfile = useMaybeProfileShadow( + relatedProfiles.get(replyTo.sender.did), + ) + // Hide the quoted content if we block, or are blocked by, the original + // sender - mirroring how the message bubble itself is hidden. + const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false + const senderName = + senderProfile && !isBlocked + ? createSanitizedDisplayName(senderProfile) + : null + + const tintColor = isFromSelf ? t.palette.white : t.atoms.text.color + const subtleColor = isFromSelf + ? t.palette.white + : t.atoms.text_contrast_high.color + const borderColor = isFromSelf + ? utils.alpha(t.palette.white, 0.5) + : t.atoms.border_contrast_high.borderColor + + let text: string + let subtle = false + if (isBlocked) { + text = l`Blocked message hidden` + subtle = true + } else if (ChatBskyConvoDefs.isMessageView(replyTo)) { + text = replyTo.text + if (!text.trim()) { + subtle = true + if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) { + text = l`(chat invite link)` + } else if (AppBskyEmbedRecord.isView(replyTo.embed)) { + text = l`(contains embedded content)` + } else { + text = l`No text` + } + } + } else { + text = l`Deleted message` + subtle = true + } + + return ( + + ) +} diff --git a/src/components/dms/MessageReplies.tsx b/src/components/dms/MessageReplies.tsx new file mode 100644 index 000000000..5093d8d15 --- /dev/null +++ b/src/components/dms/MessageReplies.tsx @@ -0,0 +1,124 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import {type ChatBskyConvoDefs} from '@atproto/api' + +/** + * How long a message stays highlighted after scrolling to it, before the flash + * fades out. + */ +export const MESSAGE_HIGHLIGHT_DURATION_MS = 1500 + +type HighlightedMessage = { + id: string + /** + * Bumped on every highlight so that re-tapping the same reply re-triggers the + * flash even while the previous highlight is still active. + */ + key: number +} + +type MessageRepliesContextType = { + /** + * The message currently staged for reply in the composer, or null. + */ + replyTo: ChatBskyConvoDefs.MessageView | null + setReply: (message: ChatBskyConvoDefs.MessageView) => void + clearReply: () => void + /** + * Scroll the list to a message, if it's currently loaded, and flash it. No-op + * otherwise. + */ + scrollToMessage: (messageId: string) => void + /** + * The message to flash, or null. Consumers compare against their own id. + */ + highlightedMessage: HighlightedMessage | null +} + +const Context = createContext(null) + +export function useMessageReplies() { + const ctx = useContext(Context) + if (!ctx) { + throw new Error( + 'useMessageReplies must be used within a MessageRepliesProvider', + ) + } + return ctx +} + +export function MessageRepliesProvider({ + children, + scrollToMessage: scrollToMessageRaw, +}: { + children: React.ReactNode + /** + * Performs the actual scroll. Returns true if the message was found and + * scrolled to, false if it isn't currently loaded (so we know whether to + * flash it). + */ + scrollToMessage: (messageId: string) => boolean +}) { + const [replyTo, setReplyTo] = useState( + null, + ) + const [highlightedMessage, setHighlightedMessage] = + useState(null) + const highlightKey = useRef(0) + const clearHighlightTimeout = useRef | null>( + null, + ) + + const setReply = useCallback((message: ChatBskyConvoDefs.MessageView) => { + setReplyTo(message) + }, []) + + const clearReply = useCallback(() => { + setReplyTo(null) + }, []) + + const scrollToMessage = useCallback( + (messageId: string) => { + const didScroll = scrollToMessageRaw(messageId) + if (!didScroll) return + + highlightKey.current += 1 + setHighlightedMessage({id: messageId, key: highlightKey.current}) + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + clearHighlightTimeout.current = setTimeout(() => { + setHighlightedMessage(null) + }, MESSAGE_HIGHLIGHT_DURATION_MS) + }, + [scrollToMessageRaw], + ) + + useEffect(() => { + return () => { + if (clearHighlightTimeout.current) { + clearTimeout(clearHighlightTimeout.current) + } + } + }, []) + + const ctx = useMemo( + () => ({ + replyTo, + setReply, + clearReply, + scrollToMessage, + highlightedMessage, + }), + [replyTo, setReply, clearReply, scrollToMessage, highlightedMessage], + ) + + return {children} +} diff --git a/src/components/icons/ArrowCornerDownRight.tsx b/src/components/icons/ArrowCornerDownRight.tsx index 86dde7015..85346f270 100644 --- a/src/components/icons/ArrowCornerDownRight.tsx +++ b/src/components/icons/ArrowCornerDownRight.tsx @@ -5,3 +5,9 @@ export const ArrowCornerDownRight_Stroke2_Corner2_Rounded = createSinglePathSVG( path: 'M15.793 10.293a1 1 0 0 1 1.338-.068l.076.068 3.293 3.293a2 2 0 0 1 .138 2.677l-.138.151-3.293 3.293a1 1 0 1 1-1.414-1.414L18.086 16H8a5 5 0 0 1-5-5V5a1 1 0 0 1 2 0v6a3 3 0 0 0 3 3h10.086l-2.293-2.293-.068-.076a1 1 0 0 1 .068-1.338Z', }, ) + +export const ArrowCornerDownRight_Stroke2_Corner3_Rounded = createSinglePathSVG( + { + path: 'M5 5a1 1 0 0 0-2 0v4a7 7 0 0 0 7 7h8.086l-2.293 2.293a1 1 0 0 0 1.414 1.414l2.94-2.94a2.5 2.5 0 0 0 0-3.535l-2.94-2.94a1 1 0 1 0-1.414 1.415L18.086 14H10a5 5 0 0 1-5-5V5Z', + }, +) diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 36c0a1ec2..1e2f4037a 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -262,7 +262,6 @@ function InnerReady({ {IS_LIQUID_GLASS ? ( {header} diff --git a/src/screens/Messages/components/MessageComposer.tsx b/src/screens/Messages/components/MessageComposer.tsx index 83bab02be..926a3cccc 100644 --- a/src/screens/Messages/components/MessageComposer.tsx +++ b/src/screens/Messages/components/MessageComposer.tsx @@ -1,4 +1,4 @@ -import {useRef, useState} from 'react' +import {useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' import { useKeyboardHandler, @@ -13,6 +13,7 @@ import Animated, { import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' import {LinearGradient} from 'expo-linear-gradient' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {ScrollEdgeEffect} from '@bsky.app/expo-scroll-edge-effect' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -28,6 +29,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf' import {Composer, useComposerInternalApiRef} from '#/components/Composer' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {GlassView} from '#/components/GlassView' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' @@ -47,7 +49,10 @@ export function MessageComposer({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -60,10 +65,16 @@ export function MessageComposer({ const editable = !needsEmailVerification && !loading const {getDraft, clearDraft} = useMessageDraft() const composerInternalApiRef = useComposerInternalApiRef() + const {replyTo, clearReply} = useMessageReplies() const [text, setText] = useState(getDraft) useSaveMessageDraft(text) + useEffect(() => { + if (!replyTo) return + composerInternalApiRef.current?.input?.focus() + }, [replyTo, composerInternalApiRef]) + // Android interactive dismiss sometimes doesn't blur the input const blur = useNonReactiveCallback(() => { composerInternalApiRef.current?.input?.blur() @@ -80,7 +91,10 @@ export function MessageComposer({ const submitDisabled = !editable || (!hasEmbed && text.trim().length === 0) - const onSubmit = (message: string) => { + const onSubmit = ( + message: string, + replyTo: ChatBskyConvoDefs.MessageView | null, + ) => { if (!editable) return if (!hasEmbed && message.trim() === '') return const graphemeCount = countGraphemes(message) @@ -95,6 +109,7 @@ export function MessageComposer({ clearDraft() playHaptic() setEmbed(undefined) + clearReply() composerInternalApiRef.current?.clear() if (IS_WEB) { @@ -103,7 +118,15 @@ export function MessageComposer({ // defer send by a frame so that the textinput resizes before we send the message requestAnimationFrame(() => { - onSendMessage(message) + onSendMessage( + message, + replyTo + ? { + ...replyTo, + $type: 'chat.bsky.convo.defs#messageView', + } + : undefined, + ) }) } @@ -129,18 +152,18 @@ export function MessageComposer({ setTimeout(() => { if (isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(text) + onSubmit(text, replyTo) } }, 20) } else { - onSubmit(text) + onSubmit(text, replyTo) } } const handleChange = (nextText: string) => { if (IS_IOS && isFlushingAutocorrectSuggestion.current) { isFlushingAutocorrectSuggestion.current = false - onSubmit(nextText) + onSubmit(nextText, replyTo) } else { setText(nextText) } diff --git a/src/screens/Messages/components/MessageInput.tsx b/src/screens/Messages/components/MessageInput.tsx index dbe716efb..cbd47151d 100644 --- a/src/screens/Messages/components/MessageInput.tsx +++ b/src/screens/Messages/components/MessageInput.tsx @@ -15,6 +15,7 @@ import Animated, { } from 'react-native-reanimated' import {useSafeAreaInsets} from 'react-native-safe-area-context' import {GlassContainer} from 'expo-glass-effect' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {countGraphemes} from 'unicode-segmenter/grapheme' @@ -26,6 +27,7 @@ import { useSaveMessageDraft, } from '#/state/messages/message-drafts' import {atoms as a, platform, tokens, useTheme} from '#/alf' +import {useMessageReplies} from '#/components/dms/MessageReplies' import {GlassView} from '#/components/GlassView' import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane' import {Loader} from '#/components/Loader' @@ -47,7 +49,10 @@ export function MessageInput({ loading = false, }: { textInputId?: string - onSendMessage: (message: string) => Promise | void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise | void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -57,6 +62,7 @@ export function MessageInput({ const t = useTheme() const playHaptic = useHaptics() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() // Input layout const {top: topInset} = useSafeAreaInsets() @@ -92,6 +98,9 @@ export function MessageInput({ playHaptic() setEmbed(undefined) setMessage('') + // Capture the reply before clearing - the deferred send below reads it. + const reply = replyTo + clearReply() if (IS_IOS) { setShouldEnforceClear(true) } @@ -104,7 +113,12 @@ export function MessageInput({ } requestAnimationFrame(() => { - void onSendMessage(message) + void onSendMessage( + message, + reply + ? {...reply, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) }) }, [ editable, @@ -116,6 +130,8 @@ export function MessageInput({ setEmbed, inputRef, l, + replyTo, + clearReply, ]) useFocusedInputHandler( diff --git a/src/screens/Messages/components/MessageInput.web.tsx b/src/screens/Messages/components/MessageInput.web.tsx index 17c996cec..2fe3d86fa 100644 --- a/src/screens/Messages/components/MessageInput.web.tsx +++ b/src/screens/Messages/components/MessageInput.web.tsx @@ -1,5 +1,6 @@ import {useCallback, useRef, useState} from 'react' import {Pressable, View} from 'react-native' +import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' import {flushSync} from 'react-dom' import TextareaAutosize from 'react-textarea-autosize' @@ -13,6 +14,7 @@ import { } from '#/state/messages/message-drafts' import {atoms as a, flatten, useTheme} from '#/alf' import {Button} from '#/components/Button' +import {useMessageReplies} from '#/components/dms/MessageReplies' import * as EmojiPicker from '#/components/EmojiPicker' import {useSharedInputStyles} from '#/components/forms/TextField' import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji' @@ -28,7 +30,10 @@ export function MessageInput({ children, loading = false, }: { - onSendMessage: (message: string) => void + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => void hasEmbed: boolean setEmbed: (embedUrl: string | undefined) => void children?: React.ReactNode @@ -38,6 +43,7 @@ export function MessageInput({ const {t: l} = useLingui() const t = useTheme() const {getDraft, clearDraft} = useMessageDraft() + const {replyTo, clearReply} = useMessageReplies() const [message, setMessage] = useState(getDraft) const inputStyles = useSharedInputStyles() @@ -58,10 +64,25 @@ export function MessageInput({ return } clearDraft() - onSendMessage(message) + onSendMessage( + message, + replyTo + ? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'} + : undefined, + ) + clearReply() setMessage('') setEmbed(undefined) - }, [message, onSendMessage, l, clearDraft, hasEmbed, setEmbed]) + }, [ + message, + onSendMessage, + l, + clearDraft, + hasEmbed, + setEmbed, + replyTo, + clearReply, + ]) const onKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/src/screens/Messages/components/MessageInputReply.tsx b/src/screens/Messages/components/MessageInputReply.tsx new file mode 100644 index 000000000..5853e46d8 --- /dev/null +++ b/src/screens/Messages/components/MessageInputReply.tsx @@ -0,0 +1,91 @@ +import {LayoutAnimation, View} from 'react-native' +import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_20} from '#/lib/constants' +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {useConvoActive} from '#/state/messages/convo' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useMessageReplies} from '#/components/dms/MessageReplies' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Text} from '#/components/Typography' + +/** + * The reply staged in the message composer. Renders a preview of the message + * being replied to, with a button to cancel the reply. + */ +export function MessageInputReply() { + const t = useTheme() + const {t: l} = useLingui() + const convo = useConvoActive() + const {replyTo, clearReply} = useMessageReplies() + + if (!replyTo) { + return null + } + + const onRemove = () => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + clearReply() + } + + const senderProfile = convo.relatedProfiles.get(replyTo.sender.did) + const displayName = senderProfile + ? createSanitizedDisplayName(senderProfile, false) + : null + + let text = replyTo.text + let subtle = false + if (!text.trim()) { + subtle = true + if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) { + text = l`(chat invite link)` + } else if (AppBskyEmbedRecord.isView(replyTo.embed)) { + text = l`(contains embedded content)` + } else { + text = l`No text` + } + } + + return ( + + + {displayName && ( + + {displayName} + + )} + + {text} + + + + + ) +} diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx index f0b1389b8..be8ebcb8a 100644 --- a/src/screens/Messages/components/MessagesList.tsx +++ b/src/screens/Messages/components/MessagesList.tsx @@ -35,6 +35,7 @@ import { } from '@atproto/api' import {useScrollEdgeEffectRef} from '@bsky.app/expo-scroll-edge-effect' +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {mergeRefs} from '#/lib/merge-refs' import {ScrollProvider} from '#/lib/ScrollContext' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' @@ -62,6 +63,7 @@ import {atoms as a, platform, tokens, useTheme, web} from '#/alf' import {DateDivider} from '#/components/dms/DateDivider' import {MessageItem} from '#/components/dms/MessageItem' import {MessageOverlays} from '#/components/dms/MessageOverlays' +import {MessageRepliesProvider} from '#/components/dms/MessageReplies' import {NewMessagesPill} from '#/components/dms/NewMessagesPill' import {SystemMessageGroup} from '#/components/dms/SystemMessageGroup' import {SystemMessageItem} from '#/components/dms/SystemMessageItem' @@ -72,7 +74,12 @@ import {IS_ANDROID, IS_NATIVE, IS_WEB} from '#/env' import {ChatStatusInfo} from './ChatStatusInfo' import {groupSystemMessages, type RenderItem} from './groupSystemMessages' import {InviteLinkDialogProvider} from './InviteLinkDialogProvider' -import {MessageInputEmbed, useMessageEmbed} from './MessageInputEmbed' +import { + type MessageEmbedState, + MessageInputEmbed, + useMessageEmbed, +} from './MessageInputEmbed' +import {MessageInputReply} from './MessageInputReply' import {MessagesListGroupInfoPanel} from './MessagesListGroupInfoPanel' import {MessagesListInfoPanel} from './MessagesListInfoPanel' import {KeyboardStickyView} from './vendor/KeyboardStickyView' @@ -371,7 +378,7 @@ export function MessagesList({ // -- Message sending const onSendMessage = useCallback( - async (text: string) => { + async (text: string, reply?: $Typed) => { let rt = new RichText({text: text.trimEnd()}, {cleanNewlines: true}) // detect facets without resolution first - this is used to see if there's @@ -387,6 +394,7 @@ export function MessagesList({ | $Typed | $Typed | undefined + let replyTo: ChatBskyConvoDefs.ReplyRef | undefined // Find the embedded link facet and, if it's at the start or end of the // message, remove it from the text (the embed card replaces it). @@ -455,6 +463,10 @@ export function MessagesList({ stripLinkFacet(uri => getChatInviteCodeFromUrl(uri) === code) } + if (reply) { + replyTo = {messageId: reply.id} + } + await rt.detectFacets(agent) rt = shortenLinks(rt) @@ -469,10 +481,18 @@ export function MessagesList({ text: rt.text, facets: rt.facets, embed, + replyTo, }, embedView, + reply, ) + if (replyTo) { + ax.metric('chat:message:reply:send', { + convoId: convoState.convo.view.id, + isGroup: convoState.convo.kind === 'group', + }) + } if (convoState.convo.kind === 'group') { ax.metric('groupchat:message:send', { convoId: convoState.convo.view.id, @@ -510,6 +530,29 @@ export function MessagesList({ }) }, [flatListRef]) + // Scroll to a message by id, if it's currently loaded in the list. Per the + // feature scope, we don't fetch history to find unloaded messages - tapping a + // reply to an out-of-window message is a no-op. Returns whether the message + // was found, so the caller knows whether to flash it. + const scrollToMessage = useNonReactiveCallback((messageId: string) => { + const index = renderItems.findIndex( + item => + (item.type === 'message' || + item.type === 'pending-message' || + item.type === 'deleted-message') && + item.message.id === messageId, + ) + if (index === -1) return false + + ax.metric('chat:message:reply:tap', {convoId: convoState.convo.view.id}) + flatListRef.current?.scrollToIndex({ + index, + viewPosition: 0.3, + animated: true, + }) + return true + }) + const renderItem = ({item, index}: {item: RenderItem; index: number}) => { if (item.type === 'message' || item.type === 'pending-message') { return ( @@ -561,138 +604,180 @@ export function MessagesList({ return ( - - - {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} - - - - - {convoState.hasAllHistory ? ( - convoState.convo?.kind === 'group' ? ( - - ) : ( - - ) - ) : null} - - } - // native only (prop is not supported on web) - renderScrollComponent={renderScrollComponent} - contentContainerStyle={{ - paddingBottom: platform({ - // ios is slightly larger as the input has no top padding - ios: tokens.space.lg, - android: tokens.space.md, - web: 0, // web uses ListFooterComponent instead for scroll reasons - }), - }} - ListFooterComponent={ - - } - style={[ - web({ - scrollbarWidth: 'thin', - scrollbarColor: `${t.palette.contrast_100} transparent`, - scrollbarGutter: 'stable', - }), - ]} - pointerEvents={!hasScrolled ? 'none' : 'auto'} - contentInset={{top: transparentHeaderHeight}} - scrollIndicatorInsets={{top: transparentHeaderHeight}} - /> - - - - {footer ?? ( - - - {({loading}) => - ax.features.enabled( - ax.features.DmsNewMessageComposerEnable, - ) ? ( - - void onSendMessage(message) - } - hasEmbed={!!messageEmbed} - setEmbed={setEmbed} - loading={loading}> - - - ) : ( - + + + {/* Custom scroll provider so that we can use the `onScroll` event in our custom List implementation */} + + + + + {convoState.hasAllHistory ? ( + convoState.convo?.kind === 'group' ? ( + + ) : ( + + ) + ) : null} + + } + // native only (prop is not supported on web) + renderScrollComponent={renderScrollComponent} + contentContainerStyle={{ + paddingBottom: platform({ + // ios is slightly larger as the input has no top padding + ios: tokens.space.lg, + android: tokens.space.md, + web: 0, // web uses ListFooterComponent instead for scroll reasons + }), + }} + ListFooterComponent={ + + } + style={[ + web({ + scrollbarWidth: 'thin', + scrollbarColor: `${t.palette.contrast_100} transparent`, + scrollbarGutter: 'stable', + }), + ]} + pointerEvents={!hasScrolled ? 'none' : 'auto'} + contentInset={{top: transparentHeaderHeight}} + scrollIndicatorInsets={{top: transparentHeaderHeight}} + /> + + + + {footer ?? ( + + + {({loading}) => ( + - - - ) - } - - - )} - - - - {newMessagesPill.show && ( - - )} - + loading={loading} + useNewComposer={ax.features.enabled( + ax.features.DmsNewMessageComposerEnable, + )} + /> + )} + + + )} + + + + {newMessagesPill.show && ( + + )} + + ) } +/** + * Picks the new vs legacy composer and mounts the reply preview alongside the + * existing embed preview in the composer's children slot. The staged reply + * itself is read and cleared inside the composer via `useMessageReplies`. + */ +function Composer({ + textInputId, + onSendMessage, + messageEmbed, + setEmbed, + loading, + useNewComposer, +}: { + textInputId: string + onSendMessage: ( + message: string, + replyTo?: $Typed, + ) => Promise + messageEmbed: MessageEmbedState | undefined + setEmbed: (embedUrl: string | undefined) => void + loading?: boolean + useNewComposer: boolean +}) { + const handleSendMessage = useNonReactiveCallback( + (message: string, replyTo?: $Typed) => { + void onSendMessage(message, replyTo) + }, + ) + + const previews = ( + <> + + + + ) + + return useNewComposer ? ( + + {previews} + + ) : ( + + {previews} + + ) +} + /** Note: native only */ function ChatScrollComponent({ ref, diff --git a/src/state/messages/convo/agent.ts b/src/state/messages/convo/agent.ts index 3603026d7..b1dc348e7 100644 --- a/src/state/messages/convo/agent.ts +++ b/src/state/messages/convo/agent.ts @@ -86,6 +86,22 @@ function toSystemMessageView( return ev.message } +/** + * Derive a deleted-message tombstone from a (now-deleted) message, preserving + * the fields the deleted view carries so a reply can render it as deleted. + */ +function toDeletedMessageView( + m: ChatBskyConvoDefs.MessageView, +): $Typed { + return { + $type: 'chat.bsky.convo.defs#deletedMessageView', + id: m.id, + rev: m.rev, + sender: m.sender, + sentAt: m.sentAt, + } +} + export class Convo { private id: string @@ -119,6 +135,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed + optimisticReplyTo?: $Typed } > = new Map() private deletedMessages: Set = new Set() @@ -802,6 +819,12 @@ export class Convo { }) const {cursor, messages, relatedProfiles} = response.data + // Trust the cursor for pagination. We can't infer "no more pages" from a + // short page: the server pages by raw rows but strips deleted messages + // from the response, so a full page containing a deleted message (e.g. + // from a deleted account) comes back short *with* a valid cursor. Using a + // count heuristic here would stop history fetching early and hide + // messages. The tradeoff is one extra empty fetch at the true top. this.oldestRev = cursor ?? null if (relatedProfiles) { @@ -811,14 +834,6 @@ export class Convo { this.applyProfileShadows() } - /* - * If the response contained fewer messages than the limit, we know - * there are no more pages, regardless of whether a cursor was returned. - */ - if (messages.length < (IS_NATIVE ? 30 : 60)) { - this.oldestRev = null - } - for (const message of messages) { if ( ChatBskyConvoDefs.isMessageView(message) || @@ -965,17 +980,17 @@ export class Convo { ChatBskyConvoDefs.isDeletedMessageView(ev.message) ) { /* - * Update if we have this in state. If we don't, don't worry about it. + * Remove the message itself, and keep its id in `deletedMessages` + * so any message that quotes it keeps rendering a deleted-message + * tombstone (see `tombstoneDeletedReplyTo`) rather than reverting + * to the original hydrated text. We add here rather than relying on + * the optimistic entry so deletes from elsewhere (e.g. another + * device) are covered too. */ - if ( - this.pastMessages.has(ev.message.id) || - this.newMessages.has(ev.message.id) - ) { - this.pastMessages.delete(ev.message.id) - this.newMessages.delete(ev.message.id) - this.deletedMessages.delete(ev.message.id) - needsCommit = true - } + this.pastMessages.delete(ev.message.id) + this.newMessages.delete(ev.message.id) + this.deletedMessages.add(ev.message.id) + needsCommit = true } else if ( (ChatBskyConvoDefs.isLogAddReaction(ev) || ChatBskyConvoDefs.isLogRemoveReaction(ev)) && @@ -1020,6 +1035,7 @@ export class Convo { optimisticEmbedView?: | $Typed | $Typed, + optimisticReplyTo?: $Typed, ) { // Ignore empty messages for now since they have no other purpose atm if (!message.text.trim() && !message.embed) return @@ -1033,6 +1049,7 @@ export class Convo { id: tempId, message, optimisticEmbedView, + optimisticReplyTo, }) if (this.convo?.view.status === 'request') { this.updateConvo({ @@ -1316,6 +1333,26 @@ export class Convo { } } + /** + * When a message is deleted locally, it's removed from the list, but other + * messages that reply to it still carry a hydrated `replyTo` with the + * original text until the server re-sends them. Swap that `replyTo` for a + * deleted-message tombstone so the quote reflects the deletion immediately, + * matching what the server returns on refresh. + */ + private tombstoneDeletedReplyTo( + m: ChatBskyConvoDefs.MessageView, + ): ChatBskyConvoDefs.MessageView { + const {replyTo} = m + if ( + !ChatBskyConvoDefs.isMessageView(replyTo) || + !this.deletedMessages.has(replyTo.id) + ) { + return m + } + return {...m, replyTo: toDeletedMessageView(replyTo)} + } + /* * Items in reverse order, since FlatList inverts */ @@ -1327,7 +1364,7 @@ export class Convo { items.unshift({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.unshift({ @@ -1360,7 +1397,7 @@ export class Convo { items.push({ type: 'message', key: m.id, - message: m, + message: this.tombstoneDeletedReplyTo(m), }) } else if (ChatBskyConvoDefs.isDeletedMessageView(m)) { items.push({ @@ -1378,12 +1415,17 @@ export class Convo { }) this.pendingMessages.forEach(m => { + const optimisticReplyTo = + m.optimisticReplyTo && this.deletedMessages.has(m.optimisticReplyTo.id) + ? toDeletedMessageView(m.optimisticReplyTo) + : m.optimisticReplyTo items.push({ type: 'pending-message', key: m.id, message: { ...m.message, embed: m.optimisticEmbedView, + replyTo: optimisticReplyTo, $type: 'chat.bsky.convo.defs#messageView', id: nanoid(), rev: '__fake__', diff --git a/src/state/messages/convo/types.ts b/src/state/messages/convo/types.ts index c83fbfb81..e330d6eea 100644 --- a/src/state/messages/convo/types.ts +++ b/src/state/messages/convo/types.ts @@ -113,6 +113,7 @@ type SendMessage = ( | $Typed | $Typed | undefined, + optimisticReplyTo?: $Typed, ) => void type FetchMessageHistory = () => Promise type MarkConvoAccepted = () => void diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx index 787ca9e27..441297f12 100644 --- a/src/view/com/util/List.web.tsx +++ b/src/view/com/util/List.web.tsx @@ -30,6 +30,15 @@ export type ListMethods = { scrollToTop: () => void scrollToOffset: (options: {animated: boolean; offset: number}) => void scrollToEnd: (options?: {animated?: boolean}) => void + // Signature kept compatible with FlatList's scrollToIndex (the native + // ListMethods type) so callers stay platform-agnostic. viewOffset is + // accepted for parity but not currently used by the web implementation. + scrollToIndex: (params: { + animated?: boolean | null + index: number + viewOffset?: number + viewPosition?: number + }) => void } export type ListProps = Omit< FlatListProps, @@ -216,6 +225,22 @@ function ListImpl( }, [disableFullWindowScroll]) const nativeRef = useRef(null) + + // Registry of item index -> row DOM node. The list renders header/footer and + // visibility-detector siblings too, so we can't index into the container's + // children directly; each Row registers its own node here keyed by index. + const rowNodesRef = useRef>(new Map()) + const registerRowNode = useCallback( + (index: number, node: HTMLElement | null) => { + if (node) { + rowNodesRef.current.set(index, node) + } else { + rowNodesRef.current.delete(index) + } + }, + [], + ) + useImperativeHandle( ref, () => ({ @@ -239,6 +264,17 @@ function ListImpl( behavior: animated ? 'smooth' : 'instant', }) }, + + scrollToIndex({animated = true, index}) { + const node = rowNodesRef.current.get(index) + // scrollIntoView with block: 'center' roughly matches the caller's + // viewPosition of 0.3 - not exact, but close enough and it respects + // whichever element is the scroll container (window or nativeRef). + node?.scrollIntoView({ + block: 'center', + behavior: animated ? 'smooth' : 'instant', + }) + }, }), [getScrollableNode], ) @@ -392,6 +428,7 @@ function ListImpl( renderItem={renderItem} extraData={extraData} onItemSeen={onItemSeen} + registerRowNode={registerRowNode} /> ) })} @@ -470,6 +507,7 @@ let Row = function RowImpl({ renderItem, extraData: _unused, onItemSeen, + registerRowNode, }: { item: ItemT index: number @@ -479,6 +517,7 @@ let Row = function RowImpl({ | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }): React.ReactNode { const rowRef = useRef(null) const intersectionTimeout = useRef | undefined>( @@ -529,6 +568,15 @@ let Row = function RowImpl({ } }, [handleIntersection, onItemSeen]) + // Register this row's DOM node so the list can scroll to it by index. + useEffect(() => { + const node: HTMLElement | null = rowRef.current + registerRowNode(index, node) + return () => { + registerRowNode(index, null) + } + }, [index, registerRowNode]) + if (!renderItem) { return null } @@ -552,6 +600,7 @@ Row = memo(Row) as (props: { | ((info: ListRenderItemInfo) => React.ReactNode) extraData: unknown onItemSeen: ((item: ItemT) => void) | undefined + registerRowNode: (index: number, node: HTMLElement | null) => void }) => React.ReactNode let Visibility = ({ -- 2.51.2 From bb6847f39626dacd611826907e88813eb7da21a4 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 16 Jun 2026 20:41:05 +0300 Subject: [PATCH 44/64] Replace Add User to Lists Modal with modern Dialog (#9677) Co-authored-by: Claude Haiku 4.5 Co-authored-by: Eric Bailey --- __e2e__/flows/curate-lists.yml | 8 +- eslint-suppressions.json | 22 -- .../lists/ListAddRemoveUsersDialog.tsx | 32 +- .../lists/UserAddRemoveListsDialog.tsx | 331 ++++++++++++++++++ .../Messages/components/RequestButtons.tsx | 2 +- src/state/modals/index.tsx | 14 +- src/state/queries/list-memberships.ts | 150 +------- src/state/queries/lists-with-membership.ts | 122 +++++++ src/view/com/lists/ListMembers.tsx | 211 ++++++----- src/view/com/modals/Modal.tsx | 6 +- src/view/com/modals/Modal.web.tsx | 7 +- src/view/com/modals/UserAddRemoveLists.tsx | 299 ---------------- src/view/com/profile/ProfileMenu.tsx | 21 +- 13 files changed, 633 insertions(+), 592 deletions(-) create mode 100644 src/components/dialogs/lists/UserAddRemoveListsDialog.tsx create mode 100644 src/state/queries/lists-with-membership.ts delete mode 100644 src/view/com/modals/UserAddRemoveLists.tsx diff --git a/__e2e__/flows/curate-lists.yml b/__e2e__/flows/curate-lists.yml index 906199e97..cc97b0fd7 100644 --- a/__e2e__/flows/curate-lists.yml +++ b/__e2e__/flows/curate-lists.yml @@ -145,21 +145,21 @@ appId: xyz.blueskyweb.app id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "profileHeaderDropdownBtn" - tapOn: "Add to Lists" - assertVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" - tapOn: id: "user-bob.test-addBtn" - tapOn: id: "doneBtn" - assertNotVisible: - id: "userAddRemoveListsModal" + id: "userAddRemoveListsDialog" diff --git a/eslint-suppressions.json b/eslint-suppressions.json index a3cbe6000..1e8edd043 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1836,11 +1836,6 @@ "count": 1 } }, - "src/state/queries/list-memberships.ts": { - "@typescript-eslint/no-floating-promises": { - "count": 2 - } - }, "src/state/queries/list.ts": { "@typescript-eslint/no-floating-promises": { "count": 7 @@ -2163,20 +2158,6 @@ "count": 2 } }, - "src/view/com/lists/ListMembers.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - }, - "@typescript-eslint/no-floating-promises": { - "count": 1 - }, - "@typescript-eslint/no-misused-promises": { - "count": 2 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 2 - } - }, "src/view/com/lists/MyLists.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -2208,9 +2189,6 @@ }, "@typescript-eslint/require-await": { "count": 1 - }, - "react-hooks/refs": { - "count": 1 } }, "src/view/com/notifications/NotificationFeed.tsx": { diff --git a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx index da9656565..8129e9bdb 100644 --- a/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx +++ b/src/components/dialogs/lists/ListAddRemoveUsersDialog.tsx @@ -7,10 +7,8 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useAllListMembersQuery} from '#/state/queries/list-members' import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, useListMembershipAddMutation, useListMembershipRemoveMutation, } from '#/state/queries/list-memberships' @@ -61,7 +59,7 @@ function DialogInner({ }) { const {_} = useLingui() const moderationOpts = useModerationOpts() - const {data: memberships} = useDangerousListMembershipsQuery() + const {data: listMembers} = useAllListMembersQuery(list.uri) const renderProfileCard = useCallback( (item: ProfileItem) => { @@ -69,13 +67,13 @@ function DialogInner({ ) }, - [onChange, memberships, list, moderationOpts], + [onChange, listMembers, list, moderationOpts], ) return ( @@ -86,16 +84,30 @@ function DialogInner({ ) } +/** + * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) + */ +function getMembership( + listMembers: AppBskyGraphDefs.ListItemView[] | undefined, + actorDid: string, +): string | false | undefined { + if (!listMembers) { + return undefined + } + const member = listMembers.find(item => item.subject.did === actorDid) + return member ? member.uri : false +} + function UserResult({ profile, list, - memberships, + listMembers, onChange, moderationOpts, }: { profile: bsky.profile.AnyProfileView list: AppBskyGraphDefs.ListView - memberships: ListMembersip[] | undefined + listMembers: AppBskyGraphDefs.ListItemView[] | undefined onChange?: ( type: 'add' | 'remove', profile: bsky.profile.AnyProfileView, @@ -104,8 +116,8 @@ function UserResult({ }) { const {_} = useLingui() const membership = useMemo( - () => getMembership(memberships, list.uri, profile.did), - [memberships, list.uri, profile.did], + () => getMembership(listMembers, profile.did), + [listMembers, profile.did], ) const {mutate: listMembershipAdd, isPending: isAddingPending} = useListMembershipAddMutation({ diff --git a/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx new file mode 100644 index 000000000..de8f453d5 --- /dev/null +++ b/src/components/dialogs/lists/UserAddRemoveListsDialog.tsx @@ -0,0 +1,331 @@ +import {View} from 'react-native' +import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {isNetworkError} from '#/lib/strings/errors' +import {sanitizeHandle} from '#/lib/strings/handles' +import {logger} from '#/logger' +import { + useListMembershipAddMutation, + useListMembershipRemoveMutation, +} from '#/state/queries/list-memberships' +import { + type ListWithMembership, + removeListMembershipOptimistically, + updateListMembershipOptimistically, + useListsWithMembershipQuery, +} from '#/state/queries/lists-with-membership' +import {useSession} from '#/state/session' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, platform, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {BulletList_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/BulletList' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export type UserAddRemoveListsDialogProps = { + control: Dialog.DialogControlProps + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +} + +export function UserAddRemoveListsDialog({ + control, + profile, + onAdd, + onRemove, +}: UserAddRemoveListsDialogProps) { + return ( + + + + + ) +} + +function Empty() { + const t = useTheme() + + return ( + + + + + You have no lists. + + + + ) +} + +function ListsContent({ + profile, + onAdd, + onRemove, +}: { + profile: bsky.profile.AnyProfileView | undefined + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const t = useTheme() + const control = Dialog.useDialogContext() + const {t: l} = useLingui() + const {gtMobile} = useBreakpoints() + + const { + data, + isError, + isLoading, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + } = useListsWithMembershipQuery({actor: profile?.did}) + + const listItems = data?.pages.flatMap(page => page.listsWithMembership) || [] + + const onEndReached = async () => { + if (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + // Error handling is optional since this is just pagination + } + } + + const renderItem = ({item}: {item: ListWithMembership}) => + profile ? ( + + ) : null + + const onClose = () => { + control.close() + } + + const listHeader = ( + + + {profile ? ( + + Update {createSanitizedDisplayName(profile, true)} in Lists + + ) : ( + Update in Lists + )} + + + + ) + + return ( + ( + + + + ) + : renderItem + } + keyExtractor={ + isLoading + ? () => 'lists_dialog_loader' + : (item: ListWithMembership) => item.list.uri + } + onEndReached={() => void onEndReached()} + onEndReachedThreshold={0.1} + stickyHeaderIndices={web([0])} + ListHeaderComponent={listHeader} + ListFooterComponent={ + isFetchingNextPage ? ( + + + + ) : null + } + ListEmptyComponent={!isLoading && data ? : null} + webInnerContentContainerStyle={[a.py_0]} + style={platform({ + web: [a.px_2xl, a.pb_md], + native: [a.px_2xl, a.pt_lg], + })} + /> + ) +} + +function ListItem({ + listWithMembership, + profile, + onAdd, + onRemove, +}: { + listWithMembership: ListWithMembership + profile: bsky.profile.AnyProfileView + onAdd?: (listUri: string) => void + onRemove?: (listUri: string) => void +}) { + const {t: l} = useLingui() + const t = useTheme() + const queryClient = useQueryClient() + const {currentAccount} = useSession() + + const list = listWithMembership.list + const listItem = listWithMembership.listItem + const isMember = !!listItem + + const {mutate: addMembership, isPending: isPendingAdd} = + useListMembershipAddMutation({ + subject: profile, + onSuccess: data => { + Toast.show(l`Added to list`) + onAdd?.(list.uri) + updateListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + membershipUri: data.uri, + subject: { + did: profile.did, + handle: profile.handle, + displayName: profile.displayName, + }, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to add to list', {safeMessage: err}) + } + Toast.show(l`Failed to add to list`, {type: 'error'}) + }, + }) + + const {mutate: removeMembership, isPending: isPendingRemove} = + useListMembershipRemoveMutation({ + onSuccess: () => { + Toast.show(l`Removed from list`) + onRemove?.(list.uri) + removeListMembershipOptimistically({ + queryClient, + actor: profile.did, + listUri: list.uri, + }) + }, + onError: err => { + if (!isNetworkError(err)) { + logger.error('Failed to remove from list', {safeMessage: err}) + } + Toast.show(l`Failed to remove from list`, {type: 'error'}) + }, + }) + + const isPending = isPendingAdd || isPendingRemove + + const handleToggleMembership = () => { + if (isPending) return + + if (!isMember) { + addMembership({ + listUri: list.uri, + actorDid: profile.did, + }) + } else { + if (!listItem?.uri) { + logger.error('Cannot remove: missing membership URI') + return + } + removeMembership({ + listUri: list.uri, + actorDid: profile.did, + membershipUri: listItem.uri, + }) + } + } + + return ( + + + + + {sanitizeDisplayName(list.name)} + + + {list.purpose === 'app.bsky.graph.defs#curatelist' && + (list.creator.did === currentAccount?.did ? ( + User list by you + ) : ( + + User list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + {list.purpose === 'app.bsky.graph.defs#modlist' && + (list.creator.did === currentAccount?.did ? ( + Moderation list by you + ) : ( + + Moderation list by {sanitizeHandle(list.creator.handle, '@')} + + ))} + + + + + ) +} diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index dfac74e59..c5e0e82ec 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -1,5 +1,5 @@ import {useCallback} from 'react' -import {type ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api' +import {type ChatBskyActorDefs, type ChatBskyConvoDefs} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {StackActions, useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx index 3c545362b..d62ba2a7a 100644 --- a/src/state/modals/index.tsx +++ b/src/state/modals/index.tsx @@ -3,15 +3,6 @@ import {createContext, useContext, useEffect, useMemo, useState} from 'react' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useHotkeysContext} from '#/lib/hotkeys' -export interface UserAddRemoveListsModal { - name: 'user-add-remove-lists' - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -} - export interface ContentLanguagesSettingsModal { name: 'content-languages-settings' } @@ -21,10 +12,7 @@ export interface ContentLanguagesSettingsModal { */ export type Modal = // Curation - | ContentLanguagesSettingsModal - - // Lists - | UserAddRemoveListsModal + ContentLanguagesSettingsModal const ModalContext = createContext<{ isModalActive: boolean diff --git a/src/state/queries/list-memberships.ts b/src/state/queries/list-memberships.ts index dd7fde248..75adcd5bb 100644 --- a/src/state/queries/list-memberships.ts +++ b/src/state/queries/list-memberships.ts @@ -1,19 +1,3 @@ -/** - * NOTE - * - * This query is a temporary solution to our lack of server API for - * querying user membership in an API. It is extremely inefficient. - * - * THIS SHOULD ONLY BE USED IN MODALS FOR MODIFYING A USER'S LIST MEMBERSHIP! - * Use the list-members query for rendering a list's members. - * - * It works by fetching *all* of the user's list item records and querying - * or manipulating that cache. For users with large lists, it will fall - * down completely, so be very conservative about how you use it. - * - * -prf - */ - import { type AppBskyActorDefs, type AppBskyGraphGetStarterPacksWithMembership, @@ -22,85 +6,17 @@ import { import { type InfiniteData, useMutation, - useQuery, useQueryClient, } from '@tanstack/react-query' -import {STALE} from '#/state/queries' -import {RQKEY as LIST_MEMBERS_RQKEY} from '#/state/queries/list-members' +import { + RQKEY as LIST_MEMBERS_RQKEY, + RQKEY_ALL as LIST_MEMBERS_ALL_RQKEY, +} from '#/state/queries/list-members' import {useAgent, useSession} from '#/state/session' import type * as bsky from '#/types/bsky' import {RQKEY_WITH_MEMBERSHIP as STARTER_PACKS_WITH_MEMBERSHIPS_RKEY} from './actor-starter-packs' -// sanity limit is SANITY_PAGE_LIMIT*PAGE_SIZE total records -const SANITY_PAGE_LIMIT = 1000 -const PAGE_SIZE = 100 -// ...which comes 100,000k list members - -const RQKEY_ROOT = 'list-memberships' -export const RQKEY = () => [RQKEY_ROOT] - -export interface ListMembersip { - membershipUri: string - listUri: string - actorDid: string -} - -/** - * This API is dangerous! Read the note above! - */ -export function useDangerousListMembershipsQuery() { - const {currentAccount} = useSession() - const agent = useAgent() - return useQuery({ - staleTime: STALE.MINUTES.FIVE, - queryKey: RQKEY(), - async queryFn() { - if (!currentAccount) { - return [] - } - let cursor - let arr: ListMembersip[] = [] - for (let i = 0; i < SANITY_PAGE_LIMIT; i++) { - const res = await agent.app.bsky.graph.listitem.list({ - repo: currentAccount.did, - limit: PAGE_SIZE, - cursor, - }) - arr = arr.concat( - res.records.map(r => ({ - membershipUri: r.uri, - listUri: r.value.list, - actorDid: r.value.subject, - })), - ) - cursor = res.cursor - if (!cursor) { - break - } - } - return arr - }, - }) -} - -/** - * Returns undefined for pending, false for not a member, and string for a member (the URI of the membership record) - */ -export function getMembership( - memberships: ListMembersip[] | undefined, - list: string, - actor: string, -): string | false | undefined { - if (!memberships) { - return undefined - } - const membership = memberships.find( - m => m.listUri === list && m.actorDid === actor, - ) - return membership ? membership.membershipUri : false -} - export function useListMembershipAddMutation({ subject, onSuccess, @@ -133,43 +49,21 @@ export function useListMembershipAddMutation({ createdAt: new Date().toISOString(), }, ) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf return res }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships - // avoid dups - .filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - .concat([ - { - ...variables, - membershipUri: data.uri, - }, - ]) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) - // update WITH_MEMBERSHIPS query - + // update WITH_MEMBERSHIPS query for starter packs if (subject) { queryClient.setQueryData< InfiniteData @@ -251,32 +145,20 @@ export function useListMembershipRemoveMutation({ repo: currentAccount.did, rkey: membershipUrip.rkey, }) - // TODO - // we need to wait for appview to update, but there's not an efficient - // query for that, so we use a timeout below - // -prf }, onSuccess: (data, variables) => { - // manually update the cache; a refetch is too expensive - let memberships = queryClient.getQueryData(RQKEY()) - if (memberships) { - memberships = memberships.filter( - m => - !( - m.actorDid === variables.actorDid && - m.listUri === variables.listUri - ), - ) - queryClient.setQueryData(RQKEY(), memberships) - } // invalidate the members queries (used for rendering the listings) - // use a timeout to wait for the appview (see above) + // use a timeout to wait for the appview setTimeout(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: LIST_MEMBERS_RQKEY(variables.listUri), }) + void queryClient.invalidateQueries({ + queryKey: LIST_MEMBERS_ALL_RQKEY(variables.listUri), + }) }, 1e3) + // update WITH_MEMBERSHIPS query for starter packs queryClient.setQueryData< InfiniteData >(STARTER_PACKS_WITH_MEMBERSHIPS_RKEY(variables.actorDid), old => { diff --git a/src/state/queries/lists-with-membership.ts b/src/state/queries/lists-with-membership.ts new file mode 100644 index 000000000..3147b8845 --- /dev/null +++ b/src/state/queries/lists-with-membership.ts @@ -0,0 +1,122 @@ +import { + type AppBskyActorDefs, + type AppBskyGraphGetListsWithMembership, +} from '@atproto/api' +import { + type InfiniteData, + type QueryClient, + type QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {createQueryKey} from '#/state/queries/util' +import {useAgent} from '#/state/session' + +export type ListWithMembership = + AppBskyGraphGetListsWithMembership.ListWithMembership + +const listsWithMembershipQueryKeyRoot = 'lists-with-membership' +export const createListsWithMembershipQueryKey = (args: {actor: string}) => + createQueryKey(listsWithMembershipQueryKeyRoot, args) + +export function useListsWithMembershipQuery({ + actor, + enabled = true, +}: { + actor: string | undefined + enabled?: boolean +}) { + const agent = useAgent() + + return useInfiniteQuery< + AppBskyGraphGetListsWithMembership.OutputSchema, + Error, + InfiniteData, + QueryKey, + string | undefined + >({ + queryKey: createListsWithMembershipQueryKey({actor: actor ?? ''}), + queryFn: async ({pageParam}: {pageParam?: string}) => { + const res = await agent.app.bsky.graph.getListsWithMembership({ + actor: actor!, // the enabled flag prevents this from running until actor is set + limit: 50, + cursor: pageParam, + }) + return res.data + }, + enabled: Boolean(actor) && enabled, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + }) +} + +export function updateListMembershipOptimistically({ + queryClient, + actor, + listUri, + membershipUri, + subject, +}: { + queryClient: QueryClient + actor: string + listUri: string + membershipUri: string + subject: AppBskyActorDefs.ProfileView +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: { + uri: membershipUri, + subject, + }, + } + } + return lwm + }), + })), + } + }) +} + +export function removeListMembershipOptimistically({ + queryClient, + actor, + listUri, +}: { + queryClient: QueryClient + actor: string + listUri: string +}) { + queryClient.setQueryData< + InfiniteData + >(createListsWithMembershipQueryKey({actor}), old => { + if (!old) return old + + return { + ...old, + pages: old.pages.map(page => ({ + ...page, + listsWithMembership: page.listsWithMembership.map(lwm => { + if (lwm.list.uri === listUri) { + return { + ...lwm, + listItem: undefined, + } + } + return lwm + }), + })), + } + }) +} diff --git a/src/view/com/lists/ListMembers.tsx b/src/view/com/lists/ListMembers.tsx index a443c49fd..04876eefb 100644 --- a/src/view/com/lists/ListMembers.tsx +++ b/src/view/com/lists/ListMembers.tsx @@ -1,4 +1,4 @@ -import {type JSX, useCallback, useMemo, useState} from 'react' +import {useCallback, useMemo, useState} from 'react' import { Dimensions, type GestureResponderEvent, @@ -13,7 +13,6 @@ import {Trans} from '@lingui/react/macro' import {cleanError} from '#/lib/strings/errors' import {logger} from '#/logger' -import {useModalControls} from '#/state/modals' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useListMembersQuery} from '#/state/queries/list-members' import {useSession} from '#/state/session' @@ -23,14 +22,29 @@ import {ProfileCardFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlacehol import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog' import {ListFooter} from '#/components/Lists' import * as ProfileCard from '#/components/ProfileCard' import type * as bsky from '#/types/bsky' -const LOADING_ITEM = {_reactKey: '__loading__'} -const EMPTY_ITEM = {_reactKey: '__empty__'} -const ERROR_ITEM = {_reactKey: '__error__'} -const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'} +const LOADING_ITEM = {kind: 'loading', _reactKey: '__loading__'} as const +const EMPTY_ITEM = {kind: 'empty', _reactKey: '__empty__'} as const +const ERROR_ITEM = {kind: 'error', _reactKey: '__error__'} as const +const LOAD_MORE_ERROR_ITEM = { + kind: 'load_more_error', + _reactKey: '__load_more_error__', +} as const + +type Item = + | typeof LOADING_ITEM + | typeof EMPTY_ITEM + | typeof ERROR_ITEM + | typeof LOAD_MORE_ERROR_ITEM + | { + kind: 'list_item' + listItem: AppBskyGraphDefs.ListItemView + } export function ListMembers({ list, @@ -49,8 +63,8 @@ export function ListMembers({ scrollElRef?: ListRef onScrolledDownChange: (isScrolledDown: boolean) => void onPressTryAgain?: () => void - renderHeader: () => JSX.Element - renderEmptyState: () => JSX.Element + renderHeader: () => React.ReactElement + renderEmptyState: () => React.ReactElement testID?: string headerOffset?: number desktopFixedHeightOffset?: number @@ -58,9 +72,12 @@ export function ListMembers({ const t = useTheme() const {_} = useLingui() const [isRefreshing, setIsRefreshing] = useState(false) - const {openModal} = useModalControls() const {currentAccount} = useSession() const moderationOpts = useModerationOpts() + const editListsDialogControl = useDialogControl() + const [selectedProfile, setSelectedProfile] = useState< + bsky.profile.AnyProfileView | undefined + >() const { data, @@ -78,23 +95,28 @@ export function ListMembers({ currentAccount && data?.pages[0].list.creator.did === currentAccount.did const items = useMemo(() => { - let items: any[] = [] + const items: Item[] = [] if (isFetched) { if (isEmpty && isError) { - items = items.concat([ERROR_ITEM]) + items.push(ERROR_ITEM) } if (isEmpty) { - items = items.concat([EMPTY_ITEM]) + items.push(EMPTY_ITEM) } else if (data) { for (const page of data.pages) { - items = items.concat(page.items) + items.push( + ...page.items.map(item => ({ + kind: 'list_item' as const, + listItem: item, + })), + ) } } if (!isEmpty && isError) { - items = items.concat([LOAD_MORE_ERROR_ITEM]) + items.push(LOAD_MORE_ERROR_ITEM) } } else if (isFetching) { - items = items.concat([LOADING_ITEM]) + items.push(LOADING_ITEM) } return items }, [isFetched, isEmpty, isError, data, isFetching]) @@ -122,91 +144,97 @@ export function ListMembers({ }, [isFetching, hasNextPage, isError, fetchNextPage]) const onPressRetryLoadMore = useCallback(() => { - fetchNextPage() + void fetchNextPage() }, [fetchNextPage]) const onPressEditMembership = useCallback( (e: GestureResponderEvent, profile: bsky.profile.AnyProfileView) => { e.preventDefault() - openModal({ - name: 'user-add-remove-lists', - subject: profile.did, - displayName: profile.displayName || profile.handle, - handle: profile.handle, - }) + setSelectedProfile(profile) + editListsDialogControl.open() }, - [openModal], + [editListsDialogControl], ) // rendering // = const renderItem = useCallback( - ({item}: {item: any}) => { - if (item === EMPTY_ITEM) { - return renderEmptyState() - } else if (item === ERROR_ITEM) { - return ( - - ) - } else if (item === LOAD_MORE_ERROR_ITEM) { - return ( - - ) - } else if (item === LOADING_ITEM) { - return - } - - const profile = (item as AppBskyGraphDefs.ListItemView).subject - if (!moderationOpts) return null + ({item}: {item: Item}) => { + switch (item.kind) { + case 'empty': { + return renderEmptyState() + } + case 'error': { + return ( + + ) + } + case 'load_more_error': { + return ( + + ) + } + case 'loading': { + return + } + case 'list_item': { + const profile = item.listItem.subject + if (!moderationOpts) return null - return ( - - - - - - - {isOwner && ( - - )} - + return ( + + + + + + + {isOwner && ( + + )} + - + - - - - - ) + + + + + ) + } + } }, [ renderEmptyState, @@ -247,22 +275,29 @@ export function ListMembers({ testID={testID ? `${testID}-flatlist` : undefined} ref={scrollElRef} data={items} - keyExtractor={(item: any) => item.subject?.did || item._reactKey} + keyExtractor={(item: Item) => + item.kind === 'list_item' ? item.listItem.subject.did : item._reactKey + } renderItem={renderItem} ListHeaderComponent={!isEmpty ? renderHeader : undefined} ListFooterComponent={renderFooter} refreshing={isRefreshing} - onRefresh={onRefresh} + onRefresh={() => void onRefresh()} headerOffset={headerOffset} contentContainerStyle={{ minHeight: Dimensions.get('window').height * 1.5, }} onScrolledDownChange={onScrolledDownChange} - onEndReached={onEndReached} + onEndReached={() => void onEndReached()} onEndReachedThreshold={0.6} removeClippedSubviews={true} desktopFixedHeight={desktopFixedHeightOffset || true} /> + + ) } diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx index fa95661d2..96b04a142 100644 --- a/src/view/com/modals/Modal.tsx +++ b/src/view/com/modals/Modal.tsx @@ -7,7 +7,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useModalControls, useModals} from '#/state/modals' import {FullWindowOverlay} from '#/components/FullWindowOverlay' import {createCustomBackdrop} from '../util/BottomSheetCustomBackdrop' -import * as UserAddRemoveListsModal from './UserAddRemoveLists' const DEFAULT_SNAPPOINTS = ['90%'] const HANDLE_HEIGHT = 24 @@ -40,10 +39,7 @@ export function ModalsContainer() { let snapPoints: (string | number)[] = DEFAULT_SNAPPOINTS let element - if (activeModal?.name === 'user-add-remove-lists') { - snapPoints = UserAddRemoveListsModal.snapPoints - element = - } else { + { return null } diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx index dc080bae0..a35c2b391 100644 --- a/src/view/com/modals/Modal.web.tsx +++ b/src/view/com/modals/Modal.web.tsx @@ -6,7 +6,6 @@ import {usePalette} from '#/lib/hooks/usePalette' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type Modal as ModalIface} from '#/state/modals' import {useModalControls, useModals} from '#/state/modals' -import * as UserAddRemoveLists from './UserAddRemoveLists' export function ModalsContainer() { const {isModalActive, activeModals} = useModals() @@ -25,7 +24,7 @@ export function ModalsContainer() { ) } -function Modal({modal}: {modal: ModalIface}) { +function Modal({modal: _modal}: {modal: ModalIface}) { const {isModalActive} = useModals() const {closeModal} = useModalControls() const pal = usePalette('default') @@ -44,9 +43,7 @@ function Modal({modal}: {modal: ModalIface}) { } let element - if (modal.name === 'user-add-remove-lists') { - element = - } else { + { return null } diff --git a/src/view/com/modals/UserAddRemoveLists.tsx b/src/view/com/modals/UserAddRemoveLists.tsx deleted file mode 100644 index 1648b0ff2..000000000 --- a/src/view/com/modals/UserAddRemoveLists.tsx +++ /dev/null @@ -1,299 +0,0 @@ -import {useCallback, useMemo, useState} from 'react' -import { - ActivityIndicator, - StyleSheet, - useWindowDimensions, - View, -} from 'react-native' -import {type AppBskyGraphDefs as GraphDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' - -import {usePalette} from '#/lib/hooks/usePalette' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {cleanError} from '#/lib/strings/errors' -import {sanitizeHandle} from '#/lib/strings/handles' -import {s} from '#/lib/styles' -import {useModalControls} from '#/state/modals' -import { - getMembership, - type ListMembersip, - useDangerousListMembershipsQuery, - useListMembershipAddMutation, - useListMembershipRemoveMutation, -} from '#/state/queries/list-memberships' -import {useSession} from '#/state/session' -import {IS_ANDROID, IS_WEB, IS_WEB_MOBILE} from '#/env' -import {MyLists} from '../lists/MyLists' -import {Button} from '../util/forms/Button' -import {Text} from '../util/text/Text' -import * as Toast from '../util/Toast' -import {UserAvatar} from '../util/UserAvatar' - -export const snapPoints = ['fullscreen'] - -export function Component({ - subject, - handle, - displayName, - onAdd, - onRemove, -}: { - subject: string - handle: string - displayName: string - onAdd?: (listUri: string) => void - onRemove?: (listUri: string) => void -}) { - const {closeModal} = useModalControls() - const pal = usePalette('default') - const {height: screenHeight} = useWindowDimensions() - const {_} = useLingui() - const {data: memberships} = useDangerousListMembershipsQuery() - - const onPressDone = useCallback(() => { - closeModal() - }, [closeModal]) - - const listStyle = useMemo(() => { - if (IS_WEB_MOBILE) { - return [pal.border, {height: screenHeight / 2}] - } else if (IS_WEB) { - return [pal.border, {height: screenHeight / 1.5}] - } - - return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}] - }, [pal.border, screenHeight]) - - const headerStyles = [ - { - textAlign: 'center', - fontWeight: '600', - fontSize: 20, - marginBottom: 12, - paddingHorizontal: 12, - } as const, - pal.text, - ] - - return ( - - - - Update{' '} - - {displayName} - {' '} - in Lists - - - ( - - )} - style={listStyle} - /> - - - )} - - ) -} diff --git a/src/view/screens/Storybook/GalleryFallback.tsx b/src/view/screens/Storybook/GalleryFallback.tsx deleted file mode 100644 index 7cca087db..000000000 --- a/src/view/screens/Storybook/GalleryFallback.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import {View} from 'react-native' - -import {atoms as a} from '#/alf' -import {GalleryFallbackEmbed} from '#/components/Post/Embed/GalleryFallbackEmbed' -import {H1, H3} from '#/components/Typography' - -export function GalleryFallback() { - return ( - -

Gallery fallback (APP-2308)

- -

No count

- - -

1 photo

- - -

5 photos

- - -

10 photos

- - - ) -} diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index 84e8cb439..cc1133212 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -18,7 +18,6 @@ import {Breakpoints} from './Breakpoints' import {Buttons} from './Buttons' import {Dialogs} from './Dialogs' import {Forms} from './Forms' -import {GalleryFallback} from './GalleryFallback' import {Icons} from './Icons' import {Links} from './Links' import {Menus} from './Menus' @@ -90,13 +89,13 @@ export default function Storybook() { @@ -133,7 +132,6 @@ export default function Storybook() { -