Compare commits

...

35 Commits

Author SHA1 Message Date
Samuel Newman abcabbfb96 run jscodeshift react import codemod 2026-06-26 11:37:58 +03:00
Samuel Newman 1e97d44905 prune suppressions 2026-06-26 11:29:29 +03:00
Samuel Newman 4eca23997c typecheck modules 2026-06-26 11:29:21 +03:00
Samuel Newman 02c8ec6a4f fix loose types in GifView module 2026-06-26 11:29:08 +03:00
pfrazee 717406f16c Nightly source-language update 2026-06-26 03:18:48 +00:00
Gabe Sullivan ea35d930e3 Change dialog label and header from 'Invite Friends' to 'Share Profile' (#10987) 2026-06-25 18:23:06 -05:00
Eric Bailey 308693762c Add placeholder, empty-value, and onConfirm support to DateField (#10993)
Co-authored-by: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 18:04:54 -05:00
Spence Pope fcf17a482f Add video upload telemetry events (#10991) 2026-06-25 18:14:05 -04:00
DS Boyce 6b342f61b4 Hide autocomplete suggestions when input loses focus (#10989) 2026-06-25 16:59:50 -05:00
DS Boyce eae15aca3e Increase unread cap to 99+ (#10985) 2026-06-25 16:57:03 -05:00
Lynx 29a0d16b69 Add icon to AppLanguageDropdown (#10977) 2026-06-25 16:54:31 -05:00
Alex Benzer c33a35851e Show shorter text for account label badge (#10983) 2026-06-25 16:53:49 -05:00
Eric Bailey b1b0ad09cc Video alt text (#10990)
Co-authored-by: Zaven477 <77801431+Zaven477@users.noreply.github.com>
2026-06-25 16:52:09 -05:00
DS Boyce 46f5437faa Display more information in chat replies (#10950) 2026-06-25 15:03:42 -05:00
DS Boyce 3b2e609761 Add new chat notification preferences (#10972) 2026-06-25 12:01:33 -07:00
DS Boyce 1ff27bfd3b Upgrade pnpm to 11.9.0 (#10952) 2026-06-25 13:43:33 -05:00
pfrazee f72aae3bac Nightly source-language update 2026-06-25 03:18:16 +00:00
DS Boyce 51e25435a2 Focus composer input when editable (#10982) 2026-06-24 18:06:02 -05:00
DS Boyce da1f637160 Fix issue with stale unread message counts (#10953) 2026-06-24 13:51:06 -07:00
Spence Pope f64b1258b6 [APP-2429] Add Sunlight and Twilight color options to the Invite Friends QR code (#10921)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-24 12:27:12 -05:00
Lynx c909fef3f1 signup: align "Contact support" to the right on larger displays (#10978) 2026-06-24 12:26:25 -05:00
DS Boyce 5b66015631 Set a min height on native Menus (#10956) 2026-06-24 11:51:16 -05:00
surfdude29 90c010e98a Tweak strings and context (#10928) 2026-06-24 11:39:12 -05:00
Samuel Newman f3978d1a1f Fix internal repo sync broken by actions/checkout v6 bump (#10933)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:38:44 -05:00
Samuel Newman 2843374ef8 Fix setState-in-render warning from convo cache subscription (#10934)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:37:57 -05:00
RetroSunstar 0674626f3c Add light haptics to Edit Profile Button for labellers (#10948) 2026-06-24 11:33:49 -05:00
DS Boyce cd4d62c75a Remove old message composer (#10951) 2026-06-24 11:27:10 -05:00
DS Boyce c3626c80d1 Fix embeds overlapping each other in chat (#10949) 2026-06-24 11:14:23 -05:00
DS Boyce e371d1db44 Add mark all as read to chat settings (#10973) 2026-06-24 10:23:57 -05:00
Spence Pope 4308d75e49 Add TestFlight group selection to iOS build workflow (#10979) 2026-06-24 10:52:58 -04:00
pfrazee ea0ef23340 Nightly source-language update 2026-06-24 03:18:32 +00:00
Michael Black 692d8e5d10 bskyweb: add isPartOf jsonld post attr (#10945) 2026-06-23 12:07:00 -05:00
Spence Pope c753c94969 Process videos on web with WebCodecs (#10955)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
2026-06-23 11:33:23 -04:00
pfrazee cc0e1f88ea Nightly source-language update 2026-06-23 03:17:56 +00:00
Jerry Chen 495d1fe9a7 Restore ALT badges in quoted posts (#10964)
Co-authored-by: Eric Bailey <git@esb.lol>
2026-06-22 14:05:50 -05:00
99 changed files with 3367 additions and 1957 deletions
+15 -10
View File
@@ -10,20 +10,24 @@ on:
options:
- testflight
- production
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
testFlightGroup:
type: choice
description: TestFlight group to assign the build to after submitting
options:
- none
- QA Team
- Software Mansion
default: none
workflow_call:
inputs:
profile:
type: string
description: Build profile to use
required: true
assignTestFlightGroup:
type: boolean
description: Assign the build to the "QA Team" TestFlight group after submitting
default: false
testFlightGroup:
type: string
description: TestFlight group to assign the build to after submitting ("none" to skip)
default: none
outputs:
package-version:
description: Version from package.json
@@ -212,8 +216,9 @@ jobs:
# TestFlight group. fastlane's distribute_only mode skips the upload and assigns the
# already-submitted build to the group, polling until Apple finishes processing it.
- name: 🧪 Assign build to TestFlight group
if: ${{ inputs.assignTestFlightGroup }}
if: ${{ inputs.testFlightGroup != 'none' }}
env:
TESTFLIGHT_GROUP: ${{ inputs.testFlightGroup }}
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY_P8_BASE64: ${{ secrets.ASC_KEY_P8_BASE64 }}
@@ -241,7 +246,7 @@ jobs:
app_identifier:"xyz.blueskyweb.app" \
app_version:"$APP_VERSION" \
build_number:"$BUILD_NUMBER" \
groups:"QA Team" \
groups:"$TESTFLIGHT_GROUP" \
notify_external_testers:true
- name: 🔔 Notify Slack of Production Build
+1 -1
View File
@@ -77,7 +77,7 @@ jobs:
uses: ./.github/workflows/build-submit-ios.yml
with:
profile: testflight
assignTestFlightGroup: true
testFlightGroup: "QA Team"
secrets: inherit
android:
+3 -1
View File
@@ -17,6 +17,9 @@ jobs:
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
# Don't persist the checkout auth header; the push below authenticates
# with the app token embedded in the remote URL instead
persist-credentials: false
- name: Generate GitHub App Token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
@@ -33,6 +36,5 @@ jobs:
run: |
git config user.name "github-actions"
git config user.email "test@users.noreply.github.com"
git config --unset-all http.https://github.com/.extraheader
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
git push internal main --force
+34 -1
View File
@@ -164,12 +164,45 @@ related code together and gives us a better visual cue that there are probably
other files contained within this "macro" feature, whereas `Component.tsx` on
its own looks more like a single component file.
### Documentation and Tests Within Features
### Comments
Comment code when necessary to explain the “why” behind something; avoid
comments that simply describe the code. Avoid Unicode characters in comments,
e.g., use `-` not `—`.
Always use docblock (`/** */`) syntax for comments that document a type, type
member, method, function, or variable. These are the comments a reader expects
to find attached to a named declaration, and the docblock form makes that intent
clear and surfaces nicely in editor tooltips.
```tsx
type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
}
/**
* Date-only input. Accepts a string in the format YYYY-MM-DD, or a Date object.
*/
export function DateField() {}
```
More generally, any multiline comment should use the `/* */` block syntax rather
than stacked `//` lines. Reserve `//` for short, single-line comments.
```tsx
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today.
*/
const fallbackDate = maximumDate ? toSimpleDateString(maximumDate) : today
```
### Documentation and Tests Within Features
For larger features or components, it's helpful to include a README.md file
within the directory that explains the purpose of the feature, how it works, and
any important implementation details. The `/Component/index.tsx` pattern lends
+1 -1
View File
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
npm install --global pnpm@11.7.0 && \
npm install --global pnpm@11.9.0 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
+48 -2
View File
@@ -65,6 +65,7 @@ type discussionForumPosting struct {
CommentCount *int64 `json:"commentCount,omitempty"`
Comment []comment `json:"comment,omitempty"`
IsBasedOn string `json:"isBasedOn,omitempty"`
IsPartOf string `json:"isPartOf,omitempty"`
SharedContent *sharedContent `json:"sharedContent,omitempty"`
}
@@ -344,6 +345,43 @@ func extractSharedContentURL(pv *appbsky.FeedDefs_PostView) string {
return ""
}
// threadRootURI returns the AT-URI of the root post of the thread a reply
// belongs to, or "" if the post is not a reply or the record is malformed.
func threadRootURI(pv *appbsky.FeedDefs_PostView) string {
if pv == nil || pv.Record == nil {
return ""
}
rec, ok := pv.Record.Val.(*appbsky.FeedPost)
if !ok || rec.Reply == nil || rec.Reply.Root == nil {
return ""
}
return rec.Reply.Root.Uri
}
// findRootPostInParents walks tv's parent chain upward and returns the
// PostView whose URI matches rootURI, or nil if the root is not present.
// The root is absent when the chain is truncated by parentHeight (reply
// deeper than the fetched height) or broken by a blocked/not-found parent.
// Avoids a separate FeedGetPosts call when the thread response already
// contains the root.
func findRootPostInParents(tv *appbsky.FeedDefs_ThreadViewPost, rootURI string) *appbsky.FeedDefs_PostView {
if rootURI == "" {
return nil
}
for node := tv; node != nil; {
if node.Post != nil && node.Post.Uri == rootURI {
return node.Post
}
if node.Parent == nil {
return nil
}
// Only threadViewPost parents continue the chain; a blocked or
// not-found parent breaks it before reaching the root.
node = node.Parent.FeedDefs_ThreadViewPost
}
return nil
}
// buildAuthor constructs a Person. Organization classification for
// custom-domain accounts is a future enhancement.
func buildAuthor(author *appbsky.ActorDefs_ProfileViewBasic) *personOrOrg {
@@ -584,13 +622,21 @@ func buildReplyNode(pv *appbsky.FeedDefs_PostView, hideLabels map[string]bool) c
// buildPostJSONLD marshals the WebPage envelope wrapping a
// DiscussionForumPosting. canonicalURL is used for both envelope.url and
// (as a fallback) mainEntity.url so they always agree.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
// (as a fallback) mainEntity.url so they always agree. isPartOfURL, when
// non-empty, is the handle-form canonical URL of the thread root the handler
// resolved for a reply; pass "" to omit isPartOf (non-reply, or the root could
// not be resolved). We never emit a DID-form isPartOf because it would not
// match the root page's handle-form canonical.
func buildPostJSONLD(pv *appbsky.FeedDefs_PostView, replies []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem, canonicalURL string, isPartOfURL string, hideLabels, hideReplyLabels map[string]bool) (string, error) {
if pv == nil || pv.Author == nil {
return "", fmt.Errorf("nil post view or author")
}
node := buildPostNode(pv, replies, hideLabels, hideReplyLabels)
if isPartOfURL != "" {
node.IsPartOf = isPartOfURL
}
// mainEntity.url is empty when the author handle is unusable; fall back
// to canonicalURL so it agrees with envelope.url.
if node.URL == "" {
+155 -36
View File
@@ -183,6 +183,19 @@ func withQuotePostBlocked() func(*appbsky.FeedDefs_PostView) {
}
}
// withReplyRoot marks the post as a reply by setting its record's Reply.Root
// strong-ref to the given thread-root post.
func withReplyRoot(rootDid, rootRkey string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
rec, _ := pv.Record.Val.(*appbsky.FeedPost)
uri := "at://" + rootDid + "/app.bsky.feed.post/" + rootRkey
rec.Reply = &appbsky.FeedPost_ReplyRef{
Root: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
Parent: &comatprototypes.RepoStrongRef{Uri: uri, Cid: "bafy-root"},
}
}
}
// withSelfLabel adds a self-label that should hide embeds.
func withSelfLabel(val string) func(*appbsky.FeedDefs_PostView) {
return func(pv *appbsky.FeedDefs_PostView) {
@@ -263,7 +276,7 @@ func unmarshalLD(t *testing.T, s string) map[string]any {
func TestBuildPostJSONLD_Bare(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -333,7 +346,7 @@ func TestBuildPostJSONLD_WithImages(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -358,7 +371,7 @@ func TestBuildPostJSONLD_WithGallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
thumb3 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g3@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2, thumb3))
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -385,7 +398,7 @@ func TestBuildPostJSONLD_GalleryInRecordWithMedia(t *testing.T) {
thumb := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quote+gallery",
withRecordWithMediaGallery("bob.example.com", "did:plc:bob", "xyz", thumb))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -438,7 +451,7 @@ func TestExtractPostMedia_GallerySkipsUnknownItems(t *testing.T) {
func TestBuildPostJSONLD_WithVideo(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch", withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["thumbnailUrl"] != thumb {
t.Errorf("video thumbnailUrl wrong: %v", main["thumbnailUrl"])
@@ -451,7 +464,7 @@ func TestBuildPostJSONLD_WithVideo(t *testing.T) {
func TestBuildPostJSONLD_QuotePost(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting!", withQuotePost("bob.example.com", "did:plc:bob", "xyz"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isBasedOn"] != "https://bsky.app/profile/bob.example.com/post/xyz" {
t.Errorf("isBasedOn wrong: %v", main["isBasedOn"])
@@ -460,16 +473,122 @@ func TestBuildPostJSONLD_QuotePost(t *testing.T) {
func TestBuildPostJSONLD_QuoteBlocked(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "quoting blocked", withQuotePostBlocked())
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isBasedOn"]; present {
t.Errorf("blocked quote should not produce isBasedOn")
}
}
func TestBuildPostJSONLD_IsPartOf(t *testing.T) {
// isPartOf is sourced solely from the handler-resolved URL. When supplied,
// it is emitted on the main post; when empty, no isPartOf is present.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, nil, "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if main["isPartOf"] != isPartOf {
t.Errorf("isPartOf = %v, want %v", main["isPartOf"], isPartOf)
}
out, _ = buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main = unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["isPartOf"]; present {
t.Errorf("empty isPartOfURL should omit isPartOf, got %v", main["isPartOf"])
}
}
func TestThreadRootURI(t *testing.T) {
// A reply returns its root AT-URI; a non-reply returns "".
reply := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "a reply",
withReplyRoot("did:plc:root", "rootrkey"))
want := "at://did:plc:root/app.bsky.feed.post/rootrkey"
if got := threadRootURI(reply); got != want {
t.Errorf("threadRootURI = %q, want %q", got, want)
}
post := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "not a reply")
if got := threadRootURI(post); got != "" {
t.Errorf("threadRootURI on non-reply = %q, want empty", got)
}
}
func TestFindRootPostInParents(t *testing.T) {
rootPost := makePostView("root.bsky.social", "did:plc:root", "rootrkey", "root")
rootURI := rootPost.Uri
// tvp wraps a PostView, optionally chaining to a parent thread node.
tvp := func(pv *appbsky.FeedDefs_PostView, parent *appbsky.FeedDefs_ThreadViewPost) *appbsky.FeedDefs_ThreadViewPost {
node := &appbsky.FeedDefs_ThreadViewPost{Post: pv}
if parent != nil {
node.Parent = &appbsky.FeedDefs_ThreadViewPost_Parent{FeedDefs_ThreadViewPost: parent}
}
return node
}
t.Run("direct reply, parent is root", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post, got %v", got)
}
})
t.Run("multi-level chain", func(t *testing.T) {
mid := tvp(makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid"), tvp(rootPost, nil))
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), mid)
if got := findRootPostInParents(leaf, rootURI); got != rootPost {
t.Errorf("expected root post in chain, got %v", got)
}
})
t.Run("root absent, chain truncated", func(t *testing.T) {
// Topmost parent is not the root (e.g. parentHeight cut off the chain).
topmost := makePostView("bob.bsky.social", "did:plc:bob", "mid", "mid")
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(topmost, nil))
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when root absent, got %v", got)
}
})
t.Run("chain broken by blocked parent", func(t *testing.T) {
// A blocked/not-found parent yields a nil FeedDefs_ThreadViewPost,
// breaking the walk before the root.
leaf := &appbsky.FeedDefs_ThreadViewPost{
Post: makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"),
Parent: &appbsky.FeedDefs_ThreadViewPost_Parent{
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: rootURI},
},
}
if got := findRootPostInParents(leaf, rootURI); got != nil {
t.Errorf("expected nil when chain broken by blocked parent, got %v", got)
}
})
t.Run("empty rootURI", func(t *testing.T) {
leaf := tvp(makePostView("alice.bsky.social", "did:plc:alice", "leaf", "reply"), tvp(rootPost, nil))
if got := findRootPostInParents(leaf, ""); got != nil {
t.Errorf("expected nil for empty rootURI, got %v", got)
}
})
}
func TestBuildPostJSONLD_ReplyCommentsNoIsPartOf(t *testing.T) {
// Replies surfaced under the main post as comment[] are Comment nodes and
// never carry isPartOf, even when the main post has one.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "a reply")
isPartOf := "https://bsky.app/profile/root.bsky.social/post/rootrkey"
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", isPartOf, hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
if _, present := c["isPartOf"]; present {
t.Errorf("comment entries should not carry isPartOf, got %v", c["isPartOf"])
}
}
func TestBuildPostJSONLD_ExternalEmbed(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "check this out", withExternalEmbed("https://www.spiegel.de/article", "Title"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
sc, ok := main["sharedContent"].(map[string]any)
if !ok {
@@ -487,7 +606,7 @@ func TestBuildPostJSONLD_HiddenEmbed(t *testing.T) {
thumb := "https://cdn.bsky.app/img/x@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withImages(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed post should not emit image")
@@ -506,7 +625,7 @@ func TestBuildPostJSONLD_HiddenEmbed_Gallery(t *testing.T) {
thumb := "https://cdn.bsky.app/img/g@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "nsfw",
withGallery(thumb), withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["image"]; present {
t.Errorf("hidden-embed gallery post should not emit image")
@@ -520,7 +639,7 @@ func TestBuildPostJSONLD_TextEscaping(t *testing.T) {
// Includes ", \, newline, </script>, and a unicode char.
tricky := "hello \"world\" \\ <\\>\n</script> 🎉"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", tricky)
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -556,7 +675,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
FeedDefs_BlockedPost: &appbsky.FeedDefs_BlockedPost{Uri: "at://x/y/z"},
})
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if cc := main["commentCount"].(float64); int64(cc) != 14 {
@@ -598,7 +717,7 @@ func TestBuildPostJSONLD_Comments(t *testing.T) {
func TestBuildPostJSONLD_HandleInvalidAuthor(t *testing.T) {
pv := makePostView("handle.invalid", "did:plc:alice", "abc123", "hello")
fallback := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, fallback, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, fallback, "", hideEmbedLabels, hideReplyLabels)
envelope := unmarshalLD(t, out)
main := envelope["mainEntity"].(map[string]any)
// mainEntity.url falls back to the caller's canonical URL so envelope
@@ -647,7 +766,7 @@ func TestBuildPostJSONLD_EnvelopeURLMatchesMainEntity(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
pv := makePostView(tc.handle, tc.did, tc.rkey, "hi")
out, _ := buildPostJSONLD(pv, nil, tc.canonical, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, tc.canonical, "", hideEmbedLabels, hideReplyLabels)
env := unmarshalLD(t, out)
main := env["mainEntity"].(map[string]any)
if env["url"] != tc.canonical {
@@ -664,7 +783,7 @@ func TestBuildPostJSONLD_NilAuthor(t *testing.T) {
// Defensive: don't panic if Author is nil.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
pv.Author = nil
if _, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels); err == nil {
if _, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels); err == nil {
t.Errorf("expected error for nil-author post, got nil")
}
}
@@ -682,7 +801,7 @@ func TestBuildPostJSONLD_NilAuthorReply(t *testing.T) {
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: goodReply}},
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: badReply}},
}
out, err := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -703,7 +822,7 @@ func TestBuildPostJSONLD_CommentMedia(t *testing.T) {
replies := []*appbsky.FeedDefs_ThreadViewPost_Replies_Elem{
{FeedDefs_ThreadViewPost: &appbsky.FeedDefs_ThreadViewPost{Post: reply}},
}
out, _ := buildPostJSONLD(pv, replies, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, replies, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
imgs, ok := c["image"].([]any)
@@ -910,7 +1029,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_PostViewLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withPostLabel("!hide", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected only the unlabeled reply to remain, got %v", ids)
@@ -923,7 +1042,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_SelfLabel(t *testing.T) {
good := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "good reply")
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "spam reply",
withSelfLabel("spam"))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected self-labeled reply dropped, got %v", ids)
@@ -939,7 +1058,7 @@ func TestBuildPostJSONLD_HiddenReplyDropped_EmbedLabel(t *testing.T) {
// union behavior.
bad := makePostView("eve.bsky.social", "did:plc:eve", "rep2", "concerning reply",
withPostLabel("self-harm", false))
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(good, bad), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != good.Uri {
t.Errorf("expected embed-labeled reply dropped, got %v", ids)
@@ -951,7 +1070,7 @@ func TestBuildPostJSONLD_NegatedHideLabelKept(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "fine reply",
withPostLabel("!hide", true))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
ids := commentIdentifiers(t, out)
if len(ids) != 1 || ids[0] != reply.Uri {
t.Errorf("expected negated-label reply to be kept, got %v", ids)
@@ -962,7 +1081,7 @@ func TestBuildPostJSONLD_ReplyAuthorHasIdentifier(t *testing.T) {
// Reply author should also carry a DID identifier.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi")
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth, ok := c["author"].(map[string]any)
@@ -1103,7 +1222,7 @@ func TestBuildPostJSONLD_AuthorReviewedBy(t *testing.T) {
})
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi",
withVerifications(state))
out, err := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1143,7 +1262,7 @@ func TestBuildPostJSONLD_ReplyAuthorNoReviewedBy(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "main")
reply := makePostView("bob.bsky.social", "did:plc:bob", "rep1", "hi",
withVerifications(state))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
auth := c["author"].(map[string]any)
@@ -1267,7 +1386,7 @@ func TestBuildPostJSONLD_WithVideoObject(t *testing.T) {
hasAspect: true, width: 16, height: 9,
}))
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
out, err := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, err := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -1315,7 +1434,7 @@ func TestBuildPostJSONLD_VideoNameFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1332,7 +1451,7 @@ func TestBuildPostJSONLD_VideoNameFallbackHandleInvalid(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["name"] != "Video on Bluesky" {
@@ -1347,7 +1466,7 @@ func TestBuildPostJSONLD_VideoDescriptionFallback(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "scenic clip",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if video["description"] != "scenic clip" {
@@ -1360,7 +1479,7 @@ func TestBuildPostJSONLD_VideoNoAspectRatio(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: "https://video.bsky.app/p.m3u8", alt: "alt",
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video := main["video"].(map[string]any)
if _, present := video["width"]; present {
@@ -1377,7 +1496,7 @@ func TestBuildPostJSONLD_VideoMissingPlaylist(t *testing.T) {
thumb := "https://cdn.bsky.app/img/video_thumbnail/plain/did:plc:alice/v@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "x",
withVideo(thumb))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("video without playlist should not produce VideoObject")
@@ -1396,7 +1515,7 @@ func TestBuildPostJSONLD_VideoHiddenEmbed(t *testing.T) {
alt: "should be dropped",
}),
withSelfLabel("porn"))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("hidden-embed post should not emit video")
@@ -1411,7 +1530,7 @@ func TestBuildPostJSONLD_VideoInRecordWithMedia(t *testing.T) {
thumbnail: thumb, playlist: playlist, alt: "alt", recordMedia: true,
hasAspect: true, width: 4, height: 3,
}))
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1438,7 +1557,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
withVideoFull(videoEmbedOpts{
thumbnail: thumb, playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
@@ -1461,7 +1580,7 @@ func TestBuildPostJSONLD_VideoOnReply(t *testing.T) {
func TestBuildPostJSONLD_NoVideoNoField(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "no embed")
out, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
if _, present := main["video"]; present {
t.Errorf("post without video should not include video field")
@@ -1505,7 +1624,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL(t *testing.T) {
playlist: playlist, alt: "scenic clip",
}))
canonical := "https://bsky.app/profile/did:plc:alice/post/abc123"
out, _ := buildPostJSONLD(pv, nil, canonical, hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, nil, canonical, "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
video, ok := main["video"].(map[string]any)
if !ok {
@@ -1528,7 +1647,7 @@ func TestBuildPostJSONLD_VideoHandleInvalidEmbedURL_Reply(t *testing.T) {
withVideoFull(videoEmbedOpts{
playlist: playlist, alt: "bob's clip",
}))
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", hideEmbedLabels, hideReplyLabels)
out, _ := buildPostJSONLD(pv, buildReplies(reply), "u", "", hideEmbedLabels, hideReplyLabels)
main := unmarshalLD(t, out)["mainEntity"].(map[string]any)
c := main["comment"].([]any)[0].(map[string]any)
video, ok := c["video"].(map[string]any)
+17
View File
@@ -20,3 +20,20 @@ func profileRequiresAuth(pv *appbsky.ActorDefs_ProfileViewDetailed) bool {
}
return false
}
// postAuthorRequiresAuth reports whether the post author self-applied the
// !no-unauthenticated label, read from the author view embedded in a
// getPostThread response. The appview surfaces the account's profile-record
// self-labels on the post author (src == author DID), so this mirrors
// profileRequiresAuth without a separate ActorGetProfile call.
func postAuthorRequiresAuth(pv *appbsky.FeedDefs_PostView) bool {
if pv == nil || pv.Author == nil {
return false
}
for _, label := range pv.Author.Labels {
if label.Src == pv.Author.Did && label.Val == "!no-unauthenticated" {
return true
}
}
return false
}
+73
View File
@@ -77,3 +77,76 @@ func TestProfileRequiresAuth(t *testing.T) {
})
}
}
func TestPostAuthorRequiresAuth(t *testing.T) {
negTrue := true
authorPV := func(labels []*comatprototypes.LabelDefs_Label) *appbsky.FeedDefs_PostView {
return &appbsky.FeedDefs_PostView{
Author: &appbsky.ActorDefs_ProfileViewBasic{
Did: "did:plc:alice",
Handle: "alice.bsky.social",
Labels: labels,
},
}
}
tests := []struct {
name string
pv *appbsky.FeedDefs_PostView
want bool
}{
{
name: "nil post view",
pv: nil,
want: false,
},
{
name: "nil author",
pv: &appbsky.FeedDefs_PostView{},
want: false,
},
{
name: "no labels",
pv: authorPV(nil),
want: false,
},
{
name: "self-applied !no-unauthenticated",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated"},
}),
want: true,
},
{
name: "label from a different src does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:labeler", Val: "!no-unauthenticated"},
}),
want: false,
},
{
name: "different label value does not gate",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "spam"},
}),
want: false,
},
{
// Negation isn't honored - matches profileRequiresAuth behavior.
name: "negated label still triggers (matches profile behavior)",
pv: authorPV([]*comatprototypes.LabelDefs_Label{
{Src: "did:plc:alice", Val: "!no-unauthenticated", Neg: &negTrue},
}),
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := postAuthorRequiresAuth(tt.pv); got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
+7 -7
View File
@@ -52,7 +52,7 @@ func TestRenderBase_NoindexMeta(t *testing.T) {
func TestRenderPost_EmitsJSONLD(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, err := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
if err != nil {
t.Fatal(err)
}
@@ -81,7 +81,7 @@ func TestRenderPost_OGImageMatchesJSONLD(t *testing.T) {
thumb1 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/abc@jpeg"
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/def@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "look", withImages(thumb1, thumb2))
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -113,7 +113,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
thumb2 := "https://cdn.bsky.app/img/feed_thumbnail/plain/did:plc:alice/g2@jpeg"
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "gallery", withGallery(thumb1, thumb2))
thumbs := extractPostMedia(pv, false)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
@@ -142,7 +142,7 @@ func TestRenderPost_OGImageMatchesJSONLD_Gallery(t *testing.T) {
func TestRenderPost_FallsBackToCanonicalizeFilter(t *testing.T) {
// Without canonicalURL, the template falls back to requestURI|canonicalize_url.
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123?utm=foo",
@@ -208,7 +208,7 @@ func TestRenderProfile_AuthRequiredEmitsJSONLD(t *testing.T) {
// og:url and <link rel="canonical"> must emit the same URL.
func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hi")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
canonical := "https://bsky.app/profile/alice.bsky.social/post/abc123"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -233,7 +233,7 @@ func TestRenderPost_OGUrlMatchesCanonical(t *testing.T) {
// video without a thumbnail dropped og:video entirely.
func TestRenderPost_VideoWithoutThumbnailEmitsOGVideo(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "watch")
ld, _ := buildPostJSONLD(pv, nil, "u", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "u", "", hideEmbedLabels, hideReplyLabels)
videoURL := "https://video.bsky.app/v.m3u8"
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
@@ -286,7 +286,7 @@ func TestRenderProfile_AuthRequiredNoindex(t *testing.T) {
// flip of the noindex flag for indexable pages.
func TestRenderPost_PublicNoNoindex(t *testing.T) {
pv := makePostView("alice.bsky.social", "did:plc:alice", "abc123", "hello")
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", hideEmbedLabels, hideReplyLabels)
ld, _ := buildPostJSONLD(pv, nil, "https://bsky.app/profile/alice.bsky.social/post/abc123", "", hideEmbedLabels, hideReplyLabels)
html := renderTemplate(t, "post.html", pongo2.Context{
"postView": pv,
"requestURI": "https://bsky.app/profile/alice.bsky.social/post/abc123",
+55 -27
View File
@@ -620,23 +620,39 @@ func (srv *Server) WebPost(c echo.Context) error {
identifier := handleOrDID.Normalize().String()
// requires two fetches: first fetch profile (!)
pv, err := appbsky.ActorGetProfile(ctx, srv.xrpcc, identifier)
if err != nil {
log.Warnf("failed to fetch profile for: %s\t%v", identifier, err)
return c.Render(http.StatusOK, "post.html", data)
}
unauthedViewingOkay := !profileRequiresAuth(pv)
req := c.Request()
requestURI := fmt.Sprintf("https://%s%s", req.Host, req.URL.Path)
// Fetch the post thread directly. The AT-URI authority accepts either a
// handle or a DID (the appview resolves it), so we skip the separate
// ActorGetProfile call and source identity, the canonical URL, and the
// auth gate from the thread response's author view instead.
// parentHeight=80 (the lexicon default) pulls the reply's ancestor chain
// up to the root in nearly all threads, letting isPartOf resolve from this
// response without a separate FeedGetPosts call.
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", identifier, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 80, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil || threadView.Post.Author == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
// Always prefer the handle-form URL so JSON-LD `url` and
// <link rel="canonical"> match. Falls back to requestURI when the
// handle is unusable (template strips query/fragment).
canonicalURL := bskyPostURL(pv.Handle, rkey.String())
canonicalURL := bskyPostURL(postView.Author.Handle, rkey.String())
if !unauthedViewingOkay {
// Gate before populating any post content into the template so that
// !no-unauthenticated posts never leak text/media. The appview returns the
// post (with the author self-label) to unauthed callers, so we detect the
// label here rather than via a profile fetch.
if postAuthorRequiresAuth(postView) {
// Provide minimal OpenGraph data for auth-required posts
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -645,26 +661,13 @@ func (srv *Server) WebPost(c echo.Context) error {
data["requiresAuth"] = true
data["noindex"] = true
data["nofollow"] = true
data["profileHandle"] = pv.Handle
if pv.DisplayName != nil {
data["profileDisplayName"] = *pv.DisplayName
data["profileHandle"] = postView.Author.Handle
if postView.Author.DisplayName != nil {
data["profileDisplayName"] = *postView.Author.DisplayName
}
return c.Render(http.StatusOK, "post.html", data)
}
// then fetch the post thread (with extra context)
uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", pv.Did, rkey)
tpv, err := appbsky.FeedGetPostThread(ctx, srv.xrpcc, 1, 0, uri)
if err != nil {
log.Warnf("failed to fetch post: %s\t%v", uri, err)
return c.Render(http.StatusOK, "post.html", data)
}
threadView := tpv.Thread.FeedDefs_ThreadViewPost
if threadView == nil || threadView.Post == nil {
return c.Render(http.StatusOK, "post.html", data)
}
postView := threadView.Post
data["postView"] = postView
data["requestURI"] = requestURI
if canonicalURL != "" {
@@ -694,7 +697,32 @@ func (srv *Server) WebPost(c echo.Context) error {
if jsonldURL == "" {
jsonldURL = requestURI
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, hideEmbedLabels, hideReplyLabels); err == nil {
// Best-effort: resolve a reply's thread root to its handle-form canonical
// URL for isPartOf. Prefer the root already present in the thread response
// (parentHeight=80); fall back to a bounded FeedGetPosts only when the
// chain is truncated (very deep thread) or broken by a blocked/not-found
// ancestor. On timeout, error, or an unresolvable root we omit isPartOf
// rather than point at a non-indexable page.
isPartOfURL := ""
if rootURI := threadRootURI(postView); rootURI != "" {
if rootPost := findRootPostInParents(threadView, rootURI); rootPost != nil && rootPost.Author != nil {
isPartOfURL = bskyPostURLFromATURI(rootPost.Author.Handle, rootURI)
} else {
pctx, cancel := context.WithTimeout(ctx, 1*time.Second)
if posts, perr := appbsky.FeedGetPosts(pctx, srv.xrpcc, []string{rootURI}); perr != nil {
log.Warnf("failed to resolve thread root post for isPartOf: %s\t%v", rootURI, perr)
} else if len(posts.Posts) > 0 && posts.Posts[0].Author != nil {
// Handle-form only (no DID fallback): isPartOf must match the
// root page's handle-form canonical, so an unusable handle omits
// isPartOf rather than point at a non-canonical DID-form URL.
isPartOfURL = bskyPostURLFromATURI(posts.Posts[0].Author.Handle, rootURI)
}
cancel()
}
}
if jsonld, err := buildPostJSONLD(postView, threadView.Replies, jsonldURL, isPartOfURL, hideEmbedLabels, hideReplyLabels); err == nil {
data["postJSONLD"] = jsonld
} else {
log.Warnf("failed to build post JSON-LD for %s: %v", uri, err)
-69
View File
@@ -10,22 +10,9 @@
"count": 2
}
},
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/bottom-sheet/src/lib/Portal.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
@@ -33,28 +20,6 @@
"count": 4
}
},
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-call": {
"count": 4
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 4
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"@typescript-eslint/require-await": {
"count": 2
}
},
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
"@typescript-eslint/no-unsafe-call": {
"count": 3
@@ -88,9 +53,6 @@
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
@@ -98,16 +60,6 @@
"count": 1
}
},
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
"no-restricted-imports": {
"count": 1
}
},
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/Navigation.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
@@ -946,14 +898,6 @@
"count": 1
}
},
"src/lib/hooks/useNotificationHandler.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 16
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2126,11 +2070,6 @@
"count": 1
}
},
"src/view/com/composer/videos/pickVideo.web.ts": {
"@typescript-eslint/no-misused-promises": {
"count": 1
}
},
"src/view/com/feeds/ComposerPrompt.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2183,14 +2122,6 @@
"count": 2
}
},
"src/view/com/modals/Modal.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -1,20 +1,20 @@
import React from 'react'
import {createContext, useContext, useMemo} from 'react'
import {createPortalGroup_INTERNAL} from './lib/Portal'
type PortalContext = React.ElementType<{children: React.ReactNode}>
export const Context = React.createContext({} as PortalContext)
export const Context = createContext({} as PortalContext)
Context.displayName = 'BottomSheetPortalContext'
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
export function BottomSheetPortalProvider({
children,
}: {
children: React.ReactNode
}) {
const portal = React.useMemo(() => {
const portal = useMemo(() => {
return createPortalGroup_INTERNAL()
}, [])
+22 -12
View File
@@ -1,4 +1,14 @@
import React from 'react'
import {
createContext,
Fragment,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
type Component = React.ReactElement
@@ -13,7 +23,7 @@ type ComponentMap = {
}
export function createPortalGroup_INTERNAL() {
const Context = React.createContext<ContextType>({
const Context = createContext<ContextType>({
outlet: null,
append: () => {},
remove: () => {},
@@ -21,21 +31,21 @@ export function createPortalGroup_INTERNAL() {
Context.displayName = 'BottomSheetPortalContext'
function Provider(props: React.PropsWithChildren<{}>) {
const map = React.useRef<ComponentMap>({})
const [outlet, setOutlet] = React.useState<ContextType['outlet']>(null)
const map = useRef<ComponentMap>({})
const [outlet, setOutlet] = useState<ContextType['outlet']>(null)
const append = React.useCallback<ContextType['append']>((id, component) => {
const append = useCallback<ContextType['append']>((id, component) => {
if (map.current[id]) return
map.current[id] = <React.Fragment key={id}>{component}</React.Fragment>
map.current[id] = <Fragment key={id}>{component}</Fragment>
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const remove = React.useCallback<ContextType['remove']>(id => {
const remove = useCallback<ContextType['remove']>(id => {
delete map.current[id]
setOutlet(<>{Object.values(map.current)}</>)
}, [])
const contextValue = React.useMemo(
const contextValue = useMemo(
() => ({
outlet,
append,
@@ -50,14 +60,14 @@ export function createPortalGroup_INTERNAL() {
}
function Outlet() {
const ctx = React.useContext(Context)
const ctx = useContext(Context)
return ctx.outlet
}
function Portal({children}: React.PropsWithChildren<{}>) {
const {append, remove} = React.useContext(Context)
const id = React.useId()
React.useEffect(() => {
const {append, remove} = useContext(Context)
const id = useId()
useEffect(() => {
append(id, children as Component)
return () => remove(id)
}, [id, children, append, remove])
@@ -1,4 +1,4 @@
import React from 'react'
import {createContext, useContext, useEffect, useMemo, useState} from 'react'
import {type BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
@@ -11,11 +11,10 @@ interface BackgroundNotificationPreferencesContext {
) => void
}
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
const Context = createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext,
)
export const useBackgroundNotificationPreferences = () =>
React.useContext(Context)
export const useBackgroundNotificationPreferences = () => useContext(Context)
export function BackgroundNotificationPreferencesProvider({
children,
@@ -23,18 +22,18 @@ export function BackgroundNotificationPreferencesProvider({
children: React.ReactNode
}) {
const [preferences, setPreferences] =
React.useState<BackgroundNotificationHandlerPreferences>({
useState<BackgroundNotificationHandlerPreferences>({
playSoundChat: true,
})
React.useEffect(() => {
useEffect(() => {
;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs)
})()
}, [])
const value = React.useMemo(
const value = useMemo(
() => ({
preferences,
setPref: async <
+16 -9
View File
@@ -1,17 +1,24 @@
import React from 'react'
import {createRef, PureComponent} from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {type GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
interface GifViewNativeRef {
playAsync: () => Promise<void>
pauseAsync: () => Promise<void>
toggleAsync: () => Promise<void>
}
const NativeModule: {
prefetchAsync: (sources: string[]) => Promise<void>
} = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
GifViewProps & {ref: React.RefObject<GifViewNativeRef | null>}
> = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: React.RefObject<any> = React.createRef()
export class GifView extends PureComponent<GifViewProps> {
private nativeRef: React.RefObject<GifViewNativeRef | null> = createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
@@ -22,15 +29,15 @@ export class GifView extends React.PureComponent<GifViewProps> {
}
async playAsync(): Promise<void> {
await this.nativeRef.current.playAsync()
await this.nativeRef.current?.playAsync()
}
async pauseAsync(): Promise<void> {
await this.nativeRef.current.pauseAsync()
await this.nativeRef.current?.pauseAsync()
}
async toggleAsync(): Promise<void> {
await this.nativeRef.current.toggleAsync()
await this.nativeRef.current?.toggleAsync()
}
render() {
@@ -1,10 +1,11 @@
import {createRef, PureComponent, type RefObject} from 'react'
import {createRef, PureComponent} from 'react'
import {StyleSheet} from 'react-native'
import {type GifViewProps} from './GifView.types'
export class GifView extends PureComponent<GifViewProps> {
private readonly videoPlayerRef: RefObject<HTMLMediaElement> = createRef()
private readonly videoPlayerRef: React.RefObject<HTMLVideoElement | null> =
createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
@@ -18,9 +19,9 @@ export class GifView extends PureComponent<GifViewProps> {
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
this.playAsync()
void this.playAsync()
} else {
this.pauseAsync()
void this.pauseAsync()
}
}
}
@@ -29,6 +30,7 @@ export class GifView extends PureComponent<GifViewProps> {
document.removeEventListener('visibilitychange', this.onVisibilityChange)
}
// eslint-disable-next-line @typescript-eslint/require-await
static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web')
}
@@ -81,6 +83,7 @@ export class GifView extends PureComponent<GifViewProps> {
}
}
// eslint-disable-next-line @typescript-eslint/require-await
async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause()
}
@@ -102,12 +105,12 @@ export class GifView extends PureComponent<GifViewProps> {
// When `<source>` children are present, omit `src` so the browser
// walks the source list and picks via canPlayType.
src={useSources ? undefined : source}
autoPlay={autoplay ? 'autoplay' : undefined}
autoPlay={autoplay ? true : undefined}
preload={autoplay ? 'auto' : undefined}
playsInline={true}
loop="loop"
muted="muted"
style={StyleSheet.flatten(style)}
loop={true}
muted={true}
style={StyleSheet.flatten(style) as React.CSSProperties}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
@@ -1,4 +1,4 @@
import React from 'react'
import {useCallback} from 'react'
import {type StyleProp, type ViewStyle} from 'react-native'
import {requireNativeModule, requireNativeViewManager} from 'expo-modules-core'
@@ -21,7 +21,7 @@ export default function VisibilityView({
onChangeStatus: onChangeStatusOuter,
enabled,
}: VisibilityViewProps) {
const onChangeStatus = React.useCallback(
const onChangeStatus = useCallback(
(e: {nativeEvent: {isActive: boolean}}) => {
onChangeStatusOuter(e.nativeEvent.isActive)
},
@@ -1,4 +1,3 @@
import type React from 'react'
export interface VisibilityViewProps {
children: React.ReactNode
onChangeStatus: (isActive: boolean) => void
@@ -1,5 +1,4 @@
import {requireNativeView} from 'expo'
import type * as React from 'react'
import {
type EmojiPickerNativeViewProps,
+3 -2
View File
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "11.7.0",
"version": "11.9.0",
"onFail": "warn"
},
"runtime": {
@@ -94,7 +94,7 @@
"update-actions": "pnpm dlx actions-up --min-age 7"
},
"dependencies": {
"@atproto/api": "0.20.15",
"@atproto/api": "0.20.20",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
@@ -204,6 +204,7 @@
"lodash.debounce": "^4.0.8",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mediabunny": "^1.25.3",
"multiformats": "^13.4.2",
"nanoid": "^5.0.5",
"normalize-url": "^8.0.0",
+101 -69
View File
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: 11.7.0
version: 11.7.0
specifier: 11.9.0
version: 11.9.0
pnpm:
specifier: 11.7.0
version: 11.7.0
specifier: 11.9.0
version: 11.9.0
packages:
'@pnpm/exe@11.7.0':
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==}
'@pnpm/exe@11.9.0':
resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
hasBin: true
'@pnpm/linux-arm64@11.7.0':
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==}
'@pnpm/linux-arm64@11.9.0':
resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.7.0':
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==}
'@pnpm/linux-x64@11.9.0':
resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.7.0':
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==}
'@pnpm/linuxstatic-arm64@11.9.0':
resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.7.0':
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==}
'@pnpm/linuxstatic-x64@11.9.0':
resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.7.0':
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==}
'@pnpm/macos-arm64@11.9.0':
resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.7.0':
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==}
'@pnpm/win-arm64@11.9.0':
resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.7.0':
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==}
'@pnpm/win-x64@11.9.0':
resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.7.0:
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==}
pnpm@11.9.0:
resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.7.0':
'@pnpm/exe@11.9.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.7.0
'@pnpm/linux-x64': 11.7.0
'@pnpm/linuxstatic-arm64': 11.7.0
'@pnpm/linuxstatic-x64': 11.7.0
'@pnpm/macos-arm64': 11.7.0
'@pnpm/win-arm64': 11.7.0
'@pnpm/win-x64': 11.7.0
'@pnpm/linux-arm64': 11.9.0
'@pnpm/linux-x64': 11.9.0
'@pnpm/linuxstatic-arm64': 11.9.0
'@pnpm/linuxstatic-x64': 11.9.0
'@pnpm/macos-arm64': 11.9.0
'@pnpm/win-arm64': 11.9.0
'@pnpm/win-x64': 11.9.0
'@pnpm/linux-arm64@11.7.0':
'@pnpm/linux-arm64@11.9.0':
optional: true
'@pnpm/linux-x64@11.7.0':
'@pnpm/linux-x64@11.9.0':
optional: true
'@pnpm/linuxstatic-arm64@11.7.0':
'@pnpm/linuxstatic-arm64@11.9.0':
optional: true
'@pnpm/linuxstatic-x64@11.7.0':
'@pnpm/linuxstatic-x64@11.9.0':
optional: true
'@pnpm/macos-arm64@11.7.0':
'@pnpm/macos-arm64@11.9.0':
optional: true
'@pnpm/win-arm64@11.7.0':
'@pnpm/win-arm64@11.9.0':
optional: true
'@pnpm/win-x64@11.7.0':
'@pnpm/win-x64@11.9.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
pnpm@11.7.0: {}
pnpm@11.9.0: {}
---
lockfileVersion: '9.0'
@@ -241,8 +241,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.15
version: 0.20.15
specifier: 0.20.20
version: 0.20.20
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -570,6 +570,9 @@ importers:
lodash.throttle:
specifier: ^4.1.1
version: 4.1.1
mediabunny:
specifier: ^1.25.3
version: 1.49.0
multiformats:
specifier: ^13.4.2
version: 13.4.2
@@ -873,32 +876,36 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.15':
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
'@atproto/api@0.20.20':
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
'@atproto/common-web@0.5.1':
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.1':
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
'@atproto/lex-data@0.1.2':
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.0':
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
'@atproto/lex-json@0.1.1':
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.1':
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
'@atproto/lexicon@0.7.2':
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.0':
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
'@atproto/syntax@0.6.2':
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.1':
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -3494,6 +3501,12 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/dom-mediacapture-transform@0.1.11':
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
'@types/dom-webcodecs@0.1.13':
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
'@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -6849,6 +6862,9 @@ packages:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
mediabunny@1.49.0:
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
memfs@3.5.3:
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
engines: {node: '>= 4.0.0'}
@@ -9460,40 +9476,40 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.15':
'@atproto/api@0.20.20':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
'@atproto/syntax': 0.6.1
'@atproto/xrpc': 0.8.0
'@atproto/common-web': 0.5.1
'@atproto/lexicon': 0.7.2
'@atproto/syntax': 0.6.2
'@atproto/xrpc': 0.8.1
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.0':
'@atproto/common-web@0.5.1':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-json': 0.1.0
'@atproto/syntax': 0.6.1
'@atproto/lex-data': 0.1.2
'@atproto/lex-json': 0.1.1
'@atproto/syntax': 0.6.2
zod: 3.25.76
'@atproto/lex-data@0.1.1':
'@atproto/lex-data@0.1.2':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.0':
'@atproto/lex-json@0.1.1':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-data': 0.1.2
tslib: 2.8.1
'@atproto/lexicon@0.7.1':
'@atproto/lexicon@0.7.2':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/syntax': 0.6.1
'@atproto/common-web': 0.5.1
'@atproto/syntax': 0.6.2
multiformats: 13.4.2
zod: 3.25.76
@@ -9502,9 +9518,14 @@ snapshots:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.0':
'@atproto/syntax@0.6.2':
dependencies:
'@atproto/lexicon': 0.7.1
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.1':
dependencies:
'@atproto/lexicon': 0.7.2
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -12748,6 +12769,12 @@ snapshots:
dependencies:
'@types/node': 24.12.4
'@types/dom-mediacapture-transform@0.1.11':
dependencies:
'@types/dom-webcodecs': 0.1.13
'@types/dom-webcodecs@0.1.13': {}
'@types/eslint-scope@3.7.7':
dependencies:
'@types/eslint': 9.6.1
@@ -16664,6 +16691,11 @@ snapshots:
media-typer@0.3.0: {}
mediabunny@1.49.0:
dependencies:
'@types/dom-mediacapture-transform': 0.1.11
'@types/dom-webcodecs': 0.1.13
memfs@3.5.3:
dependencies:
fs-monkey: 1.1.0
@@ -399,7 +399,7 @@ function AccessSection() {
locationControl.open()
})}>
Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '}
</SimpleInlineLinkText>
</Trans>
</Admonition>
-1
View File
@@ -10,7 +10,6 @@ export enum Features {
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
+97 -1
View File
@@ -5,6 +5,7 @@
import {type Platform} from 'react-native'
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {type NotificationType} from '#/state/queries/notifications/types'
import {type FeedDescriptor} from '#/state/queries/post-feed'
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
@@ -1319,7 +1320,7 @@ export type Events = {
'invite:action:scan': {}
// user changed the QR card color theme
'invite:theme:change': {
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
}
// QR scanner decoded a code; result indicates whether it resolved to a profile
'invite:scanner:scanned': {
@@ -1331,4 +1332,99 @@ export type Events = {
'invite:followersPromo:press': {}
// user dismissed the empty-followers promo banner
'invite:followersPromo:dismiss': {}
// === Video upload funnel (Frontend Spec section D) ===
// Every event carries uploadId (client-generated UUID, ties one upload
// session end-to-end) + engine (compression engine id, e.g.
// native:react-native-compressor@1.13.0). jobId is added once the server
// returns it. Sizes / codecs / dimensions / timings only - never content.
'video:upload:picked': {
uploadId: string
engine: string
sourceMimeType?: string
sourceBytes?: number
sourceDurationMs?: number
sourceWidth?: number
sourceHeight?: number
}
'video:upload:compressStarted': {
uploadId: string
engine: string
sourceBytes?: number
}
'video:upload:compressCompleted': {
uploadId: string
engine: string
bytesIn?: number
bytesOut: number
outputMimeType: string
elapsedMs: number
}
'video:upload:compressSkipped': {
uploadId: string
engine: string
skipReason: VideoCompressSkipReason
bytes: number
mimeType: string
elapsedMs: number
}
'video:upload:compressFailed': {
uploadId: string
engine: string
errorClass: string
elapsedMs: number
}
'video:upload:uploadStarted': {
uploadId: string
engine: string
bytes: number
}
'video:upload:uploadCompleted': {
uploadId: string
engine: string
jobId: string
bytes: number
elapsedMs: number
throughputBytesPerSec: number
}
'video:upload:uploadFailed': {
uploadId: string
engine: string
bytes: number
errorClass: string
elapsedMs: number
}
'video:upload:processingStarted': {
uploadId: string
engine: string
jobId: string
}
'video:upload:processingCompleted': {
uploadId: string
engine: string
jobId: string
elapsedMs: number
}
'video:upload:processingFailed': {
uploadId: string
engine: string
jobId: string
errorClass: string
elapsedMs: number
}
'video:upload:published': {
uploadId: string
engine: string
jobId: string
// wall-clock from picked to published
totalElapsedMs: number
}
// The event that measures the actual problem: users giving up mid-wait.
'video:upload:abandoned': {
uploadId: string
engine: string
phase: 'compress' | 'upload' | 'processing'
jobId?: string
elapsedInPhaseMs: number
}
}
+93
View File
@@ -0,0 +1,93 @@
import {Pressable} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
const positionStyles = {
'top-left': {
top: a.p_xs.padding,
left: a.p_xs.padding,
},
'top-right': {
top: a.p_xs.padding,
right: a.p_xs.padding,
},
'bottom-left': {
bottom: a.p_xs.padding,
left: a.p_xs.padding,
},
'bottom-right': {
bottom: a.p_xs.padding,
right: a.p_xs.padding,
},
}
export function AltBadgeWithDialog({
text,
position,
}: {
text: string
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
}) {
const t = useTheme()
const {t: l} = useLingui()
const large = useLargeAltBadgeEnabled()
const control = Prompt.usePromptControl()
const pos = position ? [a.absolute, positionStyles[position]] : {}
return (
<>
<Pressable
testID="altBadgeWithDialogButton"
accessibilityRole="button"
accessibilityLabel={l`Show alt text`}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={s => [
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
large && {
padding: 6,
},
{
opacity: 0.8,
},
pos,
s.hovered || s.pressed
? [
{
opacity: 1,
},
]
: [],
]}>
<Text
accessible={false}
style={[a.font_bold, large ? a.text_xs : {fontSize: 8}]}>
<Trans>ALT</Trans>
</Text>
</Pressable>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Cancel cta={l`Close`} />
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
+5 -3
View File
@@ -7,9 +7,10 @@ import {sanitizeAppLanguageSetting} from '#/locale/helpers'
import {APP_LANGUAGES} from '#/locale/languages'
import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences'
import {resetPostsFeedQueries} from '#/state/queries/post-feed'
import {atoms as a, platform, useTheme} from '#/alf'
import {atoms as a, platform, useTheme, web} from '#/alf'
import * as Select from '#/components/Select'
import {Button} from './Button'
import {Button, ButtonIcon} from './Button'
import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from './icons/Globe'
export function AppLanguageDropdown() {
const t = useTheme()
@@ -57,6 +58,7 @@ export function AppLanguageDropdown() {
native: [a.gap_xs],
}),
]}>
<ButtonIcon icon={EarthIcon} size="md" />
<Select.ValueText
placeholder={_(msg`Select an app language`)}
style={[t.atoms.text_contrast_medium]}
@@ -68,7 +70,7 @@ export function AppLanguageDropdown() {
<Select.Content
label={_(msg`Select language`)}
renderItem={({label, value}) => (
<Select.Item value={value} label={label}>
<Select.Item value={value} label={label} style={web([a.pointer])}>
<Select.ItemIndicator />
<Select.ItemText>{label}</Select.ItemText>
</Select.Item>
+2
View File
@@ -615,6 +615,8 @@ export function Outer({
label?: string
style?: StyleProp<ViewStyle>
align?: 'left' | 'right'
/** Web only. Native restores focus differently. */
onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
const context = useContextMenuContext()
+3 -1
View File
@@ -34,14 +34,16 @@ export function Outer({
children,
label,
style,
onCloseAutoFocus,
}: {
children: React.ReactNode
label?: string
style?: StyleProp<ViewStyle>
onCloseAutoFocus?: (event: Event) => void
}) {
const t = useTheme()
return (
<Menu.Outer style={style}>
<Menu.Outer style={style} onCloseAutoFocus={onCloseAutoFocus}>
{label ? (
<Text
numberOfLines={1}
+14 -8
View File
@@ -6,9 +6,7 @@ import {
View,
type ViewStyle,
} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import flattenReactChildren from 'react-keyed-flatten-children'
import {atoms as a, useTheme} from '#/alf'
@@ -32,6 +30,11 @@ import {
import {Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS, IS_NATIVE} from '#/env'
// iOS 26's floaty sheet presentation subtracts the bottom safe-area inset from
// the requested detent, which eats the visible bottom padding of short (e.g.
// single-item) menus. Flooring the native sheet height restores that padding.
const IOS_MENU_MIN_HEIGHT = 128
export {
type DialogControlProps as MenuControlProps,
useDialogControl as useMenuControl,
@@ -101,16 +104,19 @@ export function Outer({
onCloseAutoFocus?: (event: Event) => void
}>) {
const context = useMenuContext()
const {_} = useLingui()
const {t: l} = useLingui()
return (
<Dialog.Outer
control={context.control}
nativeOptions={{preventExpansion: true}}>
nativeOptions={{
preventExpansion: true,
minHeight: IS_IOS ? IOS_MENU_MIN_HEIGHT : undefined,
}}>
<Dialog.Handle />
{/* Re-wrap with context since Dialogs are portal-ed to root */}
<Context.Provider value={context}>
<Dialog.ScrollableInner label={_(msg`Menu`)}>
<Dialog.ScrollableInner label={l`Menu`}>
<View style={[a.gap_lg]}>
{children}
{IS_NATIVE && showCancel && <Cancel />}
@@ -353,12 +359,12 @@ export function Group({children, style}: GroupProps) {
}
function Cancel() {
const {_} = useLingui()
const {t: l} = useLingui()
const context = useMenuContext()
return (
<Button
label={_(msg`Close this dialog`)}
label={l`Close this dialog`}
size="small"
variant="ghost"
color="secondary"
@@ -66,19 +66,7 @@ export function GifEmbed({
a.overflow_hidden,
{backgroundColor: t.palette.black},
]}>
<View
style={[
a.absolute,
/*
* Aspect ratio was being clipped weirdly on web -esb
*/
{
top: -2,
bottom: -2,
left: -2,
right: -2,
},
]}>
<View style={[a.absolute, a.inset_0]}>
<MediaInsetBorder />
<GifPresentationControls
onPress={onPress}
-1
View File
@@ -142,7 +142,6 @@ export function ImageEmbed({
onPress(0, [containerRef], [dims])
}
onPressIn={() => onPressIn(0)}
hideBadge={rest.isWithinQuote}
/>
</ImageContextMenu>
</View>
@@ -1,18 +1,13 @@
import {
ActivityIndicator,
StyleSheet,
TouchableOpacity,
View,
} from 'react-native'
import {ActivityIndicator, View} from 'react-native'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {Button} from '#/components/Button'
import {Fill} from '#/components/Fill'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -29,6 +24,7 @@ export function GifPresentationControls({
}) {
const {_} = useLingui()
const t = useTheme()
const largeBadge = useLargeAltBadgeEnabled()
return (
<>
@@ -64,74 +60,41 @@ export function GifPresentationControls({
]}
/>
)}
<View style={styles.gifBadgeContainer}>
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
<Trans>GIF</Trans>
</Text>
<View
style={[
a.absolute,
a.flex_row,
a.z_10,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeBadge && {
gap: 4,
},
]}>
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
a.z_10,
t.atoms.bg_contrast_25,
largeBadge && {
padding: 6,
},
{
opacity: 0.8,
},
]}>
<Text style={[a.font_bold, largeBadge ? a.text_xs : {fontSize: 8}]}>
<Trans>GIF</Trans>
</Text>
</View>
{altText && <AltBadgeWithDialog text={altText} />}
</View>
{altText && <AltBadge text={altText} />}
</>
)
}
function AltBadge({text}: {text: string}) {
const control = Prompt.usePromptControl()
const {_} = useLingui()
return (
<>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Show alt text`)}
accessibilityHint=""
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altBadgeContainer}>
<Text
style={[{color: 'white'}, a.font_bold, a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<Prompt.Outer control={control}>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action
onPress={() => control.close()}
cta={_(msg`Close`)}
color="secondary"
/>
</Prompt.Actions>
</Prompt.Outer>
</>
)
}
const styles = StyleSheet.create({
gifBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
left: 6,
bottom: 6,
zIndex: 2,
},
altBadgeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 4,
paddingVertical: 3,
position: 'absolute',
right: 6,
bottom: 6,
zIndex: 2,
},
})
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {useAutoplayDisabled} from '#/state/preferences'
import {atoms as a, useTheme} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
@@ -98,19 +99,24 @@ export function VideoEmbedInnerNative({
altText={embed.alt}
/>
) : (
<VideoPresentationControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
<>
<VideoPresentationControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
{embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
</>
)}
<MediaInsetBorder />
<KeepAwake enabled={isPlaying} />
@@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {atoms as a} from '#/alf'
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import * as BandwidthEstimate from './bandwidth-estimate'
import {Controls} from './web-controls/VideoControls'
@@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({
const [hlsLoading, setHlsLoading] = useState(false)
const figId = useId()
const {_} = useLingui()
const [isFullscreen] = useFullscreen(containerRef)
const isGif = embed.presentation === 'gif'
// send error up to error boundary
const [error, setError] = useState<Error | null>(null)
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
</figcaption>
)}
</figure>
{!isFullscreen && !isGif && embed.alt && (
<AltBadgeWithDialog text={embed.alt} position="top-right" />
)}
<Controls
videoRef={videoRef}
hlsRef={hlsRef}
@@ -88,7 +95,7 @@ export function VideoEmbedInnerWeb({
onScreen={onScreen}
fullscreenRef={containerRef}
hasSubtitleTrack={hasSubtitleTrack}
isGif={embed.presentation === 'gif'}
isGif={isGif}
altText={embed.alt}
updateCuePositions={updateCuePositions}
/>
+2 -6
View File
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
import {ImageEmbed} from './ImageEmbed'
import {ModeratedListEmbed} from './ListEmbed'
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
import {
type CommonProps,
type EmbedProps,
type PostEmbedViewContext,
} from './types'
import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
import {VideoEmbed} from './VideoEmbed'
export {PostEmbedViewContext} from './types'
@@ -356,7 +352,7 @@ export function QuoteEmbed({
return (
<GalleryBleed>
<View
style={[a.mt_sm]}
style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
@@ -1,8 +1,12 @@
import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {
isChatPreferenceName,
type NotificationSettingsPreferenceName,
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
import {atoms as a, useTheme, web} from '#/alf'
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
type NotificationSettingsDialogProps = {
control: Dialog.DialogControlProps
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
name: NotificationSettingsPreferenceName
syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode
subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit<NotificationSettingsDialogProps, 'icon'>) {
const t = useTheme()
const {t: l} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery()
const isChat = isChatPreferenceName(name)
const appQuery = useNotificationSettingsQuery({enabled: !isChat})
const chatQuery = useChatNotificationSettingsQuery({enabled: isChat})
const isError = isChat ? chatQuery.isError : appQuery.isError
const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name]
return (
<>
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
<PreferenceControls
name={name}
syncOthers={syncOthers}
preference={preferences?.[name]}
preference={preference}
allowDisableInApp={allowDisableInApp}
/>
)}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
<Dialog.Close />
{IS_NATIVE && (
<Button
color="secondary"
color="primary"
size="large"
label={l`Close dialog`}
onPress={() => control.close()}
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
a.font_medium,
{color: t.palette.primary_500},
]}>
<Trans>New</Trans>
<Trans context="nux-description">New</Trans>
</Text>
</View>
<Text
+17 -2
View File
@@ -60,6 +60,20 @@ export let MessageContextMenu = ({
const langPrefs = useLanguagePrefs()
const translate = useGoogleTranslate()
const onReply = useCallback(() => {
setReply(message)
}, [setReply, message])
// On web, the menu is a Radix dropdown that restores focus to the trigger on
// close. When Reply moves focus to the composer, don't let Radix steal it
// back. Checking activeElement (rather than tracking reply intent) also
// handles re-replying to the same message, where the composer's focus effect
// bails on an unchanged reply target and focus should stay on the trigger.
const onCloseAutoFocus = useCallback((event: Event) => {
if (document.activeElement && document.activeElement !== document.body) {
event.preventDefault()
}
}, [])
const isFromSelf = message.sender?.did === currentAccount?.did
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -161,11 +175,12 @@ export let MessageContextMenu = ({
label={l`Sent at ${i18n.date(new Date(message.sentAt), {
timeStyle: 'short',
})}`}
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}>
style={[isFromSelf && isGroupChatEnabled ? null : a.ml_sm]}
onCloseAutoFocus={onCloseAutoFocus}>
<ContextMenu.Item
testID="messageDropdownReplyBtn"
label={l`Reply`}
onPress={() => setReply(message)}>
onPress={onReply}>
<ContextMenu.ItemIcon icon={ReplyIcon} position="left" />
<ContextMenu.ItemText>{l`Reply`}</ContextMenu.ItemText>
</ContextMenu.Item>
+11 -14
View File
@@ -44,12 +44,13 @@ import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, platform, useTheme, utils} from '#/alf'
import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'
import {isOnlyEmoji} from '#/alf/typography'
import {Button} from '#/components/Button'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {useMessageDialogs} from '#/components/dms/MessageOverlays'
import {useMessageReplies} from '#/components/dms/MessageReplies'
import {useReplyPreviewText} from '#/components/dms/replyPreview'
import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'
import {InlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
@@ -834,6 +835,7 @@ function ReplyQuote({
}) {
const t = useTheme()
const {t: l} = useLingui()
const getReplyPreviewText = useReplyPreviewText()
const senderProfile = useMaybeProfileShadow(
relatedProfiles.get(replyTo.sender.did),
@@ -857,22 +859,15 @@ function ReplyQuote({
let text: string
let subtle = false
if (isBlocked) {
text = l`Blocked message hidden`
text = l({
message: '(blocked message hidden)',
comment: 'A reply summary in chat',
})
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`
}
}
;({text, subtle} = getReplyPreviewText(replyTo))
} else {
text = l`Deleted message`
text = l({message: '(deleted message)', comment: 'A reply summary in chat'})
subtle = true
}
@@ -888,6 +883,8 @@ function ReplyQuote({
a.mb_xs,
a.rounded_md,
a.p_sm,
// The padding above is a little loose, so we tighten it up here.
{paddingTop: tokens.space.sm - 2},
a.flex_col,
a.align_start,
a.border,
-3
View File
@@ -77,9 +77,6 @@ let MessageItemEmbed = ({
minWidth: 280,
maxWidth: 360,
}),
// Cancel out the embed's internal a.mt_sm so the container's
// CLUSTERED_MESSAGE_GAP (2px) is the only spacing applied
{marginTop: -a.mt_sm.marginTop},
]}>
<Animated.View
style={[a.rounded_xl, a.overflow_hidden, radiiStyle, highlightStyle]}>
+112
View File
@@ -0,0 +1,112 @@
import {
AppBskyEmbedExternal,
AppBskyEmbedRecord,
type ChatBskyConvoDefs,
ChatBskyEmbedJoinLink,
ChatBskyGroupDefs,
} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
/**
* Describes the embed of a quoted message that has no text of its own, so the
* reply preview can show what was shared instead of a generic placeholder. For
* a link card we surface the URI directly; otherwise we classify the quoted
* record (post/feed/list/etc.) so the caller can render a translated label.
*/
type ReplyEmbedSummary =
| {type: 'external'; uri: string}
| {type: 'post'}
| {type: 'unknown'}
function summarizeReplyEmbed(
embed: ChatBskyConvoDefs.MessageView['embed'],
): ReplyEmbedSummary {
if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
const {record} = embed
if (AppBskyEmbedRecord.isViewRecord(record)) {
const inner = record.embeds?.[0]
if (AppBskyEmbedExternal.isView(inner)) {
return {type: 'external', uri: inner.external.uri}
}
return {type: 'post'}
}
return {type: 'unknown'}
}
/**
* Returns a formatter that computes the preview text for a message being quoted
* in a reply, shared between the staged-reply composer and the sent reply
* bubble so the two stay in sync. When the message has its own text we use it
* verbatim; otherwise we summarize the embed.
*
* `subtle` indicates the text is a placeholder (not real message content), so
* callers can render it in a muted/italic style.
*/
export function useReplyPreviewText(): (
message: ChatBskyConvoDefs.MessageView,
) => {text: string; subtle: boolean} {
const {t: l} = useLingui()
return (message: ChatBskyConvoDefs.MessageView) => {
const text = message.text
if (text.trim()) {
return {text, subtle: false}
}
if (ChatBskyEmbedJoinLink.isView(message.embed)) {
const {joinLinkPreview} = message.embed
if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
return {
text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
subtle: true,
}
}
if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(disabled chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
return {
text: l({
message: '(invalid chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
return {
text: l({
message: '(chat invite link)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
}
const summary = summarizeReplyEmbed(message.embed)
switch (summary.type) {
case 'external':
return {text: toShortUrl(summary.uri), subtle: true}
case 'post':
return {
text: l({
message: '(quoted post)',
comment: 'A reply summary in chat',
}),
subtle: true,
}
default:
return {
text: l({message: '(no text)', comment: 'A reply summary in chat'}),
subtle: true,
}
}
}
}
@@ -16,6 +16,8 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
label,
isInvalid,
testID,
@@ -26,14 +28,28 @@ export function DateField({
const t = useTheme()
const [open, setOpen] = useState(false)
/*
* The picker requires a valid date, so when value is empty we open at
* maximumDate (if set) or today. Normalize through toSimpleDateString so a
* date-only value is parsed as UTC midnight, consistent with the picker's
* timeZoneOffsetInMinutes={0} and the maximumDate below.
*/
const initialDate =
value === ''
? maximumDate
? new Date(toSimpleDateString(maximumDate))
: new Date()
: new Date(toSimpleDateString(value))
const onChangeInternal = useCallback(
(date: Date) => {
setOpen(false)
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
onConfirm?.(formatted)
},
[onChangeDate, setOpen],
[onChangeDate, onConfirm, setOpen],
)
useImperativeHandle(
@@ -63,6 +79,7 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
placeholder={placeholder}
onPress={onPress}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
@@ -77,7 +94,7 @@ export function DateField({
theme={t.scheme}
// @ts-ignore TODO
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
date={new Date(value)}
date={initialDate}
onConfirm={onChangeInternal}
onCancel={onCancel}
mode="date"
@@ -14,12 +14,14 @@ import {Text} from '#/components/Typography'
export function DateFieldButton({
label,
value,
placeholder,
onPress,
isInvalid,
accessibilityHint,
}: {
label: string
value: string | Date
placeholder?: string
onPress: () => void
isInvalid?: boolean
accessibilityHint?: string
@@ -78,20 +80,18 @@ export function DateFieldButton({
a.align_center,
hovered ? chromeHover : {},
focused || pressed ? chromeFocus : {},
isInvalid || isInvalid ? chromeError : {},
(isInvalid || isInvalid) && (hovered || focused)
? chromeErrorHover
: {},
isInvalid ? chromeError : {},
isInvalid && (hovered || focused) ? chromeErrorHover : {},
]}>
<TextField.Icon icon={CalendarDays} />
<Text
style={[
a.text_md,
a.pl_xs,
t.atoms.text,
value === '' ? t.atoms.text_contrast_low : t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875},
]}>
{i18n.date(value, {timeZone: 'UTC'})}
{value === '' ? placeholder : i18n.date(value, {timeZone: 'UTC'})}
</Text>
</Pressable>
</View>
+33 -4
View File
@@ -1,4 +1,4 @@
import {useCallback, useImperativeHandle} from 'react'
import {useCallback, useImperativeHandle, useState} from 'react'
import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {msg} from '@lingui/core/macro'
@@ -28,6 +28,8 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
testID,
label,
isInvalid,
@@ -38,10 +40,23 @@ export function DateField({
const t = useTheme()
const control = Dialog.useDialogControl()
/*
* The picker requires a valid date, so when value is empty we fall back to
* maximumDate (if set) or today. Draft state lets the picker scroll even when
* the parent does not echo value back (e.g. a clearable field).
*/
const fallbackDate = maximumDate
? toSimpleDateString(maximumDate)
: toSimpleDateString(new Date())
const [draft, setDraft] = useState(() =>
value === '' ? fallbackDate : toSimpleDateString(value),
)
const onChangeInternal = useCallback(
(date: Date | undefined) => {
if (date) {
const formatted = toSimpleDateString(date)
setDraft(formatted)
onChangeDate(formatted)
}
},
@@ -53,13 +68,14 @@ export function DateField({
() => ({
focus: () => {
Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open()
},
blur: () => {
control.close()
},
}),
[control],
[control, value, fallbackDate],
)
return (
@@ -67,8 +83,10 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
placeholder={placeholder}
onPress={() => {
Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open()
}}
isInvalid={isInvalid}
@@ -85,7 +103,7 @@ export function DateField({
<DatePicker
timeZoneOffsetInMinutes={0}
theme={t.scheme}
date={new Date(toSimpleDateString(value))}
date={new Date(draft)}
onDateChange={onChangeInternal}
mode="date"
locale={i18n.locale}
@@ -102,7 +120,18 @@ export function DateField({
</View>
<Button
label={_(msg`Done`)}
onPress={() => control.close()}
onPress={() => {
/*
* Commit the currently shown date even if the user never
* scrolled (onDateChange only fires on scroll). This keeps
* onChangeDate firing alongside onConfirm, matching Android and
* web, so an empty field confirmed without scrolling does not
* report a date via onConfirm while onChangeDate stays silent.
*/
onChangeDate(draft)
onConfirm?.(draft)
control.close()
}}
size="large"
color="primary"
variant="solid">
+4 -2
View File
@@ -36,6 +36,7 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
label,
isInvalid,
testID,
@@ -49,16 +50,17 @@ export function DateField({
if (date) {
const formatted = toSimpleDateString(date)
onChangeDate(formatted)
onConfirm?.(formatted)
}
},
[onChangeDate],
[onChangeDate, onConfirm],
)
return (
<TextField.Root isInvalid={isInvalid}>
<TextField.Icon icon={CalendarDays} />
<Input
value={toSimpleDateString(value)}
value={value === '' ? '' : toSimpleDateString(value)}
inputRef={inputRef as React.Ref<TextInput>}
label={label}
onChange={handleOnChange}
+15
View File
@@ -3,8 +3,23 @@ export type DateFieldRef = {
blur: () => void
}
export type DateFieldProps = {
/**
* An empty string renders the placeholder and opens the picker at today (or
* maximumDate, if earlier).
*/
value: string | Date
onChangeDate: (date: string) => void
/**
* Fired when the user commits a date: iOS "Done", Android confirm, or web
* input change. Distinct from onChangeDate, which on iOS fires on every
* scroll tick.
*/
onConfirm?: (date: string) => void
/**
* Shown on native when value is empty. Web uses the browser's native date
* placeholder.
*/
placeholder?: string
label: string
inputRef?: React.Ref<DateFieldRef>
isInvalid?: boolean
+8 -7
View File
@@ -65,7 +65,6 @@ export function ConstrainedImage({
export function AutoSizedImage({
image,
crop = 'constrained',
hideBadge,
onPress,
onLongPress,
onPressIn,
@@ -74,7 +73,6 @@ export function AutoSizedImage({
}: {
image: AppBskyEmbedImages.ViewImage
crop?: 'none' | 'square' | 'constrained'
hideBadge?: boolean
onPress?: (
containerRef: AnimatedRef<any>,
fetchedDims: Dimensions | null,
@@ -146,7 +144,7 @@ export function AutoSizedImage({
/>
<MediaInsetBorder />
{(hasAlt || isCropped) && !hideBadge ? (
{hasAlt || isCropped ? (
<View
accessible={false}
style={[
@@ -165,8 +163,10 @@ export function AutoSizedImage({
]}>
{isCropped && (
<View
accessible={false}
style={[
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
@@ -186,17 +186,18 @@ export function AutoSizedImage({
)}
{hasAlt && (
<View
accessible={false}
style={[
a.justify_center,
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
padding: 3,
opacity: 0.8,
},
largeAlt && [
{
padding: 5,
padding: 6,
},
],
]}>
+4 -7
View File
@@ -115,7 +115,6 @@ export function Gallery({
const bps = useBreakpoints()
const window = useWindowDimensions()
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
const hideBadges = isWithinQuote
const contentHeight = useMemo(() => {
if (isWithinChat) {
return 120
@@ -250,7 +249,6 @@ export function Gallery({
onPress?.(index, [containerRef], [dims])
}
onPressIn={() => onPressIn?.(index)}
hideBadge={isWithinQuote}
/>
))}
</View>
@@ -300,7 +298,6 @@ export function Gallery({
: undefined
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
@@ -396,7 +393,6 @@ function GalleryImage({
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
@@ -410,7 +406,6 @@ function GalleryImage({
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
@@ -499,7 +494,7 @@ function GalleryImage({
useAppleWebpCodec
/>
{!hideBadges && imageCount > 1 ? (
{imageCount > 1 ? (
<View
accessible={false}
pointerEvents="none"
@@ -532,7 +527,7 @@ function GalleryImage({
</View>
) : null}
{(hasAlt || isCropped) && !hideBadges ? (
{hasAlt || isCropped ? (
<View
accessible={false}
style={[
@@ -549,6 +544,7 @@ function GalleryImage({
]}>
{isCropped && (
<View
accessible={false}
style={[
a.rounded_sm,
a.p_xs,
@@ -568,6 +564,7 @@ function GalleryImage({
)}
{hasAlt && (
<View
accessible={false}
style={[
a.justify_center,
a.rounded_sm,
+5 -12
View File
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, tokens, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
type EventFunction = (index: number) => void
@@ -42,8 +42,6 @@ export function GalleryItem({
onPress,
onPressIn,
onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle,
containerRefs,
thumbDimsRef,
@@ -53,9 +51,6 @@ export function GalleryItem({
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
image.aspectRatio && image.aspectRatio.height > 0
@@ -114,26 +109,24 @@ export function GalleryItem({
<MediaInsetBorder style={insetBorderStyle} />
</Pressable>
</ImageContextMenu>
{hasAlt && !hideBadges ? (
{hasAlt ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
a.align_center,
a.rounded_xs,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
gap: 3,
padding: 3,
bottom: a.p_xs.padding,
right: a.p_xs.padding,
opacity: 0.8,
},
largeAltBadge && [
{
gap: 4,
padding: 5,
padding: 6,
},
],
]}>
+2 -2
View File
@@ -362,14 +362,14 @@ function MutualGroupChat({
<Button
color="negative_subtle"
disabled={isRemovePending}
label={l`Kick member`}
label={l`Remove member`}
size="small"
onPress={() => {
onOptimisticallyRemoveConvo(view.id)
removeMembers({members: [profileDid]})
}}>
<ButtonText>
<Trans>Kick member</Trans>
<Trans>Remove member</Trans>
</ButtonText>
{isRemovePending ? <ButtonIcon icon={Loader} /> : null}
</Button>
+11 -17
View File
@@ -2,7 +2,7 @@ import {type StyleProp, View, type ViewStyle} from 'react-native'
import {type AppBskyFeedDefs, type ComAtprotoLabelDefs} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Plural, Trans} from '@lingui/react/macro'
import {Plural} from '@lingui/react/macro'
import {useSession} from '#/state/session'
import {atoms as a} from '#/alf'
@@ -60,23 +60,17 @@ export function LabelsOnMe({
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{type === 'account' ? (
<Trans>
<Plural
value={labels.length}
one="# label has"
other="# labels have"
/>{' '}
been placed on this account
</Trans>
<Plural
value={labels.length}
one="# account label"
other="# account labels"
/>
) : (
<Trans>
<Plural
value={labels.length}
one="# label has"
other="# labels have"
/>{' '}
been placed on this content
</Trans>
<Plural
value={labels.length}
one="# content label"
other="# content labels"
/>
)}
</ButtonText>
</Button>
@@ -138,7 +138,7 @@ export function InviteFriendsDialogInner({
return (
<Dialog.ScrollableInner
label={l`Invite friends`}
label={l`Share Profile`}
contentContainerStyle={[a.pt_0, a.px_0]}
header={
<Dialog.Header
@@ -153,7 +153,7 @@ export function InviteFriendsDialogInner({
<ButtonText style={[a.text_md]}>{l`Done`}</ButtonText>
</Button>
)}>
<Dialog.HeaderText>{l`Invite Friends`}</Dialog.HeaderText>
<Dialog.HeaderText>{l`Share Profile`}</Dialog.HeaderText>
</Dialog.Header>
}>
<View style={[a.align_center, a.pt_xl, a.px_xl]}>
@@ -26,8 +26,10 @@ export function ThemePicker({
const labels: Record<InviteThemeKey, string> = {
dawn: l`Dawn`,
sunlight: l`Sunlight`,
day: l`Day`,
dusk: l`Dusk`,
twilight: l`Twilight`,
night: l`Night`,
}
+9 -2
View File
@@ -3,8 +3,15 @@ import {describe, expect, it} from '@jest/globals'
import {getInviteTheme, INVITE_THEME_KEYS, INVITE_THEMES} from './themes'
describe('invite themes', () => {
it('exposes four themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual(['dawn', 'day', 'dusk', 'night'])
it('exposes six themes in canonical order', () => {
expect(INVITE_THEME_KEYS).toEqual([
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
])
})
it('has light and dark variants for every theme', () => {
+45 -1
View File
@@ -5,7 +5,13 @@
* matching primary color; the QR data uses qrPrimary; the avatar handle text
* follows handleColor.
*/
export type InviteThemeKey = 'dawn' | 'day' | 'dusk' | 'night'
export type InviteThemeKey =
| 'dawn'
| 'sunlight'
| 'day'
| 'dusk'
| 'twilight'
| 'night'
export type InviteThemeVariant = {
/** QR data + eye color */
@@ -30,8 +36,10 @@ export type InviteTheme = {
export const INVITE_THEME_KEYS: readonly InviteThemeKey[] = [
'dawn',
'sunlight',
'day',
'dusk',
'twilight',
'night',
] as const
@@ -54,6 +62,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff',
},
},
sunlight: {
key: 'sunlight',
swatch: '#ff8159',
light: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#ff8159',
gradientFrom: '#ffc785',
gradientTo: '#ff8159',
shadowColor: '#ff8159',
handleColor: '#ffffff',
},
},
day: {
key: 'day',
swatch: '#006aff',
@@ -90,6 +116,24 @@ export const INVITE_THEMES: Record<InviteThemeKey, InviteTheme> = {
handleColor: '#ffffff',
},
},
twilight: {
key: 'twilight',
swatch: '#8b60f7',
light: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
dark: {
qrPrimary: '#8b60f7',
gradientFrom: '#8ec1ff',
gradientTo: '#8b60f7',
shadowColor: '#8b60f7',
handleColor: '#ffffff',
},
},
night: {
key: 'night',
swatch: '#0048ad',
+38 -40
View File
@@ -1,8 +1,7 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -108,6 +107,7 @@ let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() {
const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession()
@@ -116,7 +116,7 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui()
const {t: l} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android
// 28 or higher. Instead, we have to configure a notification channel ahead of time
@@ -129,14 +129,12 @@ export function useNotificationsHandler() {
// NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat'
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: _(msg`Chat`),
description: _(
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
),
void Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: l`Chat`,
description: l`You can choose whether chat notifications have sound in the chat settings within the app`,
})
Notifications.setNotificationChannelAsync('chat-messages', {
name: _(msg`Chat messages - sound`),
void Notifications.setNotificationChannelAsync('chat-messages', {
name: l`Chat messages - sound`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3',
@@ -144,8 +142,8 @@ export function useNotificationsHandler() {
vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: _(msg`Chat messages - silent`),
void Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: l`Chat messages - silent`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: null,
@@ -154,70 +152,70 @@ export function useNotificationsHandler() {
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'like' satisfies NotificationReason,
{
name: _(msg`Likes`),
name: l`Likes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'repost' satisfies NotificationReason,
{
name: _(msg`Reposts`),
name: l`Reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'reply' satisfies NotificationReason,
{
name: _(msg`Replies`),
name: l`Replies`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'mention' satisfies NotificationReason,
{
name: _(msg`Mentions`),
name: l`Mentions`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'quote' satisfies NotificationReason,
{
name: _(msg`Quotes`),
name: l`Quotes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'follow' satisfies NotificationReason,
{
name: _(msg`New followers`),
name: l`New followers`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies NotificationReason,
{
name: _(msg`Likes of your reposts`),
name: l`Likes of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies NotificationReason,
{
name: _(msg`Reposts of your reposts`),
name: l`Reposts of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason,
{
name: _(msg`Activity from others`),
name: l`Activity from others`,
importance: Notifications.AndroidImportance.HIGH,
},
)
}, [_])
}, [l])
useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
@@ -237,7 +235,7 @@ export function useNotificationsHandler() {
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
onPressSwitchAccount(account, 'Notification')
void onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
@@ -305,10 +303,10 @@ export function useNotificationsHandler() {
}
Notifications.setNotificationHandler({
handleNotification: async e => {
handleNotification: e => {
const payload = getNotificationPayload(e)
if (!payload) return DEFAULT_HANDLER_OPTIONS
if (!payload) return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
logger.debug('useNotificationsHandler: incoming', {e, payload})
@@ -323,17 +321,17 @@ export function useNotificationsHandler() {
payload.reason === 'chat-removed-from-group' ||
payload.reason === 'chat-join-request-rejected' ||
payload.convoId !== currentConvoId
return {
return Promise.resolve({
shouldShowList: shouldAlert,
shouldShowBanner: shouldAlert,
shouldPlaySound: false,
shouldSetBadge: false,
} satisfies Notifications.NotificationBehavior
} satisfies Notifications.NotificationBehavior)
}
// Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage()
return DEFAULT_HANDLER_OPTIONS
return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
},
})
@@ -363,14 +361,14 @@ export function useNotificationsHandler() {
})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if (
payload.reason === 'mention' ||
payload.reason === 'quote' ||
payload.reason === 'reply'
) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
logger.debug('Notifications: handleNotification', {
@@ -379,7 +377,7 @@ export function useNotificationsHandler() {
})
handleNotification(payload)
Notifications.dismissAllNotificationsAsync()
void Notifications.dismissAllNotificationsAsync()
// Also clear the native `lastResponse` cache. Otherwise a subsequent
// `getLastNotificationResponse()` (e.g. on an account-switch remount,
// which re-runs `handlePushNotificationEntry`) would replay this
+31 -10
View File
@@ -5,7 +5,7 @@ import {SUPPORTED_MIME_TYPES, type SupportedMimeTypes} from '#/lib/constants'
import {type CompressedVideo} from './types'
import {extToMime} from './util'
const MIN_SIZE_FOR_COMPRESSION = 25 // 25mb
const MIN_SIZE_FOR_COMPRESSION_BYTES = 25 * 1024 * 1024 // 25mb
export async function compressVideo(
file: ImagePickerAsset,
@@ -16,20 +16,36 @@ export async function compressVideo(
): Promise<CompressedVideo> {
const {onProgress, signal} = opts || {}
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (file.mimeType === 'image/gif') {
// let's hope they're small enough that they don't need compression!
// this compression library doesn't support gifs
// worst case - server rejects them. I think that's fine -sfn
return {uri: file.uri, size: file.fileSize ?? -1, mimeType: 'image/gif'}
return {
uri: file.uri,
size: file.fileSize ?? -1,
mimeType: 'image/gif',
passthroughReason: 'gif',
}
}
const minimumFileSizeForCompress = isAcceptableFormat
? MIN_SIZE_FOR_COMPRESSION
: 0
// Pre-check the threshold ourselves so we can label the skip in telemetry.
// rnc would do the same skip internally via minimumFileSizeForCompress, but
// that path is invisible to us.
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
file.mimeType as SupportedMimeTypes,
)
if (
isAcceptableFormat &&
file.fileSize != null &&
file.fileSize < MIN_SIZE_FOR_COMPRESSION_BYTES
) {
return {
uri: file.uri,
size: file.fileSize,
mimeType: file.mimeType ?? 'video/mp4',
passthroughReason: 'below-byte-threshold',
}
}
const compressed = await Video.compress(
file.uri,
@@ -37,8 +53,13 @@ export async function compressVideo(
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
// Force a transcode for unacceptable-format files regardless of size.
// rnc's default minimumFileSizeForCompress would otherwise pass small
// unacceptable-format files through unchanged and the server would
// reject them. Acceptable formats are already short-circuited above so
// they never reach this call.
// WARNING: this ONE SPECIFIC ARG is in MB -sfn
minimumFileSizeForCompress,
minimumFileSizeForCompress: 0,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
+308 -29
View File
@@ -1,56 +1,335 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {
ALL_FORMATS,
type AudioCodec,
BlobSource,
BufferTarget,
canEncodeAudio,
canEncodeVideo,
Conversion,
Input,
Mp4OutputFormat,
Output,
type VideoCodec,
WebMOutputFormat,
} from 'mediabunny'
import {VIDEO_MAX_SIZE} from '#/lib/constants'
import {VideoTooLargeError} from '#/lib/media/video/errors'
import {logger} from '#/logger'
import {hasWebCodecs} from '#/view/com/composer/videos/metadata'
import {
COMPRESSION_MAX_DIMENSION,
COMPRESSION_MIN_SIZE_BYTES,
COMPRESSION_TARGET_BITRATE,
} from './constants'
import {type CompressedVideo} from './types'
// doesn't actually compress, converts to ArrayBuffer
// Codecs to try in order of preference
// avc (H.264) is most compatible, vp9/vp8 are fallbacks for WebM
const VIDEO_CODECS: VideoCodec[] = ['avc', 'hevc', 'vp9', 'vp8']
export async function compressVideo(
asset: ImagePickerAsset,
_opts?: {
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
const {onProgress, signal} = opts || {}
logger.debug('compress: starting', {
uri: asset.uri.slice(0, 50),
hasWebCodecs: hasWebCodecs(),
})
const response = await fetch(asset.uri)
const blob = await response.blob()
const isGif = blob.type === 'image/gif'
const hasCodecs = hasWebCodecs()
logger.debug('compress: fetched blob', {
size: blob.size,
mimeType: blob.type,
isGif,
minSizeForCompression: COMPRESSION_MIN_SIZE_BYTES,
})
// Try MediaBunny compression if WebCodecs is available and file is large enough
// Skip GIFs - MediaBunny doesn't support them
let fallbackReason: NonNullable<CompressedVideo['passthroughReason']> | null =
null
if (isGif) {
fallbackReason = 'gif'
} else if (!hasCodecs) {
fallbackReason = 'no-webcodecs'
} else if (blob.size < COMPRESSION_MIN_SIZE_BYTES) {
fallbackReason = 'below-byte-threshold'
} else {
try {
return await doCompression(blob, asset.uri, {onProgress, signal})
} catch (e) {
logger.warn('compress: MediaBunny compression failed, using original', {
safeMessage: e,
})
fallbackReason = 'compress-error-fallback'
}
}
logger.debug('compress: skipping compression', {
hasWebCodecs: hasCodecs,
blobSize: blob.size,
minSize: COMPRESSION_MIN_SIZE_BYTES,
reason: fallbackReason,
})
// No compression path - just return the blob as-is
if (blob.size > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
return {
uri: asset.uri,
size: blob.size,
uri,
bytes: await blob.arrayBuffer(),
mimeType: blob.type || 'video/mp4',
passthroughReason: fallbackReason,
}
}
async function findEncodableVideoCodec(
width: number,
height: number,
): Promise<{codec: VideoCodec; useWebM: boolean} | null> {
for (const codec of VIDEO_CODECS) {
const canEncode = await canEncodeVideo(codec, {
width,
height,
bitrate: COMPRESSION_TARGET_BITRATE,
})
logger.debug('compress: checking video codec', {
codec,
canEncode,
width,
height,
})
if (canEncode) {
// vp8/vp9 need WebM container, others use MP4
const useWebM = codec === 'vp8' || codec === 'vp9'
return {codec, useWebM}
}
}
return null
}
// Audio codecs to try - aac for MP4, opus for WebM
const AUDIO_CODECS_MP4: AudioCodec[] = ['aac']
const AUDIO_CODECS_WEBM: AudioCodec[] = ['opus', 'vorbis']
async function findEncodableAudioCodec(
audioTrack: Awaited<ReturnType<Input['getPrimaryAudioTrack']>>,
useWebM: boolean,
): Promise<{codec: AudioCodec} | null> {
if (!audioTrack) {
return null
}
// First check if we can decode the source audio
const canDecodeSource = await audioTrack.canDecode()
logger.debug('compress: checking audio source', {
sourceCodec: audioTrack.codec,
canDecode: canDecodeSource,
channels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
if (!canDecodeSource) {
return null
}
const codecsToTry = useWebM ? AUDIO_CODECS_WEBM : AUDIO_CODECS_MP4
for (const codec of codecsToTry) {
const canEncode = await canEncodeAudio(codec, {
numberOfChannels: audioTrack.numberOfChannels,
sampleRate: audioTrack.sampleRate,
})
logger.debug('compress: checking audio encode codec', {
codec,
canEncode,
})
if (canEncode) {
return {codec}
}
}
return null
}
async function doCompression(
blob: Blob,
originalUri: string,
opts: {
onProgress?: (progress: number) => void
signal?: AbortSignal
},
): Promise<CompressedVideo> {
const {onProgress, signal} = opts
const input = new Input({
source: new BlobSource(blob),
formats: ALL_FORMATS,
})
// Get video track to determine dimensions for codec check
const videoTrack = await input.getPrimaryVideoTrack()
if (!videoTrack) {
input.dispose()
throw new Error('No video track found')
}
// Get audio track to check if we can encode it
const audioTrack = await input.getPrimaryAudioTrack()
const {width, height} = calculateDimensions(
videoTrack.displayWidth,
videoTrack.displayHeight,
COMPRESSION_MAX_DIMENSION,
)
logger.debug('compress: video dimensions', {
original: {
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
},
target: {width, height},
audioCodec: audioTrack?.codec,
})
// Find a video codec we can encode with
const codecInfo = await findEncodableVideoCodec(width, height)
if (!codecInfo) {
input.dispose()
throw new Error('No supported video codec available')
}
// Check if we can encode the audio
const audioCodecInfo = await findEncodableAudioCodec(
audioTrack,
codecInfo.useWebM,
)
logger.debug('compress: using codecs', {
video: codecInfo.codec,
audio: audioCodecInfo?.codec ?? 'none',
useWebM: codecInfo.useWebM,
})
const target = new BufferTarget()
const output = new Output({
format: codecInfo.useWebM ? new WebMOutputFormat() : new Mp4OutputFormat(),
target,
})
// If we have audio but can't encode it, bail out and use the original
if (audioTrack && !audioCodecInfo) {
input.dispose()
throw new Error(
`Cannot encode audio codec: ${audioTrack.codec ?? 'unknown'}`,
)
}
const conversion = await Conversion.init({
input,
output,
video: {
codec: codecInfo.codec,
bitrate: COMPRESSION_TARGET_BITRATE,
width,
height,
fit: 'contain',
},
audio: audioCodecInfo ? {codec: audioCodecInfo.codec} : undefined,
})
if (onProgress) {
conversion.onProgress = onProgress
}
if (signal) {
signal.addEventListener(
'abort',
() => {
logger.debug('compress: cancelled')
void conversion.cancel()
},
{once: true},
)
}
logger.debug('compress: starting conversion')
const startTime = performance.now()
try {
await conversion.execute()
} finally {
input.dispose()
}
const elapsed = performance.now() - startTime
const bytes = target.buffer
if (!bytes) {
// mediabunny's BufferTarget reports a null buffer after a successful
// execute(). Should not happen in normal use; recoverable here because
// the outer compressVideo() catches and falls back to the original blob,
// but worth flagging in Sentry so we can chase the root cause.
const err = new Error('Compression produced empty output')
logger.error(err, {})
throw err
}
const mimeType = codecInfo.useWebM ? 'video/webm' : 'video/mp4'
const savedBytes = blob.size - bytes.byteLength
const savedPercent = ((savedBytes / blob.size) * 100).toFixed(1)
logger.debug('compress: completed', {
from: blob.type,
to: mimeType,
originalSize: blob.size,
compressedSize: bytes.byteLength,
savedBytes,
savedPercent: `${savedPercent}%`,
elapsedMs: Math.round(elapsed),
})
if (bytes.byteLength > VIDEO_MAX_SIZE) {
throw new VideoTooLargeError()
}
return {
uri: originalUri,
size: bytes.byteLength,
bytes,
mimeType,
}
}
function parseDataUrl(dataUrl: string) {
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
if (!mimeType || !base64) {
throw new Error('Invalid data URL')
}
return {mimeType, base64}
}
function base64ToBlob(base64: string, mimeType: string) {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
function calculateDimensions(
width: number,
height: number,
maxDimension: number,
): {width: number; height: number} {
const maxSide = Math.max(width, height)
if (maxSide <= maxDimension) {
return {width, height}
}
return new Blob(byteArrays, {type: mimeType})
const scale = maxDimension / maxSide
return {
width: Math.round(width * scale),
height: Math.round(height * scale),
}
}
+8
View File
@@ -0,0 +1,8 @@
// Shared compression knobs. Mirrored between native (compress.ts) and web
// (compress.web.ts) so both platforms produce videos with the same target.
export const COMPRESSION_TARGET_BITRATE = 3_000_000 // 3 Mbps
export const COMPRESSION_MAX_DIMENSION = 1920
// Web skips compression entirely for files under this size; native applies its
// own threshold logic inside expo-bluesky-video-compress's probe step.
export const COMPRESSION_MIN_SIZE_BYTES = 25_000_000
+271
View File
@@ -0,0 +1,271 @@
import {Platform} from 'react-native'
import {type ImagePickerAsset} from 'expo-image-picker'
import {nanoid} from 'nanoid/non-secure'
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
import {Sentry} from '#/logger/sentry/lib'
import {type Metrics} from '#/analytics/metrics'
type MetricFn = <E extends keyof Metrics>(event: E, payload: Metrics[E]) => void
// Identifies the active compression engine. Bumped when the engine swaps.
// Versions are intentionally hardcoded so a dependency bump shows up in
// analytics as a label change.
const COMPRESS_ENGINE =
Platform.OS === 'web'
? 'web:mediabunny@1.25.3'
: 'native:react-native-compressor@1.13.0'
type Phase = 'compress' | 'upload' | 'processing'
function errorClass(e: unknown): string {
if (e instanceof Error) return e.name || 'Error'
return 'Unknown'
}
export type VideoTelemetry = {
readonly uploadId: string
readonly engine: string
picked: () => void
compressStarted: () => void
compressSkipped: (video: {
size: number
mimeType: string
skipReason: VideoCompressSkipReason
}) => void
compressCompleted: (video: {size: number; mimeType: string}) => void
compressFailed: (e: unknown) => void
uploadStarted: (bytes: number) => void
uploadCompleted: (jobId: string) => void
uploadFailed: (e: unknown) => void
processingStarted: (jobId: string) => void
processingCompleted: () => void
processingFailed: (e: unknown) => void
published: () => void
}
export function createVideoTelemetry({
asset,
signal,
metric,
}: {
asset: ImagePickerAsset
signal: AbortSignal
metric: MetricFn
}): VideoTelemetry {
const uploadId = nanoid()
const engine = COMPRESS_ENGINE
const startedAt = Date.now()
let phase: Phase | undefined
let phaseStartedAt = startedAt
let jobId: string | undefined
let uploadBytes: number | undefined
let txnEnded = false
let abortBound = true
// Parent span: full selection->ready arc. Inactive so phase spans can be
// attached as children regardless of the current async context.
const txn = Sentry.startInactiveSpan({
name: 'video.upload',
op: 'video.upload',
attributes: {
uploadId,
engine,
'video.source.mime': asset.mimeType ?? 'unknown',
'video.source.bytes': asset.fileSize ?? 0,
'video.source.durationMs': asset.duration ?? 0,
'video.source.width': asset.width ?? 0,
'video.source.height': asset.height ?? 0,
},
})
let phaseSpan: ReturnType<typeof Sentry.startInactiveSpan> | undefined
function endPhaseSpan() {
if (!phaseSpan) return
phaseSpan.end()
phaseSpan = undefined
}
function enterPhase(next: Phase, spanName: string) {
endPhaseSpan()
phase = next
phaseStartedAt = Date.now()
phaseSpan = Sentry.withActiveSpan(txn, () =>
Sentry.startInactiveSpan({
name: spanName,
op: spanName,
attributes: {uploadId, engine},
}),
)
}
function endTxn(outcome: 'ok' | 'error' | 'cancelled') {
if (txnEnded) return
txnEnded = true
endPhaseSpan()
txn.setAttribute('outcome', outcome)
txn.end()
}
function detachAbort() {
if (!abortBound) return
abortBound = false
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
if (phase) {
metric('video:upload:abandoned', {
uploadId,
engine,
phase,
jobId,
elapsedInPhaseMs: Date.now() - phaseStartedAt,
})
}
endTxn('cancelled')
abortBound = false
}
signal.addEventListener('abort', onAbort, {once: true})
return {
uploadId,
engine,
picked() {
metric('video:upload:picked', {
uploadId,
engine,
sourceMimeType: asset.mimeType,
sourceBytes: asset.fileSize,
sourceDurationMs: asset.duration ?? undefined,
sourceWidth: asset.width,
sourceHeight: asset.height,
})
},
compressStarted() {
enterPhase('compress', 'video.compress')
metric('video:upload:compressStarted', {
uploadId,
engine,
sourceBytes: asset.fileSize,
})
},
compressSkipped({size, mimeType, skipReason}) {
metric('video:upload:compressSkipped', {
uploadId,
engine,
skipReason,
bytes: size,
mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressCompleted({size, mimeType}) {
metric('video:upload:compressCompleted', {
uploadId,
engine,
bytesIn: asset.fileSize,
bytesOut: size,
outputMimeType: mimeType,
elapsedMs: Date.now() - phaseStartedAt,
})
endPhaseSpan()
phase = undefined
},
compressFailed(e) {
metric('video:upload:compressFailed', {
uploadId,
engine,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
uploadStarted(bytes) {
uploadBytes = bytes
enterPhase('upload', 'video.upload.transfer')
metric('video:upload:uploadStarted', {uploadId, engine, bytes})
},
uploadCompleted(id) {
jobId = id
const elapsedMs = Date.now() - phaseStartedAt
const bytes = uploadBytes ?? 0
metric('video:upload:uploadCompleted', {
uploadId,
engine,
jobId: id,
bytes,
elapsedMs,
throughputBytesPerSec:
elapsedMs > 0 ? Math.round((bytes * 1000) / elapsedMs) : 0,
})
endPhaseSpan()
phase = undefined
},
uploadFailed(e) {
metric('video:upload:uploadFailed', {
uploadId,
engine,
bytes: uploadBytes ?? 0,
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
processingStarted(id) {
jobId = id
enterPhase('processing', 'video.processing')
metric('video:upload:processingStarted', {uploadId, engine, jobId: id})
},
processingCompleted() {
metric('video:upload:processingCompleted', {
uploadId,
engine,
jobId: jobId ?? '',
elapsedMs: Date.now() - phaseStartedAt,
})
// Upload pipeline is done; publish is a separate user action that
// fires its own event. Releases the parent span so its duration
// measures upload work, not idle composer time.
endTxn('ok')
detachAbort()
},
processingFailed(e) {
metric('video:upload:processingFailed', {
uploadId,
engine,
jobId: jobId ?? '',
errorClass: errorClass(e),
elapsedMs: Date.now() - phaseStartedAt,
})
endTxn('error')
detachAbort()
},
published() {
metric('video:upload:published', {
uploadId,
engine,
jobId: jobId ?? '',
totalElapsedMs: Date.now() - startedAt,
})
},
}
}
+13
View File
@@ -1,7 +1,20 @@
// Why the compress engine returned the input unchanged. Used both as the
// reason on `CompressedVideo.passthroughReason` and as the `skipReason` field
// on the `video:upload:compressSkipped` analytics event, so the two stay in
// sync.
export type VideoCompressSkipReason =
| 'gif'
| 'below-byte-threshold'
| 'no-webcodecs'
| 'compress-error-fallback'
export type CompressedVideo = {
uri: string
mimeType: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
// Set when the engine returned the input unchanged. Undefined means the
// bytes were actually re-encoded.
passthroughReason?: VideoCompressSkipReason
}
File diff suppressed because it is too large Load Diff
+96 -23
View File
@@ -3,14 +3,21 @@ import {View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {
useFocusEffect,
useIsFocused,
useNavigation,
} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {useAppState} from '#/lib/appState'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
import {
type MessagesTabNavigatorParams,
type NavigationProp,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {listenSoftReset} from '#/state/events'
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List, type ListRef} from '#/view/com/util/List'
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
@@ -31,6 +39,7 @@ import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
import {
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import * as Menu from '#/components/Menu'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
import {IS_NATIVE, IS_WEB} from '#/env'
@@ -289,7 +300,10 @@ export function ChatList({
isError,
error,
refetch,
} = useListConvosQuery({status: 'accepted'})
} = useListConvosQuery({
status: 'accepted',
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
})
const {refetch: refetchInbox} = useListConvosQuery({
status: 'request',
@@ -546,16 +560,19 @@ export function Header({
variant="solid"
action={action}
/>
<Link
to="/messages/settings"
action={action}
label={l`Chat settings`}
size="small"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} />
</Link>
<ChatSettingsMenu action={action}>
{({props}) => (
<Button
{...props}
label={l`Chat options`}
size="small"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} />
</Button>
)}
</ChatSettingsMenu>
{!chatStatus?.chatDisabled && (
<Button
label={l`New chat`}
@@ -578,19 +595,75 @@ export function Header({
</Layout.Header.Content>
<InboxRequests count={requestCount} variant="ghost" />
<Layout.Header.Slot>
<Link
to="/messages/settings"
label={l`Chat settings`}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} size="lg" />
</Link>
<ChatSettingsMenu action={action}>
{({props}) => (
<Button
{...props}
label={l`Chat options`}
size="small"
variant="ghost"
color="secondary"
shape="round"
style={[a.justify_center]}>
<ButtonIcon icon={SettingsIcon} size="lg" />
</Button>
)}
</ChatSettingsMenu>
</Layout.Header.Slot>
</>
)}
</Layout.Header.Outer>
)
}
function ChatSettingsMenu({
action,
children,
}: {
action: 'navigate' | 'push'
children: React.ComponentProps<typeof Menu.Trigger>['children']
}) {
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const {mutate: markAllChatsRead} = useUpdateAllRead('accepted', {
onMutate: () => {
Toast.show(l`Marked all chats as read`, {type: 'success'})
},
onError: () => {
Toast.show(l`Failed to mark all chats as read`, {type: 'error'})
},
})
return (
<Menu.Root>
<Menu.Trigger label={l`Chat options`}>{children}</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`Mark all chats as read`}
onPress={() => markAllChatsRead()}>
<Menu.ItemIcon icon={CircleCheckIcon} />
<Menu.ItemText>
<Trans>Mark all chats as read</Trans>
</Menu.ItemText>
</Menu.Item>
<Menu.Item
label={l`Chat settings`}
onPress={() => {
if (action === 'navigate') {
navigation.navigate('MessagesSettings')
} else {
navigation.push('MessagesSettings')
}
}}>
<Menu.ItemIcon icon={SettingsIcon} />
<Menu.ItemText>
<Trans>Chat settings</Trans>
</Menu.ItemText>
</Menu.Item>
</Menu.Group>
</Menu.Outer>
</Menu.Root>
)
}
+3 -10
View File
@@ -23,6 +23,7 @@ import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events'
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
@@ -84,16 +85,8 @@ export function MessagesInboxScreenInner({}: Props) {
return items
}, [data])
const hasUnreadConvos = useMemo(() => {
return conversations.some(
item =>
item.type === 'incoming' &&
item.view.members.every(
member => member.handle !== 'missing.invalid',
) &&
item.view.unreadCount > 0,
)
}, [conversations])
const {data: unreadCounts} = useUnreadCountsQuery()
const hasUnreadConvos = (unreadCounts?.unreadRequestConvos ?? 0) > 0
return (
<Layout.Screen testID="messagesInboxScreen">
+3 -3
View File
@@ -212,7 +212,7 @@ function JoinRequestsList({
useJoinRequestMutation('reject', convoId, {
onSuccess: () => {
ax.metric('groupchat:owner:joinRequest:reject', {convoId})
Toast.show(l`Request ignored.`)
Toast.show(l`Request rejected.`)
if (getRemainingRequestCount() < 1) {
navigation.replace('MessagesConversationSettings', {
conversation: convoId,
@@ -220,7 +220,7 @@ function JoinRequestsList({
}
},
onError: error => {
let errorMessage = l`Failed to ignore join request`
let errorMessage = l`Failed to reject join request`
if (isNetworkError(error)) {
errorMessage = l`A network error occurred. Please check your internet connection.`
} else if (
@@ -230,7 +230,7 @@ function JoinRequestsList({
} else if (
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
) {
errorMessage = l`Only admins can ignore join requests.`
errorMessage = l`Only admins can reject join requests.`
}
Toast.show(errorMessage, {type: 'error'})
},
+103 -8
View File
@@ -1,13 +1,19 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {
type NotificationSettingsPreference,
useChatNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {ExportCarDialog} from '#/screens/Settings/components/ExportCarDialog'
import {ChatNotificationDialogs} from '#/screens/Settings/NotificationSettings/components/ChatNotificationDialogs'
import {SettingPreview} from '#/screens/Settings/NotificationSettings/components/SettingPreview'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
@@ -18,7 +24,10 @@ import * as Toggle from '#/components/forms/Toggle'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import * as Layout from '#/components/Layout'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
@@ -52,8 +61,12 @@ export function MessagesSettingsScreenInner({}: Props) {
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
const {data: chatNotificationSettings, isError: chatSettingsError} =
useChatNotificationSettingsQuery()
const {preferences, setPref} = useBackgroundNotificationPreferences()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const exportCarControl = Dialog.useDialogControl()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -237,6 +250,54 @@ export function MessagesSettingsScreenInner({}: Props) {
<Divider style={{marginVertical: 10}} />
</>
) : null}
<View style={[a.px_xl, a.gap_lg]}>
<Text style={[a.pb_xs, a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>Notifications</Trans>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new messages`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatDialogControl.open()
}}>
<MessageIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New messages</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chat}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new message requests`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatRequestDialogControl.open()
}}>
<EnvelopeIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New message requests</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chatRequest}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
{IS_NATIVE && (
<>
<View style={[a.px_xl]}>
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</>
)}
<View style={[a.px_xl]}>
<Toggle.Item
label={l`Export my chat data`}
name="playSoundChat"
value={preferences.playSoundChat}
style={[a.flex_row, a.align_center, a.justify_between]}
onChange={() => {
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Export my chat data`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}
onPress={() => {
exportCarControl.open()
}}>
<CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
<Trans>Export my chat data</Trans>
</Text>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Toggle.Item>
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
</View>
</Layout.Content>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<ExportCarDialog control={exportCarControl} />
</Layout.Screen>
)
}
function NotificationPreferenceSubtitle({
preference,
isLoading,
isError,
}: {
preference?: NotificationSettingsPreference
isLoading: boolean
isError: boolean
}) {
const t = useTheme()
if (isError) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>Failed to load notification settings.</Trans>
</Text>
)
}
if (isLoading) {
return <Skele.Text style={[a.text_sm, {width: 120}]} />
}
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<SettingPreview preference={preference} />
</Text>
)
}
@@ -1,14 +1,12 @@
import {plural} from '@lingui/core/macro'
import {useLingui} from '@lingui/react/macro'
import {UNREAD_REQUEST_CAP} from '#/state/queries/messages/get-unread-counts'
import {atoms as a} from '#/alf'
import {ButtonIcon, ButtonText} from '#/components/Button'
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
import {Link} from '#/components/Link'
// The server caps unreadRequestConvos at 11, where 11 means "any more than 10".
const REQUEST_COUNT_CAP = 11
export function InboxRequests({
count,
variant,
@@ -21,7 +19,7 @@ export function InboxRequests({
const {t: l} = useLingui()
const unread = count > 0
const overflow = count >= REQUEST_COUNT_CAP
const overflow = count >= UNREAD_REQUEST_CAP
const label = !unread
? l({
@@ -30,8 +28,8 @@ export function InboxRequests({
})
: overflow
? l({
message: `10+ requests`,
comment: 'Displayed when the number of requests is greater than 10',
message: `${UNREAD_REQUEST_CAP - 1}+ requests`,
comment: 'Displayed when the number of requests exceeds the cap',
})
: plural(count, {
one: '# request',
@@ -55,9 +53,9 @@ export function InboxRequests({
<ButtonText style={[a.text_md, a.font_bold]}>
{overflow
? l({
message: `10+`,
message: `${UNREAD_REQUEST_CAP - 1}+`,
comment:
'Displayed when the number of requests is greater than 10',
'Displayed when the number of requests exceeds the cap for example, 99+ requests',
})
: count}
</ButtonText>
@@ -77,6 +77,15 @@ export function MessageComposer({
composerInternalApiRef.current?.input?.focus()
}, [replyTo, composerInternalApiRef])
// On web, focus the input once the conversation is ready. The composer also
// mounts during the loading state (when it isn't editable), so a mount-time
// autoFocus would fire too early to land focus.
useEffect(() => {
if (IS_WEB && editable) {
composerInternalApiRef.current?.input?.focus()
}
}, [editable, composerInternalApiRef])
// Android interactive dismiss sometimes doesn't blur the input
const blur = useNonReactiveCallback(() => {
composerInternalApiRef.current?.input?.blur()
@@ -243,13 +252,12 @@ export function MessageComposer({
placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
: l({message: 'Message', context: 'description'})
}
autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef}
defaultValue={text}
editable={editable}
autoFocus={IS_WEB}
maxRows={12}
outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]}
@@ -334,8 +342,7 @@ function SubmitButton({
)
}
// TODO: remove export when MessageInput is deleted
export function ComposerContainer({children}: {children: React.ReactNode}) {
function ComposerContainer({children}: {children: React.ReactNode}) {
const {bottom: bottomInset} = useSafeAreaInsets()
const {progress} = useReanimatedKeyboardAnimation()
const t = useTheme()
@@ -1,288 +0,0 @@
import {useCallback, useState} from 'react'
import {Pressable, TextInput, useWindowDimensions} from 'react-native'
import {
useFocusedInputHandler,
useKeyboardHandler,
useReanimatedKeyboardAnimation,
} from 'react-native-keyboard-controller'
import Animated, {
measure,
runOnJS,
useAnimatedProps,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} 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'
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useEmail} from '#/state/email-verification'
import {
useMessageDraft,
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'
import * as Toast from '#/components/Toast'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import {ComposerContainer} from './MessageComposer'
import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
const MIN_HEIGHT = 40
export function MessageInput({
textInputId,
onSendMessage,
messageEmbed,
setEmbed,
children,
loading = false,
}: {
textInputId?: string
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => Promise<void> | void
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
loading?: boolean
}) {
const {t: l} = useLingui()
const t = useTheme()
const playHaptic = useHaptics()
const {getDraft, clearDraft} = useMessageDraft()
const {replyTo, clearReply} = useMessageReplies()
// Input layout
const {top: topInset} = useSafeAreaInsets()
const {height: windowHeight} = useWindowDimensions()
const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
const maxHeight = useSharedValue<undefined | number>(undefined)
const isInputScrollable = useSharedValue(false)
const [message, setMessage] = useState(getDraft)
const inputRef = useAnimatedRef<TextInput>()
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
const {needsEmailVerification} = useEmail()
const editable = !needsEmailVerification && !loading
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
const onSubmit = useCallback(() => {
if (!editable) {
return
}
if (!messageEmbed && message.trim() === '') {
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
}
clearDraft()
playHaptic()
// Capture the embed before clearing - the deferred send below reads it.
const embed = messageEmbed
setEmbed(undefined)
setMessage('')
// Capture the reply before clearing - the deferred send below reads it.
const reply = replyTo
clearReply()
if (IS_IOS) {
setShouldEnforceClear(true)
}
if (IS_WEB) {
// Pressing the send button causes the text input to lose focus, so we need to
// re-focus it after sending
setTimeout(() => {
inputRef.current?.focus()
}, 100)
}
requestAnimationFrame(() => {
void onSendMessage(
message,
embed,
reply
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
: undefined,
)
})
}, [
editable,
messageEmbed,
message,
clearDraft,
onSendMessage,
playHaptic,
setEmbed,
inputRef,
l,
replyTo,
clearReply,
])
useFocusedInputHandler(
{
onChangeText: () => {
'worklet'
const measurement = measure(inputRef)
if (!measurement) return
const max = windowHeight - -keyboardHeight.get() - topInset - 150
const availableSpace = max - measurement.height
maxHeight.set(max)
isInputScrollable.set(availableSpace < 30)
},
},
[windowHeight, topInset],
)
const animatedStyle = useAnimatedStyle(() => ({
maxHeight: maxHeight.get(),
}))
const animatedProps = useAnimatedProps(() => ({
scrollEnabled: isInputScrollable.get(),
}))
const submitDisabled =
!editable || (!messageEmbed && message.trim().length === 0)
const blur = useCallback(() => {
inputRef.current?.blur()
}, [inputRef])
useKeyboardHandler({
onEnd: evt => {
'worklet'
// small hack: interactive dismiss on Android sometimes doesn't blur the input
if (IS_ANDROID && evt.progress === 0) {
runOnJS(blur)()
}
},
})
return (
<ComposerContainer>
<GlassContainer
style={[a.flex_row, a.align_end, a.gap_sm]}
spacing={tokens.space.xs}>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}>
{children}
<AnimatedTextInput
nativeID={textInputId}
accessibilityLabel={l`Message input field`}
accessibilityHint={l`Type your message here`}
placeholder={l`Message`}
placeholderTextColor={t.palette.contrast_500}
value={message}
onChange={evt => {
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
// including the button we just pressed - and this overrides clearing the input! so we watch for the
// next change and double make sure the input is cleared. It should *always* send an onChange event after
// clearing via setMessage('') that happens in onSubmit()
// -sfn
if (IS_IOS && shouldEnforceClear) {
setShouldEnforceClear(false)
setMessage('')
return
}
const text = evt.nativeEvent.text
setMessage(text)
}}
multiline={true}
style={[
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
a.flex_shrink_0,
a.flex_grow,
a.text_md,
a.px_lg,
t.atoms.text,
platform({
android: {paddingTop: 2, paddingBottom: 3},
ios: {paddingTop: 10, paddingBottom: 5},
}),
animatedStyle,
]}
verticalAlign="middle"
keyboardAppearance={t.scheme}
submitBehavior="newline"
ref={inputRef}
hitSlop={HITSLOP_10}
animatedProps={animatedProps}
editable={editable}
/>
</GlassView>
<GlassView
isInteractive
glassEffectStyle="regular"
style={[a.rounded_full]}
tintColor={
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
}
fallbackStyle={{
backgroundColor: submitDisabled
? t.palette.contrast_100
: t.palette.primary_500,
}}>
<Pressable
accessibilityRole="button"
accessibilityLabel={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
accessibilityHint=""
hitSlop={HITSLOP_10}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
height: MIN_HEIGHT,
width: MIN_HEIGHT,
},
]}
onPress={onSubmit}
disabled={submitDisabled}>
{loading ? (
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
) : (
<PaperPlaneIcon
size="md"
fill={t.palette.white}
style={[a.mb_2xs]}
/>
)}
</Pressable>
</GlassView>
</GlassContainer>
</ComposerContainer>
)
}
@@ -1,271 +0,0 @@
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'
import {countGraphemes} from 'unicode-segmenter/grapheme'
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {
useMessageDraft,
useSaveMessageDraft,
} 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'
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
import * as Toast from '#/components/Toast'
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
import {
type MessageEmbedState,
useExtractEmbedFromFacets,
} from './MessageInputEmbed'
export function MessageInput({
onSendMessage,
messageEmbed,
setEmbed,
children,
loading = false,
}: {
onSendMessage: (
message: string,
embed?: MessageEmbedState,
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
) => void
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
loading?: boolean
}) {
const {isMobile} = useWebMediaQueries()
const {t: l} = useLingui()
const t = useTheme()
const {getDraft, clearDraft} = useMessageDraft()
const {replyTo, clearReply} = useMessageReplies()
const [message, setMessage] = useState(getDraft)
const inputStyles = useSharedInputStyles()
const isComposing = useRef(false)
const [isFocused, setIsFocused] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [textAreaHeight, setTextAreaHeight] = useState(38)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const onSubmit = useCallback(() => {
if (!messageEmbed && message.trim() === '') {
return
}
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
Toast.show(l`Message is too long`, {
type: 'error',
})
return
}
clearDraft()
onSendMessage(
message,
messageEmbed,
replyTo
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
: undefined,
)
clearReply()
setMessage('')
setEmbed(undefined)
}, [
message,
onSendMessage,
l,
clearDraft,
messageEmbed,
setEmbed,
replyTo,
clearReply,
])
const onKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// Don't submit the form when the Japanese or any other IME is composing
if (isComposing.current) return
// see https://github.com/bluesky-social/social-app/issues/4178
// see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
// see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
//
// On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
// Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
// stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
//
// Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
// onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
// uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
// far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
// frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
// time gap between the two events firing.
if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) {
return
}
if (e.key === 'Enter') {
if (e.shiftKey) return
e.preventDefault()
onSubmit()
}
},
[onSubmit],
)
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
}, [])
const onEmojiInserted = useCallback(
(emoji: EmojiPicker.Emoji) => {
if (!textAreaRef.current) {
return
}
const position = textAreaRef.current.selectionStart ?? 0
flushSync(() => {
setMessage(
message =>
message.slice(0, position) + emoji.native + message.slice(position),
)
})
textAreaRef.current.selectionStart = position + emoji.native.length
textAreaRef.current.selectionEnd = position + emoji.native.length
},
[setMessage],
)
useSaveMessageDraft(message)
useExtractEmbedFromFacets(message, setEmbed)
return (
<View style={a.p_sm}>
{children}
<View
style={[
a.flex_row,
t.atoms.bg_contrast_25,
{
paddingRight: a.p_sm.padding - 2,
paddingLeft: a.p_sm.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
height: textAreaHeight + 23,
},
isHovered && inputStyles.chromeHover,
isFocused && inputStyles.chromeFocus,
]}
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
{loading ? null : (
<EmojiPicker.Root
onEmojiSelect={onEmojiInserted}
nextFocusRef={textAreaRef}>
<EmojiPicker.Trigger label={l`Open emoji picker`}>
{({props, state}) => (
<Button
style={[
a.rounded_full,
a.overflow_hidden,
a.align_center,
a.justify_center,
{
marginTop: 5,
height: 30,
width: 30,
},
]}
label={props.accessibilityLabel}
{...props}>
<View
style={[
a.absolute,
a.inset_0,
a.align_center,
a.justify_center,
{
backgroundColor:
state.hovered || state.focused || state.pressed
? t.atoms.bg.backgroundColor
: undefined,
},
]}>
<EmojiSmile size="lg" />
</View>
</Button>
)}
</EmojiPicker.Trigger>
<EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<TextareaAutosize
ref={textAreaRef}
disabled={loading}
style={flatten([
a.flex_1,
a.px_sm,
a.border_0,
t.atoms.text,
{
paddingTop: 10,
backgroundColor: 'transparent',
resize: 'none',
},
])}
maxRows={12}
placeholder={
loading
? l({message: 'Loading chat…', context: 'placeholder'})
: l({message: 'Message', context: 'action'})
}
defaultValue=""
value={message}
dirName="ltr"
autoFocus={true}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onCompositionStart={() => {
isComposing.current = true
}}
onCompositionEnd={() => {
isComposing.current = false
}}
onHeightChange={height => setTextAreaHeight(height)}
onChange={onChange}
// On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
// in these cases.
onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown}
/>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Send message`}
accessibilityHint=""
disabled={loading}
style={[
a.rounded_full,
a.align_center,
a.justify_center,
{
height: 30,
width: 30,
marginTop: 5,
backgroundColor: t.palette.primary_500,
},
]}
onPress={onSubmit}>
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
</Pressable>
</View>
</View>
)
}
@@ -1,5 +1,5 @@
import {LayoutAnimation, View} from 'react-native'
import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api'
import {type ChatBskyConvoDefs} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {HITSLOP_20} from '#/lib/constants'
@@ -8,6 +8,7 @@ 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 {useReplyPreviewText} from '#/components/dms/replyPreview'
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Text} from '#/components/Typography'
@@ -16,15 +17,27 @@ import {Text} from '#/components/Typography'
* 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()
const {replyTo} = useMessageReplies()
if (!replyTo) {
return null
}
return <MessageInputReplyInner replyTo={replyTo} />
}
function MessageInputReplyInner({
replyTo,
}: {
replyTo: ChatBskyConvoDefs.MessageView
}) {
const t = useTheme()
const {t: l} = useLingui()
const convo = useConvoActive()
const {clearReply} = useMessageReplies()
const getReplyPreviewText = useReplyPreviewText()
const {text, subtle} = getReplyPreviewText(replyTo)
const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
clearReply()
@@ -35,19 +48,6 @@ export function MessageInputReply() {
? 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 (
<View
style={[
@@ -82,7 +82,7 @@ export function MessageInputReply() {
<Button
label={l`Cancel reply`}
onPress={onRemove}
style={[a.px_2xs]}
style={[a.px_2xs, {transform: [{translateX: 2}]}]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
@@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
import {useAgent, useSession} from '#/state/session'
import {List, type ListMethods} from '#/view/com/util/List'
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
import {MessageInput} from '#/screens/Messages/components/MessageInput'
import {MessageListError} from '#/screens/Messages/components/MessageListError'
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
import {DateDivider} from '#/components/dms/DateDivider'
@@ -704,9 +703,6 @@ export function MessagesList({
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}
useNewComposer={ax.features.enabled(
ax.features.DmsNewMessageComposerEnable,
)}
/>
)}
</ConversationFooter>
@@ -735,7 +731,6 @@ function Composer({
messageEmbed,
setEmbed,
loading,
useNewComposer,
}: {
textInputId: string
onSendMessage: (
@@ -746,7 +741,6 @@ function Composer({
messageEmbed: MessageEmbedState | undefined
setEmbed: (embedUrl: string | undefined) => void
loading?: boolean
useNewComposer: boolean
}) {
const handleSendMessage = useNonReactiveCallback(
(
@@ -758,31 +752,16 @@ function Composer({
},
)
const previews = (
<>
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</>
)
return useNewComposer ? (
return (
<MessageComposer
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
<MessageInputReply />
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
</MessageComposer>
) : (
<MessageInput
textInputId={textInputId}
onSendMessage={handleSendMessage}
messageEmbed={messageEmbed}
setEmbed={setEmbed}
loading={loading}>
{previews}
</MessageInput>
)
}
@@ -292,7 +292,10 @@ export function HeaderLabelerButtons({
testID="profileHeaderEditProfileButton"
size="small"
color="secondary"
onPress={editProfileControl.open}
onPress={() => {
playHaptic('Light')
editProfileControl.open()
}}
label={_(msg`Edit profile`)}
style={a.rounded_full}>
<ButtonText>
@@ -0,0 +1,41 @@
import {Trans} from '@lingui/react/macro'
import type * as Dialog from '#/components/Dialog'
import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSettingsDialog'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
export function ChatNotificationDialogs({
chatControl,
chatRequestControl,
}: {
chatControl: Dialog.DialogControlProps
chatRequestControl: Dialog.DialogControlProps
}) {
return (
<>
<NotificationSettingsDialog
control={chatControl}
name="chat"
icon={MessageIcon}
titleText={<Trans>New messages</Trans>}
subtitleText={
<Trans>Get notifications when people send you messages.</Trans>
}
allowDisableInApp={false}
/>
<NotificationSettingsDialog
control={chatRequestControl}
name="chatRequest"
icon={EnvelopeIcon}
titleText={<Trans>New message requests</Trans>}
subtitleText={
<Trans>
Get notifications when people send you message requests.
</Trans>
}
allowDisableInApp={false}
/>
</>
)
}
@@ -1,9 +1,12 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
import {
type NotificationSettingsPreference,
type NotificationSettingsPreferenceName,
useNotificationSettingsUpdateMutation,
} from '#/state/queries/notifications/settings'
import {atoms as a, platform, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
@@ -17,16 +20,13 @@ export function PreferenceControls({
preference,
allowDisableInApp = true,
}: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
name: NotificationSettingsPreferenceName
/**
* Keep other prefs in sync with `name`. For use in the "everything else" category
* which groups starterpack joins + verified + unverified notifications into a single toggle.
*/
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
preference?:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
syncOthers?: NotificationSettingsPreferenceName[]
preference?: NotificationSettingsPreference
allowDisableInApp?: boolean
}) {
if (!preference)
@@ -52,12 +52,9 @@ export function Inner({
preference,
allowDisableInApp,
}: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
preference:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
name: NotificationSettingsPreferenceName
syncOthers?: NotificationSettingsPreferenceName[]
preference: NotificationSettingsPreference
allowDisableInApp: boolean
}) {
const t = useTheme()
@@ -95,7 +92,7 @@ export function Inner({
}
const onChangeFilter = ([change]: string[]) => {
if (change !== 'all' && change !== 'follows' && change !== 'accepted')
if (change !== 'all' && change !== 'follows')
throw new Error('Invalid filter')
const newPreference = {
@@ -135,7 +132,7 @@ export function Inner({
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
{allowDisableInApp && (
{allowDisableInApp && 'list' in preference && (
<Toggle.Item
label={l`Receive in-app notifications`}
name="list"
@@ -176,31 +173,17 @@ export function Inner({
/>
)}
</Toggle.Item>
{name === 'chat' ? (
<Toggle.Item
highlightRow
label={l`Accepted conversations`}
name="accepted">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`Accepted conversations`}
selected={selected}
/>
)}
</Toggle.Item>
) : (
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
)}
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
</View>
</Toggle.Group>
</>
@@ -0,0 +1,35 @@
import {useLingui} from '@lingui/react/macro'
import {type NotificationSettingsPreference} from '#/state/queries/notifications/settings'
export function SettingPreview({
preference,
}: {
preference?: NotificationSettingsPreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
}
if ('include' in preference) {
const list = 'list' in preference && preference.list
if (preference.include === 'all') {
if (list && preference.push) return l`In-app, push, everyone`
if (list) return l`In-app, everyone`
if (preference.push) return l`Push, everyone`
} else if (preference.include === 'follows') {
if (list && preference.push) return l`In-app, push, people you follow`
if (list) return l`In-app, people you follow`
if (preference.push) return l`Push, people you follow`
}
} else {
if (preference.list && preference.push) return l`In-app, push`
if (preference.list) return l`In-app`
if (preference.push) return l`Push`
}
return l`Off`
}
@@ -1,7 +1,6 @@
import {useEffect} from 'react'
import {Linking, View} from 'react-native'
import * as Notification from 'expo-notifications'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQuery, useQueryClient} from '@tanstack/react-query'
@@ -10,7 +9,10 @@ import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Dialog from '#/components/Dialog'
@@ -18,11 +20,13 @@ import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSetti
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
import {
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
} from '#/components/icons/Heart2'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
import {
@@ -33,7 +37,9 @@ import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/S
import * as Layout from '#/components/Layout'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import * as SettingsList from '../components/SettingsList'
import {ChatNotificationDialogs} from './components/ChatNotificationDialogs'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {SettingPreview} from './components/SettingPreview'
const RQKEY = ['notification-permissions']
@@ -42,6 +48,8 @@ export function NotificationSettingsScreen({}: Props) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {data: settings, isError} = useNotificationSettingsQuery()
const {data: chatSettings, isError: chatError} =
useChatNotificationSettingsQuery()
const likeDialogControl = Dialog.useDialogControl()
const followDialogControl = Dialog.useDialogControl()
@@ -52,6 +60,8 @@ export function NotificationSettingsScreen({}: Props) {
const activityDialogControl = Dialog.useDialogControl()
const likeRepostDialogControl = Dialog.useDialogControl()
const repostRepostDialogControl = Dialog.useDialogControl()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const miscDialogControl = Dialog.useDialogControl()
const {data: permissions, refetch} = useQuery({
@@ -234,6 +244,40 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new messages`}
onPress={chatDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={MessageIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New messages</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chat} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new message requests`}
onPress={chatRequestDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={EnvelopeIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New message requests</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chatRequest} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for everything else`}
onPress={miscDialogControl.open}
@@ -333,6 +377,10 @@ export function NotificationSettingsScreen({}: Props) {
<Trans>Get notifications when people repost your reposts.</Trans>
}
/>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<NotificationSettingsDialog
control={miscDialogControl}
name="starterpackJoined"
@@ -350,46 +398,3 @@ export function NotificationSettingsScreen({}: Props) {
</Layout.Screen>
)
}
function SettingPreview({
preference,
}: {
preference?:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
} else {
if ('include' in preference) {
if (preference.include === 'all') {
if (preference.list && preference.push) {
return l`In-app, push, everyone`
} else if (preference.list) {
return l`In-app, everyone`
} else if (preference.push) {
return l`Push, everyone`
}
} else if (preference.include === 'follows') {
if (preference.list && preference.push) {
return l`In-app, push, people you follow`
} else if (preference.list) {
return l`In-app, people you follow`
} else if (preference.push) {
return l`Push, people you follow`
}
}
} else {
if (preference.list && preference.push) {
return l`In-app, push`
} else if (preference.list) {
return l`In-app`
} else if (preference.push) {
return l`Push`
}
}
}
return l`Off`
}
+22 -14
View File
@@ -3,6 +3,7 @@ import {AppState, type AppStateStatus, View} from 'react-native'
import ReactNativeDeviceAttest from 'react-native-device-attest'
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
import {AppBskyGraphStarterpack} from '@atproto/api'
import {tokens} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro'
import {FEEDBACK_FORM_URL} from '#/lib/constants'
@@ -222,20 +223,27 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
a.align_center,
]}>
<AppLanguageDropdown />
<Text
style={[
a.flex_1,
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
<View
style={
gtMobile
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
: []
}>
<Text
style={[
t.atoms.text_contrast_medium,
!gtMobile && a.text_md,
{paddingInline: tokens.space.sm},
]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={l`Contact support`}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
</InlineLinkText>
</Text>
</View>
</View>
</View>
</ScreenTransition>
+6
View File
@@ -132,6 +132,12 @@ export function ConvoProvider({
useEffect(() => {
const [root, id] = getConvoKey(convoId)
return queryClient.getQueryCache().subscribe(event => {
// Only react to data updates. Other event types (e.g. `added`) can be
// emitted synchronously while another component reads this same query
// during its render (React Query builds the query in `getOptimisticResult`),
// and committing to the convo store then would set state on this provider
// mid-render of that component.
if (event.type !== 'updated') return
const queryKey = event.query.queryKey as string[]
if (queryKey[0] === root && queryKey[1] === id) {
const data = event.query.state.data as
@@ -2,6 +2,7 @@ import {
type ChatBskyActorDefs,
type ChatBskyConvoDefs,
type ChatBskyConvoGetConvo,
type ChatBskyConvoGetUnreadCounts,
} from '@atproto/api'
import {
type QueryClient,
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {STALE} from '#/state/queries'
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
import {useAgent} from '#/state/session'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
UNREAD_ACCEPTED_CAP,
UNREAD_REQUEST_CAP,
} from './get-unread-counts'
import {
type ConvoListQueryData,
getConvoFromQueryData,
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
},
onMutate({convoId}) {
if (!convoId) throw new Error('No convoId provided')
// snapshot the list caches before the optimistic update so onError can
// restore the convo rows alongside the badge count
const prevListQueries = queryClient.getQueriesData<ConvoListQueryData>({
queryKey: [LIST_CONVOS_KEY],
})
// find the convo so we know which badge counter (if any) to decrement.
// keep scanning past a stale unreadCount === 0 cache so another cache
// holding the true unread state still drives the decrement
let unreadStatus: ChatBskyConvoDefs.ConvoView['status'] | undefined
for (const [, data] of prevListQueries) {
if (!data) continue
const convo = getConvoFromQueryData(convoId, data)
if (convo?.unreadCount) {
unreadStatus = convo.status
break
}
}
optimisticUpdate(convoId)
// the badge count query is a separate server query that the list caches
// don't feed, so decrement it here to keep the badge in sync
const prevUnreadCountsQueries =
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
if (unreadStatus) {
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
old => {
if (!old) return old
return {
...old,
...(unreadStatus === 'request'
? {
unreadRequestConvos:
old.unreadRequestConvos >= UNREAD_REQUEST_CAP
? old.unreadRequestConvos
: Math.max(0, old.unreadRequestConvos - 1),
}
: {
unreadAcceptedConvos:
old.unreadAcceptedConvos >= UNREAD_ACCEPTED_CAP
? old.unreadAcceptedConvos
: Math.max(0, old.unreadAcceptedConvos - 1),
}),
}
},
)
}
return {prevListQueries, prevUnreadCountsQueries}
},
onError(_, __, context) {
if (context?.prevListQueries) {
for (const [queryKey, prevData] of context.prevListQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
if (context?.prevUnreadCountsQueries) {
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
},
onSuccess(_, {convoId}) {
if (!convoId) return
// the optimistic badge arithmetic can drift from the server (e.g. a convo
// whose status differs between caches, or a sentinel-capped count). invalidate
// so the 15s-stale count query self-corrects on next access rather than
// waiting for a log event
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
queryClient.setQueriesData(
{queryKey: [LIST_CONVOS_KEY]},
(old?: ConvoListQueryData) => {
@@ -10,6 +10,13 @@ export const RQKEY = (includeGroupChats: boolean) =>
[RQKEY_ROOT, includeGroupChats] as const
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
// the server sentinel-caps the badge counts: unreadAcceptedConvos and
// unreadRequestConvos max out at 100 (meaning "more than 99"). at the cap the
// value is no longer an exact count, so consumers must not treat it as one -
// both the optimistic decrement and the badge display ceiling key off these.
export const UNREAD_ACCEPTED_CAP = 100
export const UNREAD_REQUEST_CAP = 100
export function useUnreadCountsQuery() {
const agent = useAgent()
const {hasSession} = useSession()
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
UNREAD_ACCEPTED_CAP,
useUnreadCountsQuery,
} from './get-unread-counts'
import {
@@ -855,10 +856,16 @@ export function useUnreadMessageCount(): {
const request = data?.unreadRequestConvos ?? 0
if (accepted > 0) {
const total = accepted + Math.min(request, 1)
return {
count: total,
numUnread: total > 10 ? '10+' : String(total),
count: accepted,
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
// cap - 1"). show the "+" overflow label only when accepted is actually
// capped, otherwise clamp the number to cap - 1 so we never surface the
// sentinel value (100) itself
numUnread:
accepted >= UNREAD_ACCEPTED_CAP
? `${UNREAD_ACCEPTED_CAP - 1}+`
: String(Math.min(accepted, UNREAD_ACCEPTED_CAP - 1)),
// only needed when numUnread is undefined
hasNew: false,
}
+34 -1
View File
@@ -1,8 +1,10 @@
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
import {
type ConvoRequestListQueryData,
markAllRead as markAllRequestsRead,
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
markAllRequestsRead,
)
}
// zero out the badge count query that actually drives the unread badge,
// since it's a separate server query that the list caches don't feed
const prevUnreadCountsQueries =
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
})
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
old => {
if (!old) return old
return {
...old,
...(status === 'accepted'
? {unreadAcceptedConvos: 0}
: {unreadRequestConvos: 0}),
}
},
)
onMutate?.()
return {prevConvoListQueries, prevRequestsQueries}
return {
prevConvoListQueries,
prevRequestsQueries,
prevUnreadCountsQueries,
}
},
onSuccess: () => {
// the optimistic badge zeroing can drift from the server, so invalidate
// the count query to let it self-correct on next access rather than
// waiting for a log event
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
void queryClient.invalidateQueries({
queryKey: CONVO_LIST_PARTIAL_KEY(status),
})
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
queryClient.setQueryData(queryKey, prevData)
}
}
if (context?.prevUnreadCountsQueries) {
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
queryClient.setQueryData(queryKey, prevData)
}
}
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
if (status === 'request') {
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
+145 -21
View File
@@ -1,4 +1,7 @@
import {type AppBskyNotificationDefs} from '@atproto/api'
import {
type AppBskyNotificationDefs,
type ChatBskyNotificationDefs,
} from '@atproto/api'
import {t} from '@lingui/core/macro'
import {
type QueryClient,
@@ -7,12 +10,59 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import * as Toast from '#/components/Toast'
const RQKEY_ROOT = 'notification-settings'
const RQKEY = [RQKEY_ROOT]
const RQKEY_APP = [RQKEY_ROOT, 'app']
const RQKEY_CHAT = [RQKEY_ROOT, 'chat']
// App notification preferences live on the appview. Chat preferences live on a
// separate chat service proxy that can be up or down independently, so they are
// fetched and cached separately. This combined type names every preference for
// the generic settings dialog, but it is never the shape of a query response.
export type NotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
> &
Partial<Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'>>
export type AppNotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
>
export type ChatNotificationSettingsPreferences = Pick<
ChatBskyNotificationDefs.Preferences,
'chat' | 'chatRequest'
>
export type NotificationSettingsPreferenceName = Exclude<
keyof NotificationSettingsPreferences,
'$type'
>
export type NotificationSettingsPreference =
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| ChatBskyNotificationDefs.ChatPreference
export function isChatPreferenceName(
name: NotificationSettingsPreferenceName,
): name is 'chat' | 'chatRequest' {
return name === 'chat' || name === 'chatRequest'
}
type NotificationSettingsUpdate = Partial<NotificationSettingsPreferences>
type AppNotificationSettingsUpdate = Partial<
Omit<AppBskyNotificationDefs.Preferences, '$type' | 'chat'>
>
type ChatNotificationSettingsUpdate =
Partial<ChatNotificationSettingsPreferences>
export function useNotificationSettingsQuery({
enabled,
@@ -20,10 +70,27 @@ export function useNotificationSettingsQuery({
const agent = useAgent()
return useQuery({
queryKey: RQKEY,
queryFn: async () => {
const response = await agent.app.bsky.notification.getPreferences()
return response.data.preferences
queryKey: RQKEY_APP,
queryFn: async (): Promise<AppNotificationSettingsPreferences> => {
const res = await agent.app.bsky.notification.getPreferences()
return appPreferencesWithoutChat(res.data.preferences)
},
enabled,
})
}
export function useChatNotificationSettingsQuery({
enabled,
}: {enabled?: boolean} = {}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY_CHAT,
queryFn: async (): Promise<ChatNotificationSettingsPreferences> => {
const res = await agent.chat.bsky.notification.getPreferences(undefined, {
headers: DM_SERVICE_HEADERS,
})
return chatPreferencesForSettings(res.data.preferences)
},
enabled,
})
@@ -33,19 +100,27 @@ export function useNotificationSettingsUpdateMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
update: Partial<AppBskyNotificationDefs.Preferences>,
) => {
const response =
await agent.app.bsky.notification.putPreferencesV2(update)
return response.data.preferences
mutationFn: async (update: NotificationSettingsUpdate) => {
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
await Promise.all([
hasUpdates(appUpdate)
? agent.app.bsky.notification.putPreferencesV2(appUpdate)
: undefined,
hasUpdates(chatUpdate)
? agent.chat.bsky.notification.putPreferences(chatUpdate, {
headers: DM_SERVICE_HEADERS,
encoding: 'application/json',
})
: undefined,
])
},
onMutate: update => {
optimisticUpdateNotificationSettings(queryClient, update)
},
onError: e => {
logger.error('Could not update notification settings', {message: e})
void queryClient.invalidateQueries({queryKey: RQKEY})
void queryClient.invalidateQueries({queryKey: RQKEY_APP})
void queryClient.invalidateQueries({queryKey: RQKEY_CHAT})
Toast.show(t`Could not update notification settings`, {
type: 'error',
})
@@ -55,13 +130,62 @@ export function useNotificationSettingsUpdateMutation() {
function optimisticUpdateNotificationSettings(
queryClient: QueryClient,
update: Partial<AppBskyNotificationDefs.Preferences>,
update: NotificationSettingsUpdate,
) {
queryClient.setQueryData(
RQKEY,
(old?: AppBskyNotificationDefs.Preferences) => {
if (!old) return old
return {...old, ...update}
},
)
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
if (hasUpdates(appUpdate)) {
queryClient.setQueryData(
RQKEY_APP,
(old?: AppNotificationSettingsPreferences) => {
if (!old) return old
return {...old, ...appUpdate}
},
)
}
if (hasUpdates(chatUpdate)) {
queryClient.setQueryData(
RQKEY_CHAT,
(old?: ChatNotificationSettingsPreferences) => {
if (!old) return old
return {...old, ...chatUpdate}
},
)
}
}
function appPreferencesWithoutChat(
preferences: AppBskyNotificationDefs.Preferences,
): Omit<AppBskyNotificationDefs.Preferences, 'chat'> {
const {chat: _ignoredChat, ...appPreferences} = preferences
return appPreferences
}
function chatPreferencesForSettings(
preferences: ChatBskyNotificationDefs.Preferences,
): Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'> {
return {
chat: preferences.chat,
chatRequest: preferences.chatRequest,
}
}
function splitNotificationSettingsUpdate(update: NotificationSettingsUpdate): {
appUpdate: AppNotificationSettingsUpdate
chatUpdate: ChatNotificationSettingsUpdate
} {
const {chat, chatRequest, $type: _type, ...appUpdate} = update
return {
appUpdate: appUpdate,
chatUpdate: {
...(chat !== undefined ? {chat} : {}),
...(chatRequest !== undefined ? {chatRequest} : {}),
},
}
}
function hasUpdates(update: object) {
return Object.keys(update).length > 0
}
+29 -3
View File
@@ -73,6 +73,7 @@ import {
} from '#/lib/constants'
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
@@ -179,7 +180,7 @@ import {
type VideoState,
} from './state/video'
import {type TextInputRef} from './text-input/TextInput.types'
import {getVideoMetadata} from './videos/pickVideo'
import {getVideoMetadata} from './videos/metadata'
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
type CancelRef = {
@@ -387,6 +388,12 @@ export const ComposePost = ({
const selectVideo = useCallback(
(postId: string, asset: ImagePickerAsset) => {
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId: postId,
@@ -394,6 +401,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
void processVideo(
@@ -412,9 +420,10 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
telemetry,
)
},
[i18n, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch, ax.metric],
)
const onInitVideo = useNonReactiveCallback(() => {
@@ -494,6 +503,12 @@ export const ComposePost = ({
// Start video processing using existing flow
const abortController = new AbortController()
const telemetry = createVideoTelemetry({
asset,
signal: abortController.signal,
metric: ax.metric,
})
telemetry.picked()
composerDispatch({
type: 'update_post',
postId,
@@ -501,6 +516,7 @@ export const ComposePost = ({
type: 'embed_add_video',
asset,
abortController,
telemetry,
},
})
@@ -559,6 +575,7 @@ export const ComposePost = ({
currentDid,
abortController.signal,
i18n,
telemetry,
)
} catch (e) {
logger.error('Failed to restore video from draft', {
@@ -567,7 +584,7 @@ export const ComposePost = ({
})
}
},
[i18n, agent, currentDid, composerDispatch],
[i18n, agent, currentDid, composerDispatch, ax.metric],
)
const handleSelectDraft = useCallback(
@@ -979,6 +996,15 @@ export const ComposePost = ({
})
).uris[0]
// Fire published event for every video that made it into the post.
// The status guard upstream ensures each video.telemetry is present and
// processing has completed by this point.
for (const post of filteredThread.posts) {
if (post.embed.media?.type === 'video') {
post.embed.media.video.telemetry?.published()
}
}
/*
* Wait for app view to have received the post(s). If this fails, it's
* ok, because the post _was_ actually published above.
+10 -4
View File
@@ -24,6 +24,7 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
import * as toast from '#/components/Toast'
import {IS_NATIVE, IS_WEB} from '#/env'
import {isAnimatedGif} from './videos/isAnimatedGif'
import {hasWebCodecs} from './videos/metadata'
export type SelectMediaButtonProps = {
disabled?: boolean
@@ -291,9 +292,15 @@ async function processImagePickerAssets(
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files on web. On native, we compress these anyway,
* so we only check on web.
* so we only check on web. On web, we can reject early if the browser
* doesn't support WebCodecs.
*/
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
if (
IS_WEB &&
!hasWebCodecs() &&
asset.fileSize &&
asset.fileSize > VIDEO_MAX_SIZE
) {
errors.add(SelectedAssetError.FileTooBig)
continue
}
@@ -309,8 +316,7 @@ async function processImagePickerAssets(
if (type === 'gif') {
/*
* Filesize appears to be stable across all platforms, so we can use it
* to filter out large files on web. On native, we compress GIFs as
* videos anyway, so we only check on web.
* to filter out large files. We can't compress GIFs on either platform.
*/
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
errors.add(SelectedAssetError.FileTooBig)
+7 -1
View File
@@ -8,6 +8,7 @@ import {
} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type SelfLabel} from '#/lib/moderation'
import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
@@ -88,6 +89,7 @@ export type PostAction =
type: 'embed_add_video'
asset: ImagePickerAsset
abortController: AbortController
telemetry: VideoTelemetry
}
| {type: 'embed_remove_video'}
| {type: 'embed_update_video'; videoAction: VideoAction}
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
if (!prevMedia) {
nextMedia = {
type: 'video',
video: createVideoState(action.asset, action.abortController),
video: createVideoState(
action.asset,
action.abortController,
action.telemetry,
),
}
}
return {
+31
View File
@@ -11,6 +11,7 @@ import {
UploadLimitError,
VideoTooLargeError,
} from '#/lib/media/video/errors'
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
import {type CompressedVideo} from '#/lib/media/video/types'
import {uploadVideo} from '#/lib/media/video/upload'
import {createVideoAgent} from '#/lib/media/video/util'
@@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
video: undefined,
jobId: undefined,
pendingPublish: undefined,
telemetry: undefined,
altText: '',
captions: [],
})
@@ -79,6 +81,7 @@ type ErrorState = {
jobId: string | null
error: string
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -91,6 +94,7 @@ type CompressingState = {
video?: undefined
jobId?: undefined
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -103,6 +107,7 @@ type UploadingState = {
video: CompressedVideo
jobId?: undefined
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -116,6 +121,7 @@ type ProcessingState = {
jobId: string
jobStatus: AppBskyVideoDefs.JobStatus | null
pendingPublish?: undefined
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -128,6 +134,7 @@ type DoneState = {
video: CompressedVideo
jobId?: undefined
pendingPublish: {blobRef: BlobRef}
telemetry: VideoTelemetry
altText: string
captions: CaptionsTrack[]
}
@@ -142,12 +149,14 @@ export type VideoState =
export function createVideoState(
asset: ImagePickerAsset,
abortController: AbortController,
telemetry: VideoTelemetry,
): CompressingState {
return {
status: 'compressing',
progress: 0,
abortController,
asset,
telemetry,
altText: '',
captions: [],
}
@@ -170,6 +179,7 @@ export function videoReducer(
asset: state.asset ?? null,
video: state.video ?? null,
jobId: state.jobId ?? null,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -198,6 +208,7 @@ export function videoReducer(
abortController: state.abortController,
asset: state.asset,
video: action.video,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -213,6 +224,7 @@ export function videoReducer(
video: state.video,
jobId: action.jobId,
jobStatus: null,
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -239,6 +251,7 @@ export function videoReducer(
pendingPublish: {
blobRef: action.blobRef,
},
telemetry: state.telemetry,
altText: state.altText,
captions: state.captions,
}
@@ -265,9 +278,11 @@ export async function processVideo(
did: string,
signal: AbortSignal,
i18n: I18n,
telemetry: VideoTelemetry,
) {
let video: CompressedVideo | undefined
try {
telemetry.compressStarted()
video = await compressVideo(asset, {
onProgress: num => {
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
@@ -277,6 +292,7 @@ export async function processVideo(
} catch (e) {
const message = getCompressErrorMessage(e, i18n)
if (message !== null) {
telemetry.compressFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -285,6 +301,15 @@ export async function processVideo(
}
return
}
if (video.passthroughReason) {
telemetry.compressSkipped({
size: video.size,
mimeType: video.mimeType,
skipReason: video.passthroughReason,
})
} else {
telemetry.compressCompleted({size: video.size, mimeType: video.mimeType})
}
dispatch({
type: 'compressing_to_uploading',
video,
@@ -293,6 +318,7 @@ export async function processVideo(
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
try {
telemetry.uploadStarted(video.size)
uploadResponse = await uploadVideo({
video,
agent,
@@ -306,6 +332,7 @@ export async function processVideo(
} catch (e) {
const message = getUploadErrorMessage(e, i18n)
if (message !== null) {
telemetry.uploadFailed(e)
dispatch({
type: 'to_error',
error: message,
@@ -316,6 +343,8 @@ export async function processVideo(
}
const jobId = uploadResponse.jobId
telemetry.uploadCompleted(jobId)
telemetry.processingStarted(jobId)
dispatch({
type: 'uploading_to_processing',
jobId,
@@ -354,6 +383,7 @@ export async function processVideo(
}
logger.error('Error processing video', {safeMessage: e})
telemetry.processingFailed(e)
dispatch({
type: 'to_error',
error: i18n._(msg`Video failed to process`),
@@ -363,6 +393,7 @@ export async function processVideo(
}
if (blob) {
telemetry.processingCompleted()
dispatch({
type: 'to_done',
blobRef: blob,
@@ -1,7 +1,26 @@
import {atoms as a, flatten} from '#/alf'
export function clearThumbnailCache() {
// no-op
// no-op on web
}
export function VideoTranscodeBackdrop() {
return null
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
return (
<video
src={uri}
style={flatten([
a.absolute,
a.inset_0,
a.h_full,
a.w_full,
{
objectFit: 'cover',
filter: 'blur(15px)',
transform: 'scale(1.1)', // hide blur edges
},
])}
muted
playsInline
/>
)
}
@@ -5,7 +5,6 @@ import {type ImagePickerAsset} from 'expo-image-picker'
import {atoms as a, useTheme} from '#/alf'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {IS_WEB} from '#/env'
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
}) {
const t = useTheme()
if (IS_WEB) return null
let aspectRatio: number | undefined
if (asset.width && asset.height) {
const raw = asset.width / asset.height
+25
View File
@@ -0,0 +1,25 @@
import {getVideoMetaData} from 'react-native-compressor'
import {type ImagePickerAsset} from 'expo-image-picker'
import {extToMime} from '#/lib/media/video/util'
export async function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file !== 'string')
throw new Error(
'getVideoMetadata was passed a File, when on native it should be a uri',
)
const metadata = await getVideoMetaData(file)
return {
uri: file,
mimeType: extToMime(metadata.extension),
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
}
}
export function hasWebCodecs(): boolean {
return false
}
@@ -0,0 +1,126 @@
import {type ImagePickerAsset} from 'expo-image-picker'
import {ALL_FORMATS, BlobSource, Input} from 'mediabunny'
import {logger} from '#/logger'
export function hasWebCodecs(): boolean {
return (
typeof VideoEncoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
)
}
export async function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file === 'string')
throw new Error(
'getVideoMetadata was passed a uri, when on web it should be a File',
)
const blobUrl = URL.createObjectURL(file)
logger.debug('metadata: starting', {
fileName: file.name,
fileSize: file.size,
mimeType: file.type,
hasWebCodecs: hasWebCodecs(),
})
if (hasWebCodecs()) {
try {
const result = await getMetadataWithWebCodecs(file, blobUrl)
logger.debug('metadata: WebCodecs succeeded', {
width: result.width,
height: result.height,
duration: result.duration,
})
return result
} catch (e) {
logger.warn('metadata: WebCodecs failed, using fallback', {
safeMessage: e,
})
}
}
// Fallback to old-fashioned browser APIs
const result = await getMetadataWithBrowserAPIs(file, blobUrl)
logger.debug('metadata: browser API succeeded', {
width: result.width,
height: result.height,
duration: result.duration,
})
return result
}
async function getMetadataWithWebCodecs(
file: File,
blobUrl: string,
): Promise<ImagePickerAsset> {
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
})
try {
const [videoTrack, duration] = await Promise.all([
input.getPrimaryVideoTrack(),
input.computeDuration(),
])
if (!videoTrack) {
throw new Error('No video track found')
}
return {
uri: blobUrl,
mimeType: file.type,
width: videoTrack.displayWidth,
height: videoTrack.displayHeight,
duration: duration * 1000, // convert seconds to ms
}
} finally {
input.dispose()
}
}
async function getMetadataWithBrowserAPIs(
file: File,
blobUrl: string,
): Promise<ImagePickerAsset> {
return new Promise((resolve, reject) => {
if (file.type === 'image/gif') {
const img = new Image()
img.onload = () => {
resolve({
uri: blobUrl,
mimeType: 'image/gif',
width: img.width,
height: img.height,
duration: null,
})
}
img.onerror = () => {
URL.revokeObjectURL(blobUrl)
reject(new Error('Failed to load GIF'))
}
img.src = blobUrl
} else {
const video = document.createElement('video')
video.preload = 'metadata'
video.src = blobUrl
video.onloadedmetadata = () => {
resolve({
uri: blobUrl,
mimeType: file.type,
width: video.videoWidth,
height: video.videoHeight,
duration: video.duration * 1000,
})
}
video.onerror = () => {
URL.revokeObjectURL(blobUrl)
reject(new Error('Failed to load video metadata'))
}
}
})
}
-43
View File
@@ -1,43 +0,0 @@
import {getVideoMetaData} from 'react-native-compressor'
import {
type ImagePickerAsset,
launchImageLibraryAsync,
UIImagePickerPreferredAssetRepresentationMode,
} from 'expo-image-picker'
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
import {extToMime} from '#/lib/media/video/util'
export async function pickVideo() {
return await launchImageLibraryAsync({
exif: false,
mediaTypes: ['videos'],
quality: 1,
legacy: true,
preferredAssetRepresentationMode:
UIImagePickerPreferredAssetRepresentationMode.Current,
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
})
}
/**
* Gets video metadata from a file or uri, depending on the platform
*
* @param file File on web, uri on native
*/
export async function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file !== 'string')
throw new Error(
'getVideoMetadata was passed a File, when on native it should be a uri',
)
const metadata = await getVideoMetaData(file)
return {
uri: file,
mimeType: extToMime(metadata.extension),
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
}
}
@@ -1,100 +0,0 @@
import {type ImagePickerAsset, type ImagePickerResult} from 'expo-image-picker'
import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
// mostly copied from expo-image-picker and adapted to support gifs
// also adds support for reading video metadata
export async function pickVideo(): Promise<ImagePickerResult> {
const input = document.createElement('input')
input.style.display = 'none'
input.setAttribute('type', 'file')
// TODO: do we need video/* here? -sfn
input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
input.setAttribute('id', String(Math.random()))
document.body.appendChild(input)
return new Promise(resolve => {
input.addEventListener('change', async () => {
if (input.files) {
const file = input.files[0]
resolve({
canceled: false,
assets: [await getVideoMetadata(file)],
})
} else {
resolve({canceled: true, assets: null})
}
document.body.removeChild(input)
})
const event = new MouseEvent('click')
input.dispatchEvent(event)
})
}
// TODO: we're converting to a dataUrl here, and then converting back to an
// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
// lets us use the ImagePickerAsset type, which the rest of the code expects.
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
// instead of a string -sfn
export function getVideoMetadata(
file: File | string,
): Promise<ImagePickerAsset> {
if (typeof file === 'string')
throw new Error(
'getVideoMetadata was passed a uri, when on web it should be a File',
)
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const uri = reader.result as string
if (file.type === 'image/gif') {
const img = new Image()
img.onload = () => {
resolve({
uri,
mimeType: 'image/gif',
width: img.width,
height: img.height,
// todo: calculate gif duration. seems possible if you read the bytes
// https://codepen.io/Ryman/pen/nZpYwY
// for now let's just let the server reject it, since that seems uncommon -sfn
duration: null,
})
}
img.onerror = (_ev, _source, _lineno, _colno, error) => {
console.log('Failed to grab GIF metadata', error)
reject(new Error('Failed to grab GIF metadata'))
}
img.src = uri
} else {
const video = document.createElement('video')
const blobUrl = URL.createObjectURL(file)
video.preload = 'metadata'
video.src = blobUrl
video.onloadedmetadata = () => {
URL.revokeObjectURL(blobUrl)
resolve({
uri,
mimeType: file.type,
width: video.videoWidth,
height: video.videoHeight,
// convert seconds to ms
duration: video.duration * 1000,
})
}
video.onerror = (_ev, _source, _lineno, _colno, error) => {
URL.revokeObjectURL(blobUrl)
console.log('Failed to grab video metadata', error)
reject(new Error('Failed to grab video metadata'))
}
}
}
reader.readAsDataURL(file)
})
}
+51 -3
View File
@@ -6,7 +6,7 @@ import {type CountryCode} from '#/lib/international-telephone-codes'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
import {DateField, LabelText} from '#/components/forms/DateField'
import {DateField, LabelText, utils} from '#/components/forms/DateField'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
@@ -28,6 +28,8 @@ export function Forms() {
const [value, setValue] = useState('')
const [date, setDate] = useState('2001-01-01')
const [emptyDate, setEmptyDate] = useState('')
const [confirmDate, setConfirmDate] = useState('2001-01-01')
const [countryCode, setCountryCode] = useState<CountryCode>('US')
const [phoneNumber, setPhoneNumber] = useState('')
const [lang, setLang] = useState('en')
@@ -175,17 +177,63 @@ export function Forms() {
<H3>DateField</H3>
<View style={[a.w_full]}>
<LabelText>Date</LabelText>
<LabelText>1. Date</LabelText>
<DateField
testID="date"
value={date}
onChangeDate={date => {
console.log(date)
console.log('[1] changed', date)
setDate(date)
}}
label="Input"
/>
</View>
<View style={[a.w_full]}>
<LabelText>2. Empty value with placeholder</LabelText>
<DateField
testID="dateEmpty"
value={emptyDate}
onChangeDate={date => {
console.log('[2] changed', date)
setEmptyDate(date)
}}
placeholder="Select a date"
label="Birthday"
/>
</View>
<View style={[a.w_full]}>
<LabelText>3. Empty value with placeholder and maximumDate</LabelText>
<DateField
testID="dateEmptyMax"
value={emptyDate}
onChangeDate={date => {
console.log('[3] changed', date)
setEmptyDate(date)
}}
placeholder="Select a date"
maximumDate={utils.toSimpleDateString(new Date())}
label="Date of birth"
/>
</View>
<View style={[a.w_full]}>
<LabelText>
4. onConfirm vs onChangeDate (check the console)
</LabelText>
<DateField
testID="dateConfirm"
value={confirmDate}
onChangeDate={date => {
console.log('[4] changed', date)
setConfirmDate(date)
}}
onConfirm={date => console.log('[4] confirmed', date)}
label="Input"
/>
</View>
<H3>InternationalPhoneCodeSelect</H3>
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
+12 -6
View File
@@ -242,12 +242,18 @@ export function BottomBar({navigation}: BottomTabBarProps) {
accessibilityLabel={l`Chat`}
accessibilityHint={
!aa.flags.chatDisabled && numUnreadMessages.count > 0
? l({
message: plural(numUnreadMessages.numUnread ?? 0, {
one: '# unread item',
other: '# unread items',
}),
})
? numUnreadMessages.numUnread?.includes('+')
? l({
message: `${numUnreadMessages.numUnread} unread items`,
comment:
'Accessibility hint for the bottom bar chat icon when the number of unread messages exceeds the cap, with the + symbol already included for example, 99+ unread items',
})
: l({
message: plural(numUnreadMessages.numUnread ?? 0, {
one: '# unread item',
other: '# unread items',
}),
})
: ''
}
/>
+5
View File
@@ -27,6 +27,10 @@ export function DesktopSearch() {
if (query.length) setActive(true)
}
const onBlur = () => {
setActive(false)
}
const onChangeText = (text: string) => {
setQuery(text)
if (!active) {
@@ -64,6 +68,7 @@ export function DesktopSearch() {
hotkey
value={query}
onFocus={onFocus}
onBlur={onBlur}
onChangeText={onChangeText}
onClearText={onClearText}
onSubmitEditing={onSubmit}
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src", "app.config.js"]
"include": ["src", "modules", "app.config.js"]
}