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

* origin/main: (43 commits)
  Release Prep 1.126.0 (#11015)
  Fix chat input positioning on mobile android (#11013)
  Disable selectable profile description on Android (#11002)
  Fix CI - grant workflows permission for internal sync push (#11010)
  Set apple team id via environment variable (#11008)
  Fix 149/150 user limit for starter packs (#11001)
  Nightly source-language update
  Fix GrowthBook exposure did mis-attribution across account switches (#11000)
  Add advanced search UI (#10992)
  Fail video duration check before compression (#10999)
  Add app language: Czech (`cs`) (#10994)
  Clean up type/lint errors in `modules` (#10996)
  Bump actions/download-artifact from 7.0.0 to 8.0.1 (#10971)
  Nightly source-language update
  Change dialog label and header from 'Invite Friends' to 'Share Profile' (#10987)
  Add placeholder, empty-value, and onConfirm support to DateField (#10993)
  Add video upload telemetry events (#10991)
  Hide autocomplete suggestions when input loses focus (#10989)
  Increase unread cap to 99+ (#10985)
  Add icon to AppLanguageDropdown (#10977)
  ...
This commit is contained in:
Eric Bailey
2026-06-29 19:32:08 -05:00
187 changed files with 52545 additions and 29898 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ jobs:
- name: ⬇️ Download APK artifact
if: ${{ steps.release-check.outputs.exists == 'true' }}
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ needs.build.outputs.apk-artifact-name }}
+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:
+7 -2
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
@@ -25,14 +28,16 @@ jobs:
private-key: ${{ secrets.SYNC_INTERNAL_PK }}
repositories: social-app-internal
# Scope the token down from the app's full installation permissions;
# pushing is the only thing this token is used for
# pushing is the only thing this token is used for. The workflows
# permission is required because the sync includes files under
# .github/workflows/, which GitHub refuses to push without it.
permission-contents: write
permission-workflows: write
- name: Push to internal repo
env:
TOKEN: ${{ steps.app-token.outputs.token }}
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 && \
+2 -1
View File
@@ -135,7 +135,8 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds"
- tapOn:
id: "searchScreenInput"
- inputText: "bob"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
+2 -1
View File
@@ -15,7 +15,8 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn: "Search for posts, users[,]? or feeds"
- tapOn:
id: "searchScreenInput"
- inputText: "b"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
+4 -2
View File
@@ -15,8 +15,10 @@ appId: xyz.blueskyweb.app
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- assertVisible: "Search for posts, users[,]? or feeds"
- tapOn: "Search for posts, users[,]? or feeds"
- assertVisible:
id: "searchScreenInput"
- tapOn:
id: "searchScreenInput"
- inputText: "b"
- tapOn:
id: "searchAutoCompleteResult-bob.test"
+2
View File
@@ -59,6 +59,7 @@ module.exports = function (_config) {
ios: {
supportsTablet: false,
bundleIdentifier: 'xyz.blueskyweb.app',
appleTeamId: process.env.EXPO_APPLE_TEAM_ID,
config: {
usesNonExemptEncryption: false,
},
@@ -81,6 +82,7 @@ module.exports = function (_config) {
'an',
'ast',
'ca',
'cs',
'cy',
'da',
'de',
+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)
+2 -2
View File
@@ -6,8 +6,8 @@
"start": "NODE_ENV=development ./dev-infra/with-test-redis-and-db.sh node ./mock-server.ts"
},
"dependencies": {
"@atproto/api": "^0.20.0",
"@atproto/dev-env": "^0.5.0",
"@atproto/api": "^0.20.22",
"@atproto/dev-env": "^0.5.3",
"typescript": "^6.0.3"
},
"devDependencies": {
+73 -13
View File
@@ -208,10 +208,10 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: ^0.20.0
version: 0.20.5
specifier: ^0.20.22
version: 0.20.22
'@atproto/dev-env':
specifier: ^0.5.0
specifier: ^0.5.3
version: 0.5.3
typescript:
specifier: ^6.0.3
@@ -257,8 +257,8 @@ packages:
resolution: {integrity: sha512-nmjM83KucbRnz/CDSXQX6FLH/GP93XjcI1u5WFlomPscmeOAP1+9AOVsMQlKmh27I3hgaSOdCr03GJjAIpuPZQ==}
engines: {node: '>=22'}
'@atproto/api@0.20.5':
resolution: {integrity: sha512-VqkRYKR9vRRk36NhtytJz5TPhNtR1hczCcfM+bWoOK6MBvWUT8HwelufrycFAIbFIH7n4ws+5UbnLYpTIBQZ6Q==}
'@atproto/api@0.20.22':
resolution: {integrity: sha512-TdT9ktYc0FMmMZ8HjkMz13QcSCcYiesCGN81mFZd5owQASHVM9GBtAAmzpkrPyZhUkiIjgh/nUHk8xWVu+4dVA==}
engines: {node: '>=22'}
'@atproto/aws@0.3.0':
@@ -277,6 +277,10 @@ packages:
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.2':
resolution: {integrity: sha512-oO0JEvM7MM7iXMngq6V51IJlzBZFhFJoDjnGnQT75EO94/8Q5hnM/LP+a8KyWjcBcpcSZwQ0bukNv9phZONdGA==}
engines: {node: '>=22'}
'@atproto/common@0.1.0':
resolution: {integrity: sha512-OB5tWE2R19jwiMIs2IjQieH5KTUuMb98XGCn9h3xuu6NanwjlmbCYMv08fMYwIp3UQ6jcq//84cDT3Bu6fJD+A==}
@@ -331,6 +335,10 @@ packages:
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.3':
resolution: {integrity: sha512-ysqMYW6cIKce52/+EIbTa+I4pLqZQSBP9aGImN88vAQtd1oZBKPmut6dQk00xSQ8PW9REJRuIHNSFAF4HD+fsw==}
engines: {node: '>=22'}
'@atproto/lex-document@0.1.0':
resolution: {integrity: sha512-I2q2iwK8RnSHkBeAj5xdJNYdHiUQqlkbbleBSJKJXlYOYy5y86dWRo9IfE0l9E3qZ3/zvy1ydlEZmZupEt0FGg==}
engines: {node: '>=22'}
@@ -343,6 +351,10 @@ packages:
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.2':
resolution: {integrity: sha512-58nIjoWX0c8T5fcoPPmIaShxKZgfPMhNmrprtR7GuN4PzyMwm59A3CLlKAzzR+vjI3IidEMU+enpLuwUT8L+Nw==}
engines: {node: '>=22'}
'@atproto/lex-resolver@0.1.0':
resolution: {integrity: sha512-zliMiRW4ttSNFreKxyvSpaYQjeWrKpV/lutnXI9BNE8Ysgnw8Rltm0bR7kG/y0OlyClxhhU/vAG+OR8NpjhU1Q==}
engines: {node: '>=22'}
@@ -360,6 +372,10 @@ packages:
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.3':
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'}
'@atproto/oauth-provider-api@0.6.0':
resolution: {integrity: sha512-wFioPBgI71v4PuEwYmIYPp4GNOyaRw1UX8FJq0AiNuUGMbqCjdd7ImSsG4b43hsHr5wOsMirsWyccjUEdhb+yQ==}
engines: {node: '>=22'}
@@ -400,6 +416,10 @@ packages:
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'}
'@atproto/ws-client@0.1.0':
resolution: {integrity: sha512-8qG+A+htxEHpDJRgtYNUExUOejDwU4nE2SEJCFLiTc1G+s0GbFaeg7Fk5drQA9tLMHQWoU9uqIr1YQpJyvJs8w==}
engines: {node: '>=22'}
@@ -412,6 +432,10 @@ packages:
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.2':
resolution: {integrity: sha512-geLqJwazZuCGnae68KZppciS8DujhGvYtpc/aAj16XpHkUCf5Ds64H1IPrV0uv7SFvd/IAuMZAXZS4GIpfp1iw==}
engines: {node: '>=22'}
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -2110,12 +2134,12 @@ snapshots:
- supports-color
- utf-8-validate
'@atproto/api@0.20.5':
'@atproto/api@0.20.22':
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.2
'@atproto/lexicon': 0.7.3
'@atproto/syntax': 0.6.3
'@atproto/xrpc': 0.8.2
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
@@ -2140,7 +2164,7 @@ snapshots:
dependencies:
'@atproto-labs/fetch-node': 0.3.0
'@atproto-labs/xrpc-utils': 0.1.0
'@atproto/api': 0.20.5
'@atproto/api': 0.20.22
'@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0
'@atproto/did': 0.4.0
@@ -2210,6 +2234,13 @@ snapshots:
'@atproto/syntax': 0.6.1
zod: 3.25.76
'@atproto/common-web@0.5.2':
dependencies:
'@atproto/lex-data': 0.1.3
'@atproto/lex-json': 0.1.2
'@atproto/syntax': 0.6.3
zod: 3.25.76
'@atproto/common@0.1.0':
dependencies:
'@ipld/dag-cbor': 7.0.3
@@ -2248,7 +2279,7 @@ snapshots:
'@atproto/dev-env@0.5.3':
dependencies:
'@atproto/api': 0.20.5
'@atproto/api': 0.20.22
'@atproto/bsky': 0.0.234
'@atproto/bsync': 0.0.27
'@atproto/common-web': 0.5.0
@@ -2323,6 +2354,13 @@ snapshots:
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-data@0.1.3':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-document@0.1.0':
dependencies:
'@atproto/lex-schema': 0.1.1
@@ -2345,6 +2383,11 @@ snapshots:
'@atproto/lex-data': 0.1.1
tslib: 2.8.1
'@atproto/lex-json@0.1.2':
dependencies:
'@atproto/lex-data': 0.1.3
tslib: 2.8.1
'@atproto/lex-resolver@0.1.0':
dependencies:
'@atproto-labs/did-resolver': 0.3.0
@@ -2382,6 +2425,13 @@ snapshots:
multiformats: 13.4.2
zod: 3.25.76
'@atproto/lexicon@0.7.3':
dependencies:
'@atproto/common-web': 0.5.2
'@atproto/syntax': 0.6.3
multiformats: 13.4.2
zod: 3.25.76
'@atproto/oauth-provider-api@0.6.0':
dependencies:
'@atproto/jwk': 0.7.0
@@ -2436,7 +2486,7 @@ snapshots:
'@atproto/ozone@0.1.176':
dependencies:
'@atproto/api': 0.20.5
'@atproto/api': 0.20.22
'@atproto/common': 0.6.1
'@atproto/crypto': 0.5.0
'@atproto/identity': 0.5.0
@@ -2551,6 +2601,11 @@ snapshots:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/syntax@0.6.3':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/ws-client@0.1.0':
dependencies:
'@atproto/common': 0.6.1
@@ -2586,6 +2641,11 @@ snapshots:
'@atproto/lexicon': 0.7.1
zod: 3.25.76
'@atproto/xrpc@0.8.2':
dependencies:
'@atproto/lexicon': 0.7.3
zod: 3.25.76
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
-85
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
@@ -210,11 +162,6 @@
"count": 1
}
},
"src/components/Autocomplete/Autocomplete.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
}
},
"src/components/Autocomplete/useAutocomplete/index.ts": {
"react-hooks/immutability": {
"count": 1
@@ -946,14 +893,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
@@ -1922,14 +1861,6 @@
"count": 2
}
},
"src/state/queries/search-posts.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-unsafe-member-access": {
"count": 2
}
},
"src/state/queries/starter-packs.ts": {
"@typescript-eslint/require-await": {
"count": 1
@@ -2028,9 +1959,6 @@
"@typescript-eslint/no-explicit-any": {
"count": 2
},
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"@typescript-eslint/no-misused-promises": {
"count": 4
},
@@ -2126,11 +2054,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 +2106,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
View File
@@ -7,6 +7,7 @@ export default defineConfig({
'an',
'ast',
'ca',
'cs',
'cy',
'da',
'de',
@@ -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": {
@@ -95,7 +95,7 @@
},
"dependencies": {
"@atproto/api": "0.20.23",
"@atproto/syntax": "0.6.1",
"@atproto/syntax": "0.6.3",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
@@ -205,6 +205,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",
+63 -49
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'
@@ -245,8 +245,8 @@ importers:
specifier: 0.20.23
version: 0.20.23
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
specifier: 0.6.3
version: 0.6.3
'@bitdrift/react-native':
specifier: ^0.6.8
version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -574,6 +574,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
@@ -897,10 +900,6 @@ packages:
resolution: {integrity: sha512-WP6ct2rjNCKSJN/VFc+8x6JQ7PvRMlfHUlmQM5hzvE3a6nOjm6kwSicjSIV/QldurdRGJ4FCQZ3uZu9Wqt4SxQ==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.3':
resolution: {integrity: sha512-io7Ck4o+40iFXhetHYoEtok2gZ8cWcJ1yRftEHe5AmAF0dSGcYmclbx5GatVew59taw+eSG7Ty5D3llop/0BhA==}
engines: {node: '>=22'}
@@ -3502,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==}
@@ -6863,6 +6868,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'}
@@ -9511,11 +9519,6 @@ snapshots:
multiformats: 13.4.2
zod: 3.25.76
'@atproto/syntax@0.6.1':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/syntax@0.6.3':
dependencies:
iso-datestring-validator: 2.2.2
@@ -12767,6 +12770,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
@@ -16688,6 +16697,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
@@ -513,7 +513,7 @@ function AccessSection() {
locationControl.open()
})}>
Tap here to update your location with GPS.
</SimpleInlineLinkText>{' '}
</SimpleInlineLinkText>
</Trans>
</Admonition>
+2 -1
View File
@@ -10,10 +10,11 @@ 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',
SearchV2Enable = 'search_v2:enable',
AdvancedSearchV2Enable = 'advanced_search_v2:enable',
AATest = 'aa-test',
}
+69 -11
View File
@@ -1,5 +1,6 @@
import {createContext, useContext, useMemo} from 'react'
import {Platform} from 'react-native'
import {type Result} from '@growthbook/growthbook-react'
import {Logger} from '#/logger'
import {
@@ -169,6 +170,39 @@ export function AnalyticsContext({
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
/**
* GrowthBook attribute name for the did. Must match the key used in
* `setAttributes` (`#/analytics/features`) and the assignment unit configured
* in the GrowthBook dashboard.
*/
const DID_HASH_ATTRIBUTE = 'did'
/**
* Builds the session metadata override for an exposure event so the event's
* `did` is sourced from the unit GrowthBook actually bucketed on
* (`result.hashValue`) rather than from ambient React session metadata. This
* keeps the `did` and the variation in sync, since both then come from the
* same evaluation. See APP-2461.
*
* Only did-bucketed experiments are overridden. Experiments bucketed on
* another attribute (e.g. `deviceId`) have a `hashValue` that is not a did, so
* we leave their session metadata untouched and let the ambient did stand.
*/
function sessionMetadataForResult(
parentContext: AnalyticsBaseContextType,
result: Result<unknown>,
): MergeableMetadata | undefined {
if (result.hashAttribute !== DID_HASH_ATTRIBUTE) return undefined
const {session} = parentContext.metadata
if (!session) return undefined
return {
session: {
...session,
did: result.hashValue,
},
}
}
/**
* Feature gates provider. Decorates the parent analytics context with
* feature gate capabilities. Should be mounted within `AnalyticsContext`,
@@ -185,22 +219,46 @@ export function AnalyticsFeaturesContext({
* Side-effects: we need to synchronously set these during the same render
* cycle. These calls do not trigger re-renders, they just set properties on
* the singleton GrowthBook instance.
*
* Order matters here. We register the tracking callbacks _before_ calling
* `setAttributes`, because `setAttributes` triggers a synchronous
* re-evaluation that can fire exposure events. Registering first guarantees
* those events run through this render's callback (with this render's
* metadata) rather than a stale callback left over from a previous render,
* e.g. after an account switch remounts this provider via the
* `<Fragment key={did} />` breaker in `App.<platform>.tsx`. See APP-2461.
*
* We deliberately keep these synchronous rather than moving them into a
* `useEffect`: `setAttributes` must run before children evaluate gates (or
* they bucket on the previous account's attributes), and the feature usage
* callback has no deferred-replay, so a feature evaluated before it is
* registered would lose its `feature:viewed` event entirely.
*/
setAttributes(parentContext.metadata)
feats.setTrackingCallback((experiment, result) => {
parentContext.metric('experiment:viewed', {
experimentId: experiment.key,
variationId: result.key,
})
parentContext.metric(
'experiment:viewed',
{
experimentId: experiment.key,
variationId: result.key,
},
sessionMetadataForResult(parentContext, result),
)
})
feats.setFeatureUsageCallback((feature, result) => {
parentContext.metric('feature:viewed', {
featureId: feature,
featureResultValue: result.value,
experimentId: result.experiment?.key,
variationId: result.experimentResult?.key,
})
parentContext.metric(
'feature:viewed',
{
featureId: feature,
featureResultValue: result.value,
experimentId: result.experiment?.key,
variationId: result.experimentResult?.key,
},
result.experimentResult
? sessionMetadataForResult(parentContext, result.experimentResult)
: undefined,
)
})
setAttributes(parentContext.metadata)
const childContext = useMemo<AnalyticsContextType>(() => {
return {
+110 -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'
@@ -774,6 +775,7 @@ export type Events = {
'search:query': {
source: 'typed' | 'history' | 'autocomplete'
filterCount: number
}
'search:results:loaded': {
@@ -798,6 +800,18 @@ export type Events = {
position: number
}
'search:advanced:press': {
filterCount: number
}
'search:shareLink:press': {
filterCount: number
}
'search:addFilter:press': {
filterCount: number
}
'progressGuide:hide': {}
'progressGuide:followDialog:open': {}
@@ -1319,7 +1333,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 +1345,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>
+1 -1
View File
@@ -44,7 +44,7 @@ export function Autocomplete({
const t = useTheme()
const updatePosition = useCallback(() => {
sift.updatePosition()
void sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updatePosition)
+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,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
@@ -139,8 +139,7 @@ export function WizardProfileCard({
const isTarget = profile.did === targetProfileDid
const included = isTarget || state.profiles.some(p => p.did === profile.did)
const disabled =
isTarget ||
(!included && state.profiles.length >= STARTER_PACK_MAX_SIZE - 1)
isTarget || (!included && state.profiles.length >= STARTER_PACK_MAX_SIZE)
const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
const displayName = profile.displayName
? sanitizeDisplayName(profile.displayName)
+1
View File
@@ -8,6 +8,7 @@ export const KWS_SUPPORTED_LANGS = [
{value: 'en', label: 'English'},
{value: 'ar', label: 'العربية'},
{value: 'zh-Hans', label: '简体中文'},
{value: 'cs', label: 'Čeština'},
{value: 'nl', label: 'Nederlands'},
{value: 'tl', label: 'Filipino'},
{value: 'fr', label: 'Français'},
@@ -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,24 +16,41 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
label,
isInvalid,
testID,
accessibilityHint,
maximumDate,
minimumDate,
}: DateFieldProps) {
const {i18n} = useLingui()
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 +80,7 @@ export function DateField({
<DateFieldButton
label={label}
value={value}
placeholder={placeholder}
onPress={onPress}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
@@ -77,7 +95,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"
@@ -90,6 +108,9 @@ export function DateField({
maximumDate={
maximumDate ? new Date(toSimpleDateString(maximumDate)) : undefined
}
minimumDate={
minimumDate ? new Date(toSimpleDateString(minimumDate)) : undefined
}
/>
)}
</>
@@ -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>
+39 -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,20 +28,36 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
placeholder,
testID,
label,
isInvalid,
accessibilityHint,
maximumDate,
minimumDate,
}: DateFieldProps) {
const {_, i18n} = useLingui()
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 +69,14 @@ export function DateField({
() => ({
focus: () => {
Keyboard.dismiss()
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
control.open()
},
blur: () => {
control.close()
},
}),
[control],
[control, value, fallbackDate],
)
return (
@@ -67,8 +84,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 +104,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}
@@ -98,11 +117,27 @@ export function DateField({
? new Date(toSimpleDateString(maximumDate))
: undefined
}
minimumDate={
minimumDate
? new Date(toSimpleDateString(minimumDate))
: undefined
}
/>
</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">
+6 -2
View File
@@ -36,11 +36,13 @@ export function DateField({
value,
inputRef,
onChangeDate,
onConfirm,
label,
isInvalid,
testID,
accessibilityHint,
maximumDate,
minimumDate,
}: DateFieldProps) {
const handleOnChange = useCallback(
(e: any) => {
@@ -49,16 +51,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}
@@ -66,6 +69,7 @@ export function DateField({
accessibilityHint={accessibilityHint}
// @ts-expect-error not typed as <input type="date"> even though it is one
max={maximumDate ? toSimpleDateString(maximumDate) : undefined}
min={minimumDate ? toSimpleDateString(minimumDate) : undefined}
/>
</TextField.Root>
)
+16
View File
@@ -3,12 +3,28 @@ 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
testID?: string
accessibilityHint?: string
maximumDate?: string | Date
minimumDate?: string | Date
}
+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
}
+19 -5
View File
@@ -1,10 +1,24 @@
import {type NavigationState, type PartialState} from '@react-navigation/native'
import {type NativeStackNavigationProp} from '@react-navigation/native-stack'
import {type SearchFilters} from '#/screens/Search/searchParams'
import {type VideoFeedSourceContext} from '#/screens/VideoFeed/types'
export type {NativeStackScreenProps} from '@react-navigation/native-stack'
/**
* The advanced-search filter params are owned by searchParams.ts (the param
* model, serialization, and helpers all live there). Re-export the type so the
* route params stay in sync with it automatically rather than being a second
* hand-maintained copy.
*/
export type SearchFilterParams = SearchFilters
export type SearchParams = {
q?: string
tab?: 'user' | 'profile' | 'feed' | 'latest'
} & SearchFilterParams
export type CommonNavigatorParams = {
NotFound: undefined
Lists: undefined
@@ -19,7 +33,7 @@ export type CommonNavigatorParams = {
ProfileFollowers: {name: string}
ProfileFollows: {name: string}
ProfileKnownFollowers: {name: string}
ProfileSearch: {name: string; q?: string}
ProfileSearch: {name: string} & SearchParams
ProfileList: {name: string; rkey: string}
PostThread: {name: string; rkey: string}
PostLikedBy: {name: string; rkey: string}
@@ -60,7 +74,7 @@ export type CommonNavigatorParams = {
AppIconSettings: undefined
FindContactsSettings: undefined
InviteScanner: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Search: SearchParams
Hashtag: {tag: string; author?: string}
Topic: {topic: string}
MessagesConversation: {conversation: string; embed?: string; accept?: true}
@@ -98,7 +112,7 @@ export type HomeTabNavigatorParams = CommonNavigatorParams & {
}
export type SearchTabNavigatorParams = CommonNavigatorParams & {
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Search: SearchParams
}
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
@@ -119,7 +133,7 @@ export type MessagesTabNavigatorParams = CommonNavigatorParams & {
export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Search: SearchParams
Feeds: undefined
Notifications: undefined
Messages: {
@@ -133,7 +147,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
HomeTab: undefined
Home: undefined
SearchTab: undefined
Search: {q?: string; tab?: 'user' | 'profile' | 'feed'}
Search: SearchParams
Feeds: undefined
NotificationsTab: undefined
Notifications: undefined
+2
View File
@@ -169,6 +169,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ast
case 'ca':
return AppLanguage.ca
case 'cs':
return AppLanguage.cs
case 'cy':
return AppLanguage.cy
case 'da':
+11
View File
@@ -17,6 +17,7 @@ import {AppLanguage} from '#/locale/languages'
import {messages as messagesAn} from '#/locale/locales/an/messages'
import {messages as messagesAst} from '#/locale/locales/ast/messages'
import {messages as messagesCa} from '#/locale/locales/ca/messages'
import {messages as messagesCs} from '#/locale/locales/cs/messages'
import {messages as messagesCy} from '#/locale/locales/cy/messages'
import {messages as messagesDa} from '#/locale/locales/da/messages'
import {messages as messagesDe} from '#/locale/locales/de/messages'
@@ -92,6 +93,16 @@ export async function dynamicActivate(locale: AppLanguage) {
])
return dateLocale
}
case AppLanguage.cs: {
i18n.loadAndActivate({locale, messages: messagesCs})
const [dateLocale] = await Promise.all([
import('date-fns/locale/cs').then(m => m.cs),
import('@formatjs/intl-pluralrules/locale-data/cs.js'),
import('@formatjs/intl-numberformat/locale-data/cs.js'),
import('@formatjs/intl-displaynames/locale-data/cs.js'),
])
return dateLocale
}
case AppLanguage.cy: {
i18n.loadAndActivate({locale, messages: messagesCy})
const [dateLocale] = await Promise.all([
+7
View File
@@ -36,6 +36,13 @@ export async function dynamicActivate(locale: AppLanguage) {
])
break
}
case AppLanguage.cs: {
;[messages, dateLocale] = await Promise.all([
import(`./locales/cs/messages`).then(m => m.messages),
import('date-fns/locale/cs').then(m => m.cs),
])
break
}
case AppLanguage.cy: {
;[messages, dateLocale] = await Promise.all([
import(`./locales/cy/messages`).then(m => m.messages),
+2
View File
@@ -9,6 +9,7 @@ export enum AppLanguage {
an = 'an',
ast = 'ast',
ca = 'ca',
cs = 'cs',
cy = 'cy',
da = 'da',
de = 'de',
@@ -58,6 +59,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.an, name: 'aragonés Aragonese'},
{code2: AppLanguage.ast, name: 'asturianu Asturian'},
{code2: AppLanguage.ca, name: 'català Catalan'},
{code2: AppLanguage.cs, name: 'čeština Czech'},
{code2: AppLanguage.cy, name: 'Cymraeg Welsh'},
{code2: AppLanguage.da, name: 'dansk Danish'},
{code2: AppLanguage.de, name: 'Deutsch German'},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More