Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abcabbfb96 | |||
| 1e97d44905 | |||
| 4eca23997c | |||
| 02c8ec6a4f | |||
| 717406f16c | |||
| ea35d930e3 | |||
| 308693762c | |||
| fcf17a482f | |||
| 6b342f61b4 | |||
| eae15aca3e | |||
| 29a0d16b69 | |||
| c33a35851e | |||
| b1b0ad09cc | |||
| 46f5437faa | |||
| 3b2e609761 | |||
| 1ff27bfd3b | |||
| f72aae3bac | |||
| 51e25435a2 | |||
| da1f637160 | |||
| f64b1258b6 | |||
| c909fef3f1 | |||
| 5b66015631 | |||
| 90c010e98a | |||
| f3978d1a1f | |||
| 2843374ef8 | |||
| 0674626f3c | |||
| cd4d62c75a | |||
| c3626c80d1 | |||
| e371d1db44 | |||
| 4308d75e49 | |||
| ea0ef23340 | |||
| 692d8e5d10 | |||
| c753c94969 | |||
| cc0e1f88ea | |||
| 495d1fe9a7 |
@@ -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
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
uses: ./.github/workflows/build-submit-ios.yml
|
||||
with:
|
||||
profile: testflight
|
||||
assignTestFlightGroup: true
|
||||
testFlightGroup: "QA Team"
|
||||
secrets: inherit
|
||||
|
||||
android:
|
||||
|
||||
@@ -17,6 +17,9 @@ jobs:
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't persist the checkout auth header; the push below authenticates
|
||||
# with the app token embedded in the remote URL instead
|
||||
persist-credentials: false
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
@@ -33,6 +36,5 @@ jobs:
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email "test@users.noreply.github.com"
|
||||
git config --unset-all http.https://github.com/.extraheader
|
||||
git remote add internal https://x-access-token:${TOKEN}@github.com/bluesky-social/social-app-internal.git
|
||||
git push internal main --force
|
||||
|
||||
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,22 +10,9 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/BottomSheetPortal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/bottom-sheet/src/lib/Portal.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts": {
|
||||
@@ -33,28 +20,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 4
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 4
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-gif-view/src/GifView.web.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 2
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/PlatformInfo/index.native.ts": {
|
||||
"@typescript-eslint/no-unsafe-call": {
|
||||
"count": 3
|
||||
@@ -88,9 +53,6 @@
|
||||
},
|
||||
"@typescript-eslint/no-unsafe-member-access": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/index.tsx": {
|
||||
@@ -98,16 +60,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-bluesky-swiss-army/src/VisibilityView/types.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"modules/expo-emoji-picker/src/EmojiPickerView.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/Navigation.tsx": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 1
|
||||
@@ -946,14 +898,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/useNotificationHandler.ts": {
|
||||
"@typescript-eslint/no-floating-promises": {
|
||||
"count": 16
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/lib/hooks/useOTAUpdates.ts": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2126,11 +2070,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/composer/videos/pickVideo.web.ts": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/feeds/ComposerPrompt.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
@@ -2183,14 +2122,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/view/com/modals/Modal.tsx": {
|
||||
"@typescript-eslint/no-misused-promises": {
|
||||
"count": 1
|
||||
},
|
||||
"@typescript-eslint/require-await": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/view/com/notifications/NotificationFeed.tsx": {
|
||||
"@typescript-eslint/no-explicit-any": {
|
||||
"count": 2
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from 'react'
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
|
||||
import {createPortalGroup_INTERNAL} from './lib/Portal'
|
||||
|
||||
type PortalContext = React.ElementType<{children: React.ReactNode}>
|
||||
|
||||
export const Context = React.createContext({} as PortalContext)
|
||||
export const Context = createContext({} as PortalContext)
|
||||
Context.displayName = 'BottomSheetPortalContext'
|
||||
|
||||
export const useBottomSheetPortal_INTERNAL = () => React.useContext(Context)
|
||||
export const useBottomSheetPortal_INTERNAL = () => useContext(Context)
|
||||
|
||||
export function BottomSheetPortalProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const portal = React.useMemo(() => {
|
||||
const portal = useMemo(() => {
|
||||
return createPortalGroup_INTERNAL()
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
+6
-7
@@ -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 <
|
||||
|
||||
@@ -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
@@ -8,7 +8,7 @@
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.7.0",
|
||||
"version": "11.9.0",
|
||||
"onFail": "warn"
|
||||
},
|
||||
"runtime": {
|
||||
@@ -94,7 +94,7 @@
|
||||
"update-actions": "pnpm dlx actions-up --min-age 7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "0.20.15",
|
||||
"@atproto/api": "0.20.20",
|
||||
"@atproto/syntax": "0.6.1",
|
||||
"@bitdrift/react-native": "^0.6.8",
|
||||
"@braintree/sanitize-url": "^6.0.2",
|
||||
@@ -204,6 +204,7 @@
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
"lodash.throttle": "^4.1.1",
|
||||
"mediabunny": "^1.25.3",
|
||||
"multiformats": "^13.4.2",
|
||||
"nanoid": "^5.0.5",
|
||||
"normalize-url": "^8.0.0",
|
||||
|
||||
Generated
+101
-69
@@ -7,52 +7,52 @@ importers:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: 11.7.0
|
||||
version: 11.7.0
|
||||
specifier: 11.9.0
|
||||
version: 11.9.0
|
||||
pnpm:
|
||||
specifier: 11.7.0
|
||||
version: 11.7.0
|
||||
specifier: 11.9.0
|
||||
version: 11.9.0
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.7.0':
|
||||
resolution: {integrity: sha512-3CujpSSp2PIDE0pwu7mWSdjhdDqaZa7OppVooECWWaNEoA/z66s9FZts1MhDO+2yq1XER4gBHh84DVbFN/r1rA==}
|
||||
'@pnpm/exe@11.9.0':
|
||||
resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.7.0':
|
||||
resolution: {integrity: sha512-ANTX2SlMO+d2y/4bYQhHCwHPX7gSSADJ5+pMUIiDFzIsybnFFaJdZboaFfq9NOxCbETcnDxqZ95Rz3+NHx1JIw==}
|
||||
'@pnpm/linux-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.7.0':
|
||||
resolution: {integrity: sha512-fr75tqixXoS8cnA81HQIomjOGEPnsOsd3xCDL5pMNY5raOXbKurtgRV+RjATvjxlJxSLIVFKegABlxAiB7q72A==}
|
||||
'@pnpm/linux-x64@11.9.0':
|
||||
resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.7.0':
|
||||
resolution: {integrity: sha512-Q++pgzvXkGeqnVRl26/uqmpMGdttQus0rGyL3XIfYGLCi8ZfajYUaCKdZID2MH7+CNOuugWDdFDup3r7BR7Rfg==}
|
||||
'@pnpm/linuxstatic-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.7.0':
|
||||
resolution: {integrity: sha512-z1+exW6ocU/rmOvJnmU3FUBJYaryCUqFoaXN6KZW5BqTj7BPJb7HJcAyXRlirNZMJlEiUY5rXbfStGPQJhGsVA==}
|
||||
'@pnpm/linuxstatic-x64@11.9.0':
|
||||
resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.7.0':
|
||||
resolution: {integrity: sha512-gD34/k3JT5oab4BYaqrUor3e4VdwXvkfLNlEI+lvDtX1MHT+2Nauc9p9NsQnpn1zE8blQEflzbF8wAUQ6Dmvkw==}
|
||||
'@pnpm/macos-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.7.0':
|
||||
resolution: {integrity: sha512-hRTcDmm2j7KoRwbqNo0rUAu9A1kVJN98Eob7H09U0uPJbEMax85JGOmERkT3Lf6HjVJuFNBvfaJ3OTI3HmlVGg==}
|
||||
'@pnpm/win-arm64@11.9.0':
|
||||
resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.7.0':
|
||||
resolution: {integrity: sha512-r/1NuKY7Z+6ZXVIzVrUEMj6TTEBdR63geV4Rlm8HKEhgQTdxyIsJoqV3FJGqoyzbRaScObqAwRfMaK9dskPddQ==}
|
||||
'@pnpm/win-x64@11.9.0':
|
||||
resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -116,45 +116,45 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.7.0:
|
||||
resolution: {integrity: sha512-GcyFLBIMcSV2DyRD7mvgyltA+fUFmN4aCaHxd1A+AQ5Xwjx3ZG4B52HeWb+HT7IqM5jDOrlpH8E+uUa28PTWIA==}
|
||||
pnpm@11.9.0:
|
||||
resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.7.0':
|
||||
'@pnpm/exe@11.9.0':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.7.0
|
||||
'@pnpm/linux-x64': 11.7.0
|
||||
'@pnpm/linuxstatic-arm64': 11.7.0
|
||||
'@pnpm/linuxstatic-x64': 11.7.0
|
||||
'@pnpm/macos-arm64': 11.7.0
|
||||
'@pnpm/win-arm64': 11.7.0
|
||||
'@pnpm/win-x64': 11.7.0
|
||||
'@pnpm/linux-arm64': 11.9.0
|
||||
'@pnpm/linux-x64': 11.9.0
|
||||
'@pnpm/linuxstatic-arm64': 11.9.0
|
||||
'@pnpm/linuxstatic-x64': 11.9.0
|
||||
'@pnpm/macos-arm64': 11.9.0
|
||||
'@pnpm/win-arm64': 11.9.0
|
||||
'@pnpm/win-x64': 11.9.0
|
||||
|
||||
'@pnpm/linux-arm64@11.7.0':
|
||||
'@pnpm/linux-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.7.0':
|
||||
'@pnpm/linux-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.7.0':
|
||||
'@pnpm/linuxstatic-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.7.0':
|
||||
'@pnpm/linuxstatic-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.7.0':
|
||||
'@pnpm/macos-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.7.0':
|
||||
'@pnpm/win-arm64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.7.0':
|
||||
'@pnpm/win-x64@11.9.0':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
@@ -194,7 +194,7 @@ snapshots:
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.7.0: {}
|
||||
pnpm@11.9.0: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
@@ -241,8 +241,8 @@ importers:
|
||||
.:
|
||||
dependencies:
|
||||
'@atproto/api':
|
||||
specifier: 0.20.15
|
||||
version: 0.20.15
|
||||
specifier: 0.20.20
|
||||
version: 0.20.20
|
||||
'@atproto/syntax':
|
||||
specifier: 0.6.1
|
||||
version: 0.6.1
|
||||
@@ -570,6 +570,9 @@ importers:
|
||||
lodash.throttle:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
mediabunny:
|
||||
specifier: ^1.25.3
|
||||
version: 1.49.0
|
||||
multiformats:
|
||||
specifier: ^13.4.2
|
||||
version: 13.4.2
|
||||
@@ -873,32 +876,36 @@ packages:
|
||||
graphql:
|
||||
optional: true
|
||||
|
||||
'@atproto/api@0.20.15':
|
||||
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
|
||||
'@atproto/api@0.20.20':
|
||||
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
|
||||
'@atproto/common-web@0.5.1':
|
||||
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
|
||||
'@atproto/lex-data@0.1.2':
|
||||
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
|
||||
'@atproto/lex-json@0.1.1':
|
||||
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
|
||||
'@atproto/lexicon@0.7.2':
|
||||
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/syntax@0.6.1':
|
||||
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
|
||||
'@atproto/syntax@0.6.2':
|
||||
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@atproto/xrpc@0.8.1':
|
||||
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
'@babel/code-frame@7.10.4':
|
||||
@@ -3494,6 +3501,12 @@ packages:
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==}
|
||||
|
||||
'@types/dom-webcodecs@0.1.13':
|
||||
resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||
|
||||
@@ -6849,6 +6862,9 @@ packages:
|
||||
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
resolution: {integrity: sha512-hDMtS/q22GFjyB3Yum+a6NHhDtVnye6hgaYTjx3DPUfnckRjzxFmXmCc3UQXdvo7d6PUCl7KM6Y9MRWzlvnAJQ==}
|
||||
|
||||
memfs@3.5.3:
|
||||
resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -9460,40 +9476,40 @@ snapshots:
|
||||
|
||||
'@0no-co/graphql.web@1.2.0': {}
|
||||
|
||||
'@atproto/api@0.20.15':
|
||||
'@atproto/api@0.20.20':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/lexicon': 0.7.1
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/xrpc': 0.8.0
|
||||
'@atproto/common-web': 0.5.1
|
||||
'@atproto/lexicon': 0.7.2
|
||||
'@atproto/syntax': 0.6.2
|
||||
'@atproto/xrpc': 0.8.1
|
||||
await-lock: 3.0.0
|
||||
multiformats: 13.4.2
|
||||
tlds: 1.261.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/common-web@0.5.0':
|
||||
'@atproto/common-web@0.5.1':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-json': 0.1.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/lex-data': 0.1.2
|
||||
'@atproto/lex-json': 0.1.1
|
||||
'@atproto/syntax': 0.6.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@atproto/lex-data@0.1.1':
|
||||
'@atproto/lex-data@0.1.2':
|
||||
dependencies:
|
||||
multiformats: 13.4.2
|
||||
tslib: 2.8.1
|
||||
uint8arrays: 5.1.1
|
||||
unicode-segmenter: 0.14.5
|
||||
|
||||
'@atproto/lex-json@0.1.0':
|
||||
'@atproto/lex-json@0.1.1':
|
||||
dependencies:
|
||||
'@atproto/lex-data': 0.1.1
|
||||
'@atproto/lex-data': 0.1.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/lexicon@0.7.1':
|
||||
'@atproto/lexicon@0.7.2':
|
||||
dependencies:
|
||||
'@atproto/common-web': 0.5.0
|
||||
'@atproto/syntax': 0.6.1
|
||||
'@atproto/common-web': 0.5.1
|
||||
'@atproto/syntax': 0.6.2
|
||||
multiformats: 13.4.2
|
||||
zod: 3.25.76
|
||||
|
||||
@@ -9502,9 +9518,14 @@ snapshots:
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.8.0':
|
||||
'@atproto/syntax@0.6.2':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.1
|
||||
iso-datestring-validator: 2.2.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@atproto/xrpc@0.8.1':
|
||||
dependencies:
|
||||
'@atproto/lexicon': 0.7.2
|
||||
zod: 3.25.76
|
||||
|
||||
'@babel/code-frame@7.10.4':
|
||||
@@ -12748,6 +12769,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.12.4
|
||||
|
||||
'@types/dom-mediacapture-transform@0.1.11':
|
||||
dependencies:
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
'@types/dom-webcodecs@0.1.13': {}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
dependencies:
|
||||
'@types/eslint': 9.6.1
|
||||
@@ -16664,6 +16691,11 @@ snapshots:
|
||||
|
||||
media-typer@0.3.0: {}
|
||||
|
||||
mediabunny@1.49.0:
|
||||
dependencies:
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
'@types/dom-webcodecs': 0.1.13
|
||||
|
||||
memfs@3.5.3:
|
||||
dependencies:
|
||||
fs-monkey: 1.1.0
|
||||
|
||||
@@ -399,7 +399,7 @@ function AccessSection() {
|
||||
locationControl.open()
|
||||
})}>
|
||||
Tap here to update your location with GPS.
|
||||
</SimpleInlineLinkText>{' '}
|
||||
</SimpleInlineLinkText>
|
||||
</Trans>
|
||||
</Admonition>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export enum Features {
|
||||
ImportContactsSettingsDisable = 'import_contacts:settings:disable',
|
||||
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||
GroupChatsDisable = 'group_chats:disable',
|
||||
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
|
||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import {type Platform} from 'react-native'
|
||||
|
||||
import {type NotificationReason} from '#/lib/hooks/useNotificationHandler'
|
||||
import {type VideoCompressSkipReason} from '#/lib/media/video/types'
|
||||
import {type NotificationType} from '#/state/queries/notifications/types'
|
||||
import {type FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types'
|
||||
@@ -1319,7 +1320,7 @@ export type Events = {
|
||||
'invite:action:scan': {}
|
||||
// user changed the QR card color theme
|
||||
'invite:theme:change': {
|
||||
themeKey: 'dawn' | 'day' | 'dusk' | 'night'
|
||||
themeKey: 'dawn' | 'sunlight' | 'day' | 'dusk' | 'twilight' | 'night'
|
||||
}
|
||||
// QR scanner decoded a code; result indicates whether it resolved to a profile
|
||||
'invite:scanner:scanned': {
|
||||
@@ -1331,4 +1332,99 @@ export type Events = {
|
||||
'invite:followersPromo:press': {}
|
||||
// user dismissed the empty-followers promo banner
|
||||
'invite:followersPromo:dismiss': {}
|
||||
|
||||
// === Video upload funnel (Frontend Spec section D) ===
|
||||
// Every event carries uploadId (client-generated UUID, ties one upload
|
||||
// session end-to-end) + engine (compression engine id, e.g.
|
||||
// native:react-native-compressor@1.13.0). jobId is added once the server
|
||||
// returns it. Sizes / codecs / dimensions / timings only - never content.
|
||||
'video:upload:picked': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceMimeType?: string
|
||||
sourceBytes?: number
|
||||
sourceDurationMs?: number
|
||||
sourceWidth?: number
|
||||
sourceHeight?: number
|
||||
}
|
||||
'video:upload:compressStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
sourceBytes?: number
|
||||
}
|
||||
'video:upload:compressCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytesIn?: number
|
||||
bytesOut: number
|
||||
outputMimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressSkipped': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
skipReason: VideoCompressSkipReason
|
||||
bytes: number
|
||||
mimeType: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:compressFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:uploadStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
}
|
||||
'video:upload:uploadCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
bytes: number
|
||||
elapsedMs: number
|
||||
throughputBytesPerSec: number
|
||||
}
|
||||
'video:upload:uploadFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
bytes: number
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingStarted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
}
|
||||
'video:upload:processingCompleted': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:processingFailed': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
errorClass: string
|
||||
elapsedMs: number
|
||||
}
|
||||
'video:upload:published': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
jobId: string
|
||||
// wall-clock from picked to published
|
||||
totalElapsedMs: number
|
||||
}
|
||||
// The event that measures the actual problem: users giving up mid-wait.
|
||||
'video:upload:abandoned': {
|
||||
uploadId: string
|
||||
engine: string
|
||||
phase: 'compress' | 'upload' | 'processing'
|
||||
jobId?: string
|
||||
elapsedInPhaseMs: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -142,7 +142,6 @@ export function ImageEmbed({
|
||||
onPress(0, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn(0)}
|
||||
hideBadge={rest.isWithinQuote}
|
||||
/>
|
||||
</ImageContextMenu>
|
||||
</View>
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {ActivityIndicator, View} from 'react-native'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
@@ -29,6 +24,7 @@ export function GifPresentationControls({
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const largeBadge = useLargeAltBadgeEnabled()
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -64,74 +60,41 @@ export function GifPresentationControls({
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.gifBadgeContainer}>
|
||||
<Text style={[{color: 'white'}, a.font_bold, a.text_xs]}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.z_10,
|
||||
{
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
gap: 3,
|
||||
},
|
||||
largeBadge && {
|
||||
gap: 4,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
a.z_10,
|
||||
t.atoms.bg_contrast_25,
|
||||
largeBadge && {
|
||||
padding: 6,
|
||||
},
|
||||
{
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}>
|
||||
<Text style={[a.font_bold, largeBadge ? a.text_xs : {fontSize: 8}]}>
|
||||
<Trans>GIF</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
{altText && <AltBadgeWithDialog text={altText} />}
|
||||
</View>
|
||||
{altText && <AltBadge text={altText} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AltBadge({text}: {text: string}) {
|
||||
const control = Prompt.usePromptControl()
|
||||
const {_} = useLingui()
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
testID="altTextButton"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Show alt text`)}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_20}
|
||||
onPress={control.open}
|
||||
style={styles.altBadgeContainer}>
|
||||
<Text
|
||||
style={[{color: 'white'}, a.font_bold, a.text_xs]}
|
||||
accessible={false}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<Prompt.Outer control={control}>
|
||||
<Prompt.Content>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Alt Text</Trans>
|
||||
</Prompt.TitleText>
|
||||
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
|
||||
</Prompt.Content>
|
||||
<Prompt.Actions>
|
||||
<Prompt.Action
|
||||
onPress={() => control.close()}
|
||||
cta={_(msg`Close`)}
|
||||
color="secondary"
|
||||
/>
|
||||
</Prompt.Actions>
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
gifBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
altBadgeContainer: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
zIndex: 2,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
@@ -98,19 +99,24 @@ export function VideoEmbedInnerNative({
|
||||
altText={embed.alt}
|
||||
/>
|
||||
) : (
|
||||
<VideoPresentationControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
<>
|
||||
<VideoPresentationControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen(true)
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<AltBadgeWithDialog text={embed.alt} position="top-right" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MediaInsetBorder />
|
||||
<KeepAwake enabled={isPlaying} />
|
||||
|
||||
@@ -7,6 +7,8 @@ import type * as HlsTypes from 'hls.js'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {AltBadgeWithDialog} from '#/components/AltBadgeWithDialog'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import * as BandwidthEstimate from './bandwidth-estimate'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
@@ -30,6 +32,8 @@ export function VideoEmbedInnerWeb({
|
||||
const [hlsLoading, setHlsLoading] = useState(false)
|
||||
const figId = useId()
|
||||
const {_} = useLingui()
|
||||
const [isFullscreen] = useFullscreen(containerRef)
|
||||
const isGif = embed.presentation === 'gif'
|
||||
|
||||
// send error up to error boundary
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
@@ -77,6 +81,9 @@ export function VideoEmbedInnerWeb({
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
{!isFullscreen && !isGif && embed.alt && (
|
||||
<AltBadgeWithDialog text={embed.alt} position="top-right" />
|
||||
)}
|
||||
<Controls
|
||||
videoRef={videoRef}
|
||||
hlsRef={hlsRef}
|
||||
@@ -88,7 +95,7 @@ export function VideoEmbedInnerWeb({
|
||||
onScreen={onScreen}
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
isGif={embed.presentation === 'gif'}
|
||||
isGif={isGif}
|
||||
altText={embed.alt}
|
||||
updateCuePositions={updateCuePositions}
|
||||
/>
|
||||
|
||||
@@ -40,11 +40,7 @@ import {ModeratedFeedEmbed} from './FeedEmbed'
|
||||
import {ImageEmbed} from './ImageEmbed'
|
||||
import {ModeratedListEmbed} from './ListEmbed'
|
||||
import {PostPlaceholder as PostPlaceholderText} from './PostPlaceholder'
|
||||
import {
|
||||
type CommonProps,
|
||||
type EmbedProps,
|
||||
type PostEmbedViewContext,
|
||||
} from './types'
|
||||
import {type CommonProps, type EmbedProps, PostEmbedViewContext} from './types'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
|
||||
export {PostEmbedViewContext} from './types'
|
||||
@@ -356,7 +352,7 @@ export function QuoteEmbed({
|
||||
return (
|
||||
<GalleryBleed>
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
style={[viewContext !== PostEmbedViewContext.ChatMessage && a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {
|
||||
isChatPreferenceName,
|
||||
type NotificationSettingsPreferenceName,
|
||||
useChatNotificationSettingsQuery,
|
||||
useNotificationSettingsQuery,
|
||||
} from '#/state/queries/notifications/settings'
|
||||
import * as SettingsList from '#/screens/Settings/components/SettingsList'
|
||||
import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
|
||||
|
||||
type NotificationSettingsDialogProps = {
|
||||
control: Dialog.DialogControlProps
|
||||
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
|
||||
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
|
||||
name: NotificationSettingsPreferenceName
|
||||
syncOthers?: NotificationSettingsPreferenceName[]
|
||||
icon: React.ComponentType<SVGIconProps>
|
||||
titleText: React.ReactNode
|
||||
subtitleText: React.ReactNode
|
||||
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
|
||||
}: Omit<NotificationSettingsDialogProps, 'icon'>) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const {data: preferences, isError} = useNotificationSettingsQuery()
|
||||
const isChat = isChatPreferenceName(name)
|
||||
const appQuery = useNotificationSettingsQuery({enabled: !isChat})
|
||||
const chatQuery = useChatNotificationSettingsQuery({enabled: isChat})
|
||||
const isError = isChat ? chatQuery.isError : appQuery.isError
|
||||
const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name]
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
|
||||
<PreferenceControls
|
||||
name={name}
|
||||
syncOthers={syncOthers}
|
||||
preference={preferences?.[name]}
|
||||
preference={preference}
|
||||
allowDisableInApp={allowDisableInApp}
|
||||
/>
|
||||
)}
|
||||
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
|
||||
<Dialog.Close />
|
||||
{IS_NATIVE && (
|
||||
<Button
|
||||
color="secondary"
|
||||
color="primary"
|
||||
size="large"
|
||||
label={l`Close dialog`}
|
||||
onPress={() => control.close()}
|
||||
|
||||
@@ -168,7 +168,7 @@ export function GroupChatsAnnouncement() {
|
||||
a.font_medium,
|
||||
{color: t.palette.primary_500},
|
||||
]}>
|
||||
<Trans>New</Trans>
|
||||
<Trans context="nux-description">New</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
AppBskyEmbedExternal,
|
||||
AppBskyEmbedRecord,
|
||||
type ChatBskyConvoDefs,
|
||||
ChatBskyEmbedJoinLink,
|
||||
ChatBskyGroupDefs,
|
||||
} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {BSKY_APP_HOST, toShortUrl} from '#/lib/strings/url-helpers'
|
||||
|
||||
/**
|
||||
* Describes the embed of a quoted message that has no text of its own, so the
|
||||
* reply preview can show what was shared instead of a generic placeholder. For
|
||||
* a link card we surface the URI directly; otherwise we classify the quoted
|
||||
* record (post/feed/list/etc.) so the caller can render a translated label.
|
||||
*/
|
||||
type ReplyEmbedSummary =
|
||||
| {type: 'external'; uri: string}
|
||||
| {type: 'post'}
|
||||
| {type: 'unknown'}
|
||||
|
||||
function summarizeReplyEmbed(
|
||||
embed: ChatBskyConvoDefs.MessageView['embed'],
|
||||
): ReplyEmbedSummary {
|
||||
if (!AppBskyEmbedRecord.isView(embed)) return {type: 'unknown'}
|
||||
const {record} = embed
|
||||
if (AppBskyEmbedRecord.isViewRecord(record)) {
|
||||
const inner = record.embeds?.[0]
|
||||
if (AppBskyEmbedExternal.isView(inner)) {
|
||||
return {type: 'external', uri: inner.external.uri}
|
||||
}
|
||||
return {type: 'post'}
|
||||
}
|
||||
return {type: 'unknown'}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatter that computes the preview text for a message being quoted
|
||||
* in a reply, shared between the staged-reply composer and the sent reply
|
||||
* bubble so the two stay in sync. When the message has its own text we use it
|
||||
* verbatim; otherwise we summarize the embed.
|
||||
*
|
||||
* `subtle` indicates the text is a placeholder (not real message content), so
|
||||
* callers can render it in a muted/italic style.
|
||||
*/
|
||||
export function useReplyPreviewText(): (
|
||||
message: ChatBskyConvoDefs.MessageView,
|
||||
) => {text: string; subtle: boolean} {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
return (message: ChatBskyConvoDefs.MessageView) => {
|
||||
const text = message.text
|
||||
if (text.trim()) {
|
||||
return {text, subtle: false}
|
||||
}
|
||||
|
||||
if (ChatBskyEmbedJoinLink.isView(message.embed)) {
|
||||
const {joinLinkPreview} = message.embed
|
||||
if (ChatBskyGroupDefs.isJoinLinkPreviewView(joinLinkPreview)) {
|
||||
return {
|
||||
text: `${BSKY_APP_HOST}/chat/${joinLinkPreview.code}`,
|
||||
subtle: true,
|
||||
}
|
||||
}
|
||||
if (ChatBskyGroupDefs.isDisabledJoinLinkPreviewView(joinLinkPreview)) {
|
||||
return {
|
||||
text: l({
|
||||
message: '(disabled chat invite link)',
|
||||
comment: 'A reply summary in chat',
|
||||
}),
|
||||
subtle: true,
|
||||
}
|
||||
}
|
||||
if (ChatBskyGroupDefs.isInvalidJoinLinkPreviewView(joinLinkPreview)) {
|
||||
return {
|
||||
text: l({
|
||||
message: '(invalid chat invite link)',
|
||||
comment: 'A reply summary in chat',
|
||||
}),
|
||||
subtle: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
text: l({
|
||||
message: '(chat invite link)',
|
||||
comment: 'A reply summary in chat',
|
||||
}),
|
||||
subtle: true,
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeReplyEmbed(message.embed)
|
||||
switch (summary.type) {
|
||||
case 'external':
|
||||
return {text: toShortUrl(summary.uri), subtle: true}
|
||||
case 'post':
|
||||
return {
|
||||
text: l({
|
||||
message: '(quoted post)',
|
||||
comment: 'A reply summary in chat',
|
||||
}),
|
||||
subtle: true,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
text: l({message: '(no text)', comment: 'A reply summary in chat'}),
|
||||
subtle: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
onConfirm,
|
||||
placeholder,
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
@@ -26,14 +28,28 @@ export function DateField({
|
||||
const t = useTheme()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
/*
|
||||
* The picker requires a valid date, so when value is empty we open at
|
||||
* maximumDate (if set) or today. Normalize through toSimpleDateString so a
|
||||
* date-only value is parsed as UTC midnight, consistent with the picker's
|
||||
* timeZoneOffsetInMinutes={0} and the maximumDate below.
|
||||
*/
|
||||
const initialDate =
|
||||
value === ''
|
||||
? maximumDate
|
||||
? new Date(toSimpleDateString(maximumDate))
|
||||
: new Date()
|
||||
: new Date(toSimpleDateString(value))
|
||||
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false)
|
||||
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
onConfirm?.(formatted)
|
||||
},
|
||||
[onChangeDate, setOpen],
|
||||
[onChangeDate, onConfirm, setOpen],
|
||||
)
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -63,6 +79,7 @@ export function DateField({
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onPress={onPress}
|
||||
isInvalid={isInvalid}
|
||||
accessibilityHint={accessibilityHint}
|
||||
@@ -77,7 +94,7 @@ export function DateField({
|
||||
theme={t.scheme}
|
||||
// @ts-ignore TODO
|
||||
buttonColor={t.name === 'light' ? '#000000' : '#ffffff'}
|
||||
date={new Date(value)}
|
||||
date={initialDate}
|
||||
onConfirm={onChangeInternal}
|
||||
onCancel={onCancel}
|
||||
mode="date"
|
||||
|
||||
@@ -14,12 +14,14 @@ import {Text} from '#/components/Typography'
|
||||
export function DateFieldButton({
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
onPress,
|
||||
isInvalid,
|
||||
accessibilityHint,
|
||||
}: {
|
||||
label: string
|
||||
value: string | Date
|
||||
placeholder?: string
|
||||
onPress: () => void
|
||||
isInvalid?: boolean
|
||||
accessibilityHint?: string
|
||||
@@ -78,20 +80,18 @@ export function DateFieldButton({
|
||||
a.align_center,
|
||||
hovered ? chromeHover : {},
|
||||
focused || pressed ? chromeFocus : {},
|
||||
isInvalid || isInvalid ? chromeError : {},
|
||||
(isInvalid || isInvalid) && (hovered || focused)
|
||||
? chromeErrorHover
|
||||
: {},
|
||||
isInvalid ? chromeError : {},
|
||||
isInvalid && (hovered || focused) ? chromeErrorHover : {},
|
||||
]}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.pl_xs,
|
||||
t.atoms.text,
|
||||
value === '' ? t.atoms.text_contrast_low : t.atoms.text,
|
||||
{lineHeight: a.text_md.fontSize * 1.1875},
|
||||
]}>
|
||||
{i18n.date(value, {timeZone: 'UTC'})}
|
||||
{value === '' ? placeholder : i18n.date(value, {timeZone: 'UTC'})}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback, useImperativeHandle} from 'react'
|
||||
import {useCallback, useImperativeHandle, useState} from 'react'
|
||||
import {Keyboard, View} from 'react-native'
|
||||
import DatePicker from 'react-native-date-picker'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
@@ -28,6 +28,8 @@ export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
onConfirm,
|
||||
placeholder,
|
||||
testID,
|
||||
label,
|
||||
isInvalid,
|
||||
@@ -38,10 +40,23 @@ export function DateField({
|
||||
const t = useTheme()
|
||||
const control = Dialog.useDialogControl()
|
||||
|
||||
/*
|
||||
* The picker requires a valid date, so when value is empty we fall back to
|
||||
* maximumDate (if set) or today. Draft state lets the picker scroll even when
|
||||
* the parent does not echo value back (e.g. a clearable field).
|
||||
*/
|
||||
const fallbackDate = maximumDate
|
||||
? toSimpleDateString(maximumDate)
|
||||
: toSimpleDateString(new Date())
|
||||
const [draft, setDraft] = useState(() =>
|
||||
value === '' ? fallbackDate : toSimpleDateString(value),
|
||||
)
|
||||
|
||||
const onChangeInternal = useCallback(
|
||||
(date: Date | undefined) => {
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
setDraft(formatted)
|
||||
onChangeDate(formatted)
|
||||
}
|
||||
},
|
||||
@@ -53,13 +68,14 @@ export function DateField({
|
||||
() => ({
|
||||
focus: () => {
|
||||
Keyboard.dismiss()
|
||||
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
|
||||
control.open()
|
||||
},
|
||||
blur: () => {
|
||||
control.close()
|
||||
},
|
||||
}),
|
||||
[control],
|
||||
[control, value, fallbackDate],
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -67,8 +83,10 @@ export function DateField({
|
||||
<DateFieldButton
|
||||
label={label}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss()
|
||||
setDraft(value === '' ? fallbackDate : toSimpleDateString(value))
|
||||
control.open()
|
||||
}}
|
||||
isInvalid={isInvalid}
|
||||
@@ -85,7 +103,7 @@ export function DateField({
|
||||
<DatePicker
|
||||
timeZoneOffsetInMinutes={0}
|
||||
theme={t.scheme}
|
||||
date={new Date(toSimpleDateString(value))}
|
||||
date={new Date(draft)}
|
||||
onDateChange={onChangeInternal}
|
||||
mode="date"
|
||||
locale={i18n.locale}
|
||||
@@ -102,7 +120,18 @@ export function DateField({
|
||||
</View>
|
||||
<Button
|
||||
label={_(msg`Done`)}
|
||||
onPress={() => control.close()}
|
||||
onPress={() => {
|
||||
/*
|
||||
* Commit the currently shown date even if the user never
|
||||
* scrolled (onDateChange only fires on scroll). This keeps
|
||||
* onChangeDate firing alongside onConfirm, matching Android and
|
||||
* web, so an empty field confirmed without scrolling does not
|
||||
* report a date via onConfirm while onChangeDate stays silent.
|
||||
*/
|
||||
onChangeDate(draft)
|
||||
onConfirm?.(draft)
|
||||
control.close()
|
||||
}}
|
||||
size="large"
|
||||
color="primary"
|
||||
variant="solid">
|
||||
|
||||
@@ -36,6 +36,7 @@ export function DateField({
|
||||
value,
|
||||
inputRef,
|
||||
onChangeDate,
|
||||
onConfirm,
|
||||
label,
|
||||
isInvalid,
|
||||
testID,
|
||||
@@ -49,16 +50,17 @@ export function DateField({
|
||||
if (date) {
|
||||
const formatted = toSimpleDateString(date)
|
||||
onChangeDate(formatted)
|
||||
onConfirm?.(formatted)
|
||||
}
|
||||
},
|
||||
[onChangeDate],
|
||||
[onChangeDate, onConfirm],
|
||||
)
|
||||
|
||||
return (
|
||||
<TextField.Root isInvalid={isInvalid}>
|
||||
<TextField.Icon icon={CalendarDays} />
|
||||
<Input
|
||||
value={toSimpleDateString(value)}
|
||||
value={value === '' ? '' : toSimpleDateString(value)}
|
||||
inputRef={inputRef as React.Ref<TextInput>}
|
||||
label={label}
|
||||
onChange={handleOnChange}
|
||||
|
||||
@@ -3,8 +3,23 @@ export type DateFieldRef = {
|
||||
blur: () => void
|
||||
}
|
||||
export type DateFieldProps = {
|
||||
/**
|
||||
* An empty string renders the placeholder and opens the picker at today (or
|
||||
* maximumDate, if earlier).
|
||||
*/
|
||||
value: string | Date
|
||||
onChangeDate: (date: string) => void
|
||||
/**
|
||||
* Fired when the user commits a date: iOS "Done", Android confirm, or web
|
||||
* input change. Distinct from onChangeDate, which on iOS fires on every
|
||||
* scroll tick.
|
||||
*/
|
||||
onConfirm?: (date: string) => void
|
||||
/**
|
||||
* Shown on native when value is empty. Web uses the browser's native date
|
||||
* placeholder.
|
||||
*/
|
||||
placeholder?: string
|
||||
label: string
|
||||
inputRef?: React.Ref<DateFieldRef>
|
||||
isInvalid?: boolean
|
||||
|
||||
@@ -65,7 +65,6 @@ export function ConstrainedImage({
|
||||
export function AutoSizedImage({
|
||||
image,
|
||||
crop = 'constrained',
|
||||
hideBadge,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
@@ -74,7 +73,6 @@ export function AutoSizedImage({
|
||||
}: {
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
crop?: 'none' | 'square' | 'constrained'
|
||||
hideBadge?: boolean
|
||||
onPress?: (
|
||||
containerRef: AnimatedRef<any>,
|
||||
fetchedDims: Dimensions | null,
|
||||
@@ -146,7 +144,7 @@ export function AutoSizedImage({
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadge ? (
|
||||
{hasAlt || isCropped ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
@@ -165,8 +163,10 @@ export function AutoSizedImage({
|
||||
]}>
|
||||
{isCropped && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
@@ -186,17 +186,18 @@ export function AutoSizedImage({
|
||||
)}
|
||||
{hasAlt && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAlt && [
|
||||
{
|
||||
padding: 5,
|
||||
padding: 6,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
|
||||
@@ -115,7 +115,6 @@ export function Gallery({
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const isWithinChat = viewContext === PostEmbedViewContext.ChatMessage
|
||||
const hideBadges = isWithinQuote
|
||||
const contentHeight = useMemo(() => {
|
||||
if (isWithinChat) {
|
||||
return 120
|
||||
@@ -250,7 +249,6 @@ export function Gallery({
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={isWithinQuote}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -300,7 +298,6 @@ export function Gallery({
|
||||
: undefined
|
||||
return (
|
||||
<GalleryImage
|
||||
hideBadges={hideBadges}
|
||||
largeAltBadge={largeAltBadge}
|
||||
image={item}
|
||||
contentHeight={contentHeight}
|
||||
@@ -396,7 +393,6 @@ function GalleryImage({
|
||||
imageCount,
|
||||
onWidthChange,
|
||||
itemRef,
|
||||
hideBadges,
|
||||
largeAltBadge,
|
||||
onContainerRef,
|
||||
onThumbDims,
|
||||
@@ -410,7 +406,6 @@ function GalleryImage({
|
||||
imageCount: number
|
||||
onWidthChange: (index: number, width: number) => void
|
||||
itemRef: (node: View | null) => void
|
||||
hideBadges?: boolean
|
||||
largeAltBadge?: boolean
|
||||
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
|
||||
onThumbDims: (index: number, dims: Dimensions) => void
|
||||
@@ -499,7 +494,7 @@ function GalleryImage({
|
||||
useAppleWebpCodec
|
||||
/>
|
||||
|
||||
{!hideBadges && imageCount > 1 ? (
|
||||
{imageCount > 1 ? (
|
||||
<View
|
||||
accessible={false}
|
||||
pointerEvents="none"
|
||||
@@ -532,7 +527,7 @@ function GalleryImage({
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadges ? (
|
||||
{hasAlt || isCropped ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
@@ -549,6 +544,7 @@ function GalleryImage({
|
||||
]}>
|
||||
{isCropped && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
@@ -568,6 +564,7 @@ function GalleryImage({
|
||||
)}
|
||||
{hasAlt && (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_sm,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {type PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type EventFunction = (index: number) => void
|
||||
@@ -42,8 +42,6 @@ export function GalleryItem({
|
||||
onPress,
|
||||
onPressIn,
|
||||
onLongPress,
|
||||
viewContext,
|
||||
isWithinQuote,
|
||||
insetBorderStyle,
|
||||
containerRefs,
|
||||
thumbDimsRef,
|
||||
@@ -53,9 +51,6 @@ export function GalleryItem({
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const image = images[index]
|
||||
const hasAlt = !!image.alt
|
||||
const hideBadges =
|
||||
isWithinQuote ??
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
|
||||
const aspect =
|
||||
image.aspectRatio && image.aspectRatio.height > 0
|
||||
@@ -114,26 +109,24 @@ export function GalleryItem({
|
||||
<MediaInsetBorder style={insetBorderStyle} />
|
||||
</Pressable>
|
||||
</ImageContextMenu>
|
||||
{hasAlt && !hideBadges ? (
|
||||
{hasAlt ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.p_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
padding: 3,
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && [
|
||||
{
|
||||
gap: 4,
|
||||
padding: 5,
|
||||
padding: 6,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+503
-442
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,21 @@ import {View} from 'react-native'
|
||||
import {useAnimatedRef} from 'react-native-reanimated'
|
||||
import {type ChatBskyActorGetStatus, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
|
||||
import {
|
||||
useFocusEffect,
|
||||
useIsFocused,
|
||||
useNavigation,
|
||||
} from '@react-navigation/native'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {useAppState} from '#/lib/appState'
|
||||
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
|
||||
import {type MessagesTabNavigatorParams} from '#/lib/routes/types'
|
||||
import {
|
||||
type MessagesTabNavigatorParams,
|
||||
type NavigationProp,
|
||||
} from '#/lib/routes/types'
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {listenSoftReset} from '#/state/events'
|
||||
@@ -19,6 +26,7 @@ import {useMessagesEventBus} from '#/state/messages/events'
|
||||
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
|
||||
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
import {List, type ListRef} from '#/view/com/util/List'
|
||||
import {ChatListLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
|
||||
@@ -31,6 +39,7 @@ import {NewChat} from '#/components/dms/dialogs/NewChatDialog'
|
||||
import {useRefreshOnFocus} from '#/components/hooks/useRefreshOnFocus'
|
||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as RetryIcon} from '#/components/icons/ArrowRotate'
|
||||
import {BubbleSmile_Stroke2_Corner2_Rounded_Large as BubbleSmileIcon} from '#/components/icons/Bubble'
|
||||
import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck'
|
||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfoIcon} from '#/components/icons/CircleInfo'
|
||||
import {Inbox_Stroke2_Corner2_Rounded_Large as InboxLargeIcon} from '#/components/icons/Inbox'
|
||||
import {
|
||||
@@ -41,6 +50,8 @@ import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/component
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Link} from '#/components/Link'
|
||||
import {ListFooter} from '#/components/Lists'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
@@ -289,7 +300,10 @@ export function ChatList({
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useListConvosQuery({status: 'accepted'})
|
||||
} = useListConvosQuery({
|
||||
status: 'accepted',
|
||||
kind: aa.flags.groupChatDisabled ? 'direct' : 'all',
|
||||
})
|
||||
|
||||
const {refetch: refetchInbox} = useListConvosQuery({
|
||||
status: 'request',
|
||||
@@ -546,16 +560,19 @@ export function Header({
|
||||
variant="solid"
|
||||
action={action}
|
||||
/>
|
||||
<Link
|
||||
to="/messages/settings"
|
||||
action={action}
|
||||
label={l`Chat settings`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonIcon icon={SettingsIcon} />
|
||||
</Link>
|
||||
<ChatSettingsMenu action={action}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={l`Chat options`}
|
||||
size="small"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonIcon icon={SettingsIcon} />
|
||||
</Button>
|
||||
)}
|
||||
</ChatSettingsMenu>
|
||||
{!chatStatus?.chatDisabled && (
|
||||
<Button
|
||||
label={l`New chat`}
|
||||
@@ -578,19 +595,75 @@ export function Header({
|
||||
</Layout.Header.Content>
|
||||
<InboxRequests count={requestCount} variant="ghost" />
|
||||
<Layout.Header.Slot>
|
||||
<Link
|
||||
to="/messages/settings"
|
||||
label={l`Chat settings`}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonIcon icon={SettingsIcon} size="lg" />
|
||||
</Link>
|
||||
<ChatSettingsMenu action={action}>
|
||||
{({props}) => (
|
||||
<Button
|
||||
{...props}
|
||||
label={l`Chat options`}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
shape="round"
|
||||
style={[a.justify_center]}>
|
||||
<ButtonIcon icon={SettingsIcon} size="lg" />
|
||||
</Button>
|
||||
)}
|
||||
</ChatSettingsMenu>
|
||||
</Layout.Header.Slot>
|
||||
</>
|
||||
)}
|
||||
</Layout.Header.Outer>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatSettingsMenu({
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
action: 'navigate' | 'push'
|
||||
children: React.ComponentProps<typeof Menu.Trigger>['children']
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
|
||||
const {mutate: markAllChatsRead} = useUpdateAllRead('accepted', {
|
||||
onMutate: () => {
|
||||
Toast.show(l`Marked all chats as read`, {type: 'success'})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.show(l`Failed to mark all chats as read`, {type: 'error'})
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger label={l`Chat options`}>{children}</Menu.Trigger>
|
||||
<Menu.Outer>
|
||||
<Menu.Group>
|
||||
<Menu.Item
|
||||
label={l`Mark all chats as read`}
|
||||
onPress={() => markAllChatsRead()}>
|
||||
<Menu.ItemIcon icon={CircleCheckIcon} />
|
||||
<Menu.ItemText>
|
||||
<Trans>Mark all chats as read</Trans>
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
label={l`Chat settings`}
|
||||
onPress={() => {
|
||||
if (action === 'navigate') {
|
||||
navigation.navigate('MessagesSettings')
|
||||
} else {
|
||||
navigation.push('MessagesSettings')
|
||||
}
|
||||
}}>
|
||||
<Menu.ItemIcon icon={SettingsIcon} />
|
||||
<Menu.ItemText>
|
||||
<Trans>Chat settings</Trans>
|
||||
</Menu.ItemText>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
</Menu.Outer>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
|
||||
import {useMessagesEventBus} from '#/state/messages/events'
|
||||
import {useUnreadCountsQuery} from '#/state/queries/messages/get-unread-counts'
|
||||
import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
|
||||
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
|
||||
import {EmptyState} from '#/view/com/util/EmptyState'
|
||||
@@ -84,16 +85,8 @@ export function MessagesInboxScreenInner({}: Props) {
|
||||
return items
|
||||
}, [data])
|
||||
|
||||
const hasUnreadConvos = useMemo(() => {
|
||||
return conversations.some(
|
||||
item =>
|
||||
item.type === 'incoming' &&
|
||||
item.view.members.every(
|
||||
member => member.handle !== 'missing.invalid',
|
||||
) &&
|
||||
item.view.unreadCount > 0,
|
||||
)
|
||||
}, [conversations])
|
||||
const {data: unreadCounts} = useUnreadCountsQuery()
|
||||
const hasUnreadConvos = (unreadCounts?.unreadRequestConvos ?? 0) > 0
|
||||
|
||||
return (
|
||||
<Layout.Screen testID="messagesInboxScreen">
|
||||
|
||||
@@ -212,7 +212,7 @@ function JoinRequestsList({
|
||||
useJoinRequestMutation('reject', convoId, {
|
||||
onSuccess: () => {
|
||||
ax.metric('groupchat:owner:joinRequest:reject', {convoId})
|
||||
Toast.show(l`Request ignored.`)
|
||||
Toast.show(l`Request rejected.`)
|
||||
if (getRemainingRequestCount() < 1) {
|
||||
navigation.replace('MessagesConversationSettings', {
|
||||
conversation: convoId,
|
||||
@@ -220,7 +220,7 @@ function JoinRequestsList({
|
||||
}
|
||||
},
|
||||
onError: error => {
|
||||
let errorMessage = l`Failed to ignore join request`
|
||||
let errorMessage = l`Failed to reject join request`
|
||||
if (isNetworkError(error)) {
|
||||
errorMessage = l`A network error occurred. Please check your internet connection.`
|
||||
} else if (
|
||||
@@ -230,7 +230,7 @@ function JoinRequestsList({
|
||||
} else if (
|
||||
error instanceof ChatBskyGroupRejectJoinRequest.InsufficientRoleError
|
||||
) {
|
||||
errorMessage = l`Only admins can ignore join requests.`
|
||||
errorMessage = l`Only admins can reject join requests.`
|
||||
}
|
||||
Toast.show(errorMessage, {type: 'error'})
|
||||
},
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
|
||||
import {
|
||||
type NotificationSettingsPreference,
|
||||
useChatNotificationSettingsQuery,
|
||||
} from '#/state/queries/notifications/settings'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {ExportCarDialog} from '#/screens/Settings/components/ExportCarDialog'
|
||||
import {ChatNotificationDialogs} from '#/screens/Settings/NotificationSettings/components/ChatNotificationDialogs'
|
||||
import {SettingPreview} from '#/screens/Settings/NotificationSettings/components/SettingPreview'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
|
||||
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
|
||||
@@ -18,7 +24,10 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
|
||||
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
|
||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import * as Skele from '#/components/Skeleton'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAgeAssurance} from '#/ageAssurance'
|
||||
@@ -52,8 +61,12 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
const {data: profile} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
const {data: chatNotificationSettings, isError: chatSettingsError} =
|
||||
useChatNotificationSettingsQuery()
|
||||
const {preferences, setPref} = useBackgroundNotificationPreferences()
|
||||
|
||||
const chatDialogControl = Dialog.useDialogControl()
|
||||
const chatRequestDialogControl = Dialog.useDialogControl()
|
||||
const exportCarControl = Dialog.useDialogControl()
|
||||
|
||||
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
|
||||
@@ -237,6 +250,54 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
<Divider style={{marginVertical: 10}} />
|
||||
</>
|
||||
) : null}
|
||||
<View style={[a.px_xl, a.gap_lg]}>
|
||||
<Text style={[a.pb_xs, a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>Notifications</Trans>
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Settings for notifications for new messages`}
|
||||
accessibilityHint={undefined}
|
||||
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
|
||||
onPress={() => {
|
||||
chatDialogControl.open()
|
||||
}}>
|
||||
<MessageIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
|
||||
<View style={[a.flex_1, a.flex_grow]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>New messages</Trans>
|
||||
</Text>
|
||||
<NotificationPreferenceSubtitle
|
||||
preference={chatNotificationSettings?.chat}
|
||||
isLoading={!chatNotificationSettings}
|
||||
isError={chatSettingsError}
|
||||
/>
|
||||
</View>
|
||||
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Settings for notifications for new message requests`}
|
||||
accessibilityHint={undefined}
|
||||
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
|
||||
onPress={() => {
|
||||
chatRequestDialogControl.open()
|
||||
}}>
|
||||
<EnvelopeIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
|
||||
<View style={[a.flex_1, a.flex_grow]}>
|
||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||
<Trans>New message requests</Trans>
|
||||
</Text>
|
||||
<NotificationPreferenceSubtitle
|
||||
preference={chatNotificationSettings?.chatRequest}
|
||||
isLoading={!chatNotificationSettings}
|
||||
isError={chatSettingsError}
|
||||
/>
|
||||
</View>
|
||||
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Divider style={{marginVertical: 10}} />
|
||||
{IS_NATIVE && (
|
||||
<>
|
||||
<View style={[a.px_xl]}>
|
||||
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
</>
|
||||
)}
|
||||
<View style={[a.px_xl]}>
|
||||
<Toggle.Item
|
||||
label={l`Export my chat data`}
|
||||
name="playSoundChat"
|
||||
value={preferences.playSoundChat}
|
||||
style={[a.flex_row, a.align_center, a.justify_between]}
|
||||
onChange={() => {
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Export my chat data`}
|
||||
accessibilityHint={undefined}
|
||||
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}
|
||||
onPress={() => {
|
||||
exportCarControl.open()
|
||||
}}>
|
||||
<CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
|
||||
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
|
||||
<Trans>Export my chat data</Trans>
|
||||
</Text>
|
||||
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
|
||||
</Toggle.Item>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Divider style={{marginVertical: 10}} />
|
||||
</View>
|
||||
</Layout.Content>
|
||||
<ChatNotificationDialogs
|
||||
chatControl={chatDialogControl}
|
||||
chatRequestControl={chatRequestDialogControl}
|
||||
/>
|
||||
<ExportCarDialog control={exportCarControl} />
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function NotificationPreferenceSubtitle({
|
||||
preference,
|
||||
isLoading,
|
||||
isError,
|
||||
}: {
|
||||
preference?: NotificationSettingsPreference
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <Skele.Text style={[a.text_sm, {width: 120}]} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
|
||||
<SettingPreview preference={preference} />
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {plural} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {UNREAD_REQUEST_CAP} from '#/state/queries/messages/get-unread-counts'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import {Inbox_Stroke2_Corner2_Rounded as InboxIcon} from '#/components/icons/Inbox'
|
||||
import {Link} from '#/components/Link'
|
||||
|
||||
// The server caps unreadRequestConvos at 11, where 11 means "any more than 10".
|
||||
const REQUEST_COUNT_CAP = 11
|
||||
|
||||
export function InboxRequests({
|
||||
count,
|
||||
variant,
|
||||
@@ -21,7 +19,7 @@ export function InboxRequests({
|
||||
const {t: l} = useLingui()
|
||||
|
||||
const unread = count > 0
|
||||
const overflow = count >= REQUEST_COUNT_CAP
|
||||
const overflow = count >= UNREAD_REQUEST_CAP
|
||||
|
||||
const label = !unread
|
||||
? l({
|
||||
@@ -30,8 +28,8 @@ export function InboxRequests({
|
||||
})
|
||||
: overflow
|
||||
? l({
|
||||
message: `10+ requests`,
|
||||
comment: 'Displayed when the number of requests is greater than 10',
|
||||
message: `${UNREAD_REQUEST_CAP - 1}+ requests`,
|
||||
comment: 'Displayed when the number of requests exceeds the cap',
|
||||
})
|
||||
: plural(count, {
|
||||
one: '# request',
|
||||
@@ -55,9 +53,9 @@ export function InboxRequests({
|
||||
<ButtonText style={[a.text_md, a.font_bold]}>
|
||||
{overflow
|
||||
? l({
|
||||
message: `10+`,
|
||||
message: `${UNREAD_REQUEST_CAP - 1}+`,
|
||||
comment:
|
||||
'Displayed when the number of requests is greater than 10',
|
||||
'Displayed when the number of requests exceeds the cap – for example, 99+ requests',
|
||||
})
|
||||
: count}
|
||||
</ButtonText>
|
||||
|
||||
@@ -77,6 +77,15 @@ export function MessageComposer({
|
||||
composerInternalApiRef.current?.input?.focus()
|
||||
}, [replyTo, composerInternalApiRef])
|
||||
|
||||
// On web, focus the input once the conversation is ready. The composer also
|
||||
// mounts during the loading state (when it isn't editable), so a mount-time
|
||||
// autoFocus would fire too early to land focus.
|
||||
useEffect(() => {
|
||||
if (IS_WEB && editable) {
|
||||
composerInternalApiRef.current?.input?.focus()
|
||||
}
|
||||
}, [editable, composerInternalApiRef])
|
||||
|
||||
// Android interactive dismiss sometimes doesn't blur the input
|
||||
const blur = useNonReactiveCallback(() => {
|
||||
composerInternalApiRef.current?.input?.blur()
|
||||
@@ -243,13 +252,12 @@ export function MessageComposer({
|
||||
placeholder={
|
||||
loading
|
||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
||||
: l({message: 'Message', context: 'action'})
|
||||
: l({message: 'Message', context: 'description'})
|
||||
}
|
||||
autocompletePlacement="top-start"
|
||||
internalApiRef={composerInternalApiRef}
|
||||
defaultValue={text}
|
||||
editable={editable}
|
||||
autoFocus={IS_WEB}
|
||||
maxRows={12}
|
||||
outerStyle={[a.flex_1]}
|
||||
contentTextStyle={[a.text_md, a.leading_snug]}
|
||||
@@ -334,8 +342,7 @@ function SubmitButton({
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: remove export when MessageInput is deleted
|
||||
export function ComposerContainer({children}: {children: React.ReactNode}) {
|
||||
function ComposerContainer({children}: {children: React.ReactNode}) {
|
||||
const {bottom: bottomInset} = useSafeAreaInsets()
|
||||
const {progress} = useReanimatedKeyboardAnimation()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
import {useCallback, useState} from 'react'
|
||||
import {Pressable, TextInput, useWindowDimensions} from 'react-native'
|
||||
import {
|
||||
useFocusedInputHandler,
|
||||
useKeyboardHandler,
|
||||
useReanimatedKeyboardAnimation,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
measure,
|
||||
runOnJS,
|
||||
useAnimatedProps,
|
||||
useAnimatedRef,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
import {GlassContainer} from 'expo-glass-effect'
|
||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
|
||||
import {HITSLOP_10, MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useHaptics} from '#/lib/haptics'
|
||||
import {useEmail} from '#/state/email-verification'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {atoms as a, platform, tokens, useTheme} from '#/alf'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {GlassView} from '#/components/GlassView'
|
||||
import {PaperPlaneVertical_Filled_Stroke2_Corner1_Rounded as PaperPlaneIcon} from '#/components/icons/PaperPlane'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
|
||||
import {ComposerContainer} from './MessageComposer'
|
||||
import {
|
||||
type MessageEmbedState,
|
||||
useExtractEmbedFromFacets,
|
||||
} from './MessageInputEmbed'
|
||||
|
||||
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)
|
||||
|
||||
const MIN_HEIGHT = 40
|
||||
|
||||
export function MessageInput({
|
||||
textInputId,
|
||||
onSendMessage,
|
||||
messageEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
loading = false,
|
||||
}: {
|
||||
textInputId?: string
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
embed?: MessageEmbedState,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => Promise<void> | void
|
||||
messageEmbed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
loading?: boolean
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const playHaptic = useHaptics()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
|
||||
// Input layout
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
const {height: keyboardHeight} = useReanimatedKeyboardAnimation()
|
||||
const maxHeight = useSharedValue<undefined | number>(undefined)
|
||||
const isInputScrollable = useSharedValue(false)
|
||||
|
||||
const [message, setMessage] = useState(getDraft)
|
||||
const inputRef = useAnimatedRef<TextInput>()
|
||||
const [shouldEnforceClear, setShouldEnforceClear] = useState(false)
|
||||
|
||||
const {needsEmailVerification} = useEmail()
|
||||
const editable = !needsEmailVerification && !loading
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!editable) {
|
||||
return
|
||||
}
|
||||
if (!messageEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(l`Message is too long`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
playHaptic()
|
||||
// Capture the embed before clearing - the deferred send below reads it.
|
||||
const embed = messageEmbed
|
||||
setEmbed(undefined)
|
||||
setMessage('')
|
||||
// Capture the reply before clearing - the deferred send below reads it.
|
||||
const reply = replyTo
|
||||
clearReply()
|
||||
if (IS_IOS) {
|
||||
setShouldEnforceClear(true)
|
||||
}
|
||||
if (IS_WEB) {
|
||||
// Pressing the send button causes the text input to lose focus, so we need to
|
||||
// re-focus it after sending
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
void onSendMessage(
|
||||
message,
|
||||
embed,
|
||||
reply
|
||||
? {...reply, $type: 'chat.bsky.convo.defs#messageView'}
|
||||
: undefined,
|
||||
)
|
||||
})
|
||||
}, [
|
||||
editable,
|
||||
messageEmbed,
|
||||
message,
|
||||
clearDraft,
|
||||
onSendMessage,
|
||||
playHaptic,
|
||||
setEmbed,
|
||||
inputRef,
|
||||
l,
|
||||
replyTo,
|
||||
clearReply,
|
||||
])
|
||||
|
||||
useFocusedInputHandler(
|
||||
{
|
||||
onChangeText: () => {
|
||||
'worklet'
|
||||
const measurement = measure(inputRef)
|
||||
if (!measurement) return
|
||||
|
||||
const max = windowHeight - -keyboardHeight.get() - topInset - 150
|
||||
const availableSpace = max - measurement.height
|
||||
|
||||
maxHeight.set(max)
|
||||
isInputScrollable.set(availableSpace < 30)
|
||||
},
|
||||
},
|
||||
[windowHeight, topInset],
|
||||
)
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
maxHeight: maxHeight.get(),
|
||||
}))
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
scrollEnabled: isInputScrollable.get(),
|
||||
}))
|
||||
|
||||
const submitDisabled =
|
||||
!editable || (!messageEmbed && message.trim().length === 0)
|
||||
|
||||
const blur = useCallback(() => {
|
||||
inputRef.current?.blur()
|
||||
}, [inputRef])
|
||||
|
||||
useKeyboardHandler({
|
||||
onEnd: evt => {
|
||||
'worklet'
|
||||
// small hack: interactive dismiss on Android sometimes doesn't blur the input
|
||||
if (IS_ANDROID && evt.progress === 0) {
|
||||
runOnJS(blur)()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<ComposerContainer>
|
||||
<GlassContainer
|
||||
style={[a.flex_row, a.align_end, a.gap_sm]}
|
||||
spacing={tokens.space.xs}>
|
||||
<GlassView
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
|
||||
tintColor={t.palette.contrast_50}
|
||||
fallbackStyle={[t.atoms.bg_contrast_50]}>
|
||||
{children}
|
||||
<AnimatedTextInput
|
||||
nativeID={textInputId}
|
||||
accessibilityLabel={l`Message input field`}
|
||||
accessibilityHint={l`Type your message here`}
|
||||
placeholder={l`Message`}
|
||||
placeholderTextColor={t.palette.contrast_500}
|
||||
value={message}
|
||||
onChange={evt => {
|
||||
// bit of a hack: iOS automatically accepts autocomplete suggestions when you tap anywhere on the screen
|
||||
// including the button we just pressed - and this overrides clearing the input! so we watch for the
|
||||
// next change and double make sure the input is cleared. It should *always* send an onChange event after
|
||||
// clearing via setMessage('') that happens in onSubmit()
|
||||
// -sfn
|
||||
if (IS_IOS && shouldEnforceClear) {
|
||||
setShouldEnforceClear(false)
|
||||
setMessage('')
|
||||
return
|
||||
}
|
||||
const text = evt.nativeEvent.text
|
||||
setMessage(text)
|
||||
}}
|
||||
multiline={true}
|
||||
style={[
|
||||
{flexBasis: 'auto', minHeight: MIN_HEIGHT},
|
||||
a.flex_shrink_0,
|
||||
a.flex_grow,
|
||||
a.text_md,
|
||||
a.px_lg,
|
||||
t.atoms.text,
|
||||
platform({
|
||||
android: {paddingTop: 2, paddingBottom: 3},
|
||||
ios: {paddingTop: 10, paddingBottom: 5},
|
||||
}),
|
||||
animatedStyle,
|
||||
]}
|
||||
verticalAlign="middle"
|
||||
keyboardAppearance={t.scheme}
|
||||
submitBehavior="newline"
|
||||
ref={inputRef}
|
||||
hitSlop={HITSLOP_10}
|
||||
animatedProps={animatedProps}
|
||||
editable={editable}
|
||||
/>
|
||||
</GlassView>
|
||||
<GlassView
|
||||
isInteractive
|
||||
glassEffectStyle="regular"
|
||||
style={[a.rounded_full]}
|
||||
tintColor={
|
||||
submitDisabled ? t.palette.contrast_100 : t.palette.primary_500
|
||||
}
|
||||
fallbackStyle={{
|
||||
backgroundColor: submitDisabled
|
||||
? t.palette.contrast_100
|
||||
: t.palette.primary_500,
|
||||
}}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
loading
|
||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
||||
: l({message: 'Message', context: 'action'})
|
||||
}
|
||||
accessibilityHint=""
|
||||
hitSlop={HITSLOP_10}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
height: MIN_HEIGHT,
|
||||
width: MIN_HEIGHT,
|
||||
},
|
||||
]}
|
||||
onPress={onSubmit}
|
||||
disabled={submitDisabled}>
|
||||
{loading ? (
|
||||
<Loader size="md" fill={t.palette.white} style={[a.mb_2xs]} />
|
||||
) : (
|
||||
<PaperPlaneIcon
|
||||
size="md"
|
||||
fill={t.palette.white}
|
||||
style={[a.mb_2xs]}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</GlassView>
|
||||
</GlassContainer>
|
||||
</ComposerContainer>
|
||||
)
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import {useCallback, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {type $Typed, type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
import {flushSync} from 'react-dom'
|
||||
import TextareaAutosize from 'react-textarea-autosize'
|
||||
import {countGraphemes} from 'unicode-segmenter/grapheme'
|
||||
|
||||
import {MAX_DM_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {
|
||||
useMessageDraft,
|
||||
useSaveMessageDraft,
|
||||
} from '#/state/messages/message-drafts'
|
||||
import {atoms as a, flatten, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import * as EmojiPicker from '#/components/EmojiPicker'
|
||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||
import * as Toast from '#/components/Toast'
|
||||
import {IS_WEB_SAFARI, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||
import {
|
||||
type MessageEmbedState,
|
||||
useExtractEmbedFromFacets,
|
||||
} from './MessageInputEmbed'
|
||||
|
||||
export function MessageInput({
|
||||
onSendMessage,
|
||||
messageEmbed,
|
||||
setEmbed,
|
||||
children,
|
||||
loading = false,
|
||||
}: {
|
||||
onSendMessage: (
|
||||
message: string,
|
||||
embed?: MessageEmbedState,
|
||||
replyTo?: $Typed<ChatBskyConvoDefs.MessageView>,
|
||||
) => void
|
||||
messageEmbed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
children?: React.ReactNode
|
||||
loading?: boolean
|
||||
}) {
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
const {getDraft, clearDraft} = useMessageDraft()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
const [message, setMessage] = useState(getDraft)
|
||||
|
||||
const inputStyles = useSharedInputStyles()
|
||||
const isComposing = useRef(false)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [textAreaHeight, setTextAreaHeight] = useState(38)
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!messageEmbed && message.trim() === '') {
|
||||
return
|
||||
}
|
||||
if (countGraphemes(message) > MAX_DM_GRAPHEME_LENGTH) {
|
||||
Toast.show(l`Message is too long`, {
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
clearDraft()
|
||||
onSendMessage(
|
||||
message,
|
||||
messageEmbed,
|
||||
replyTo
|
||||
? {...replyTo, $type: 'chat.bsky.convo.defs#messageView'}
|
||||
: undefined,
|
||||
)
|
||||
clearReply()
|
||||
setMessage('')
|
||||
setEmbed(undefined)
|
||||
}, [
|
||||
message,
|
||||
onSendMessage,
|
||||
l,
|
||||
clearDraft,
|
||||
messageEmbed,
|
||||
setEmbed,
|
||||
replyTo,
|
||||
clearReply,
|
||||
])
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Don't submit the form when the Japanese or any other IME is composing
|
||||
if (isComposing.current) return
|
||||
|
||||
// see https://github.com/bluesky-social/social-app/issues/4178
|
||||
// see https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/
|
||||
// see https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html
|
||||
//
|
||||
// On Safari, the final keydown event to dismiss the IME - which is the enter key - is also "Enter" below.
|
||||
// Obviously, this causes problems because the final dismissal should _not_ submit the text, but should just
|
||||
// stop the IME editing. This is the behavior of Chrome and Firefox, but not Safari.
|
||||
//
|
||||
// Keycode is deprecated, however the alternative seems to only be to compare the timestamp from the
|
||||
// onCompositionEnd event to the timestamp of the keydown event, which is not reliable. For example, this hack
|
||||
// uses that method: https://github.com/ProseMirror/prosemirror-view/pull/44. However, from my 500ms resulted in
|
||||
// far too long of a delay, and a subsequent enter press would often just end up doing nothing. A shorter time
|
||||
// frame was also not great, since it was too short to be reliable (i.e. an older system might have a larger
|
||||
// time gap between the two events firing.
|
||||
if (IS_WEB_SAFARI && e.key === 'Enter' && e.keyCode === 229) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
if (e.shiftKey) return
|
||||
e.preventDefault()
|
||||
onSubmit()
|
||||
}
|
||||
},
|
||||
[onSubmit],
|
||||
)
|
||||
|
||||
const onChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setMessage(e.target.value)
|
||||
}, [])
|
||||
|
||||
const onEmojiInserted = useCallback(
|
||||
(emoji: EmojiPicker.Emoji) => {
|
||||
if (!textAreaRef.current) {
|
||||
return
|
||||
}
|
||||
const position = textAreaRef.current.selectionStart ?? 0
|
||||
flushSync(() => {
|
||||
setMessage(
|
||||
message =>
|
||||
message.slice(0, position) + emoji.native + message.slice(position),
|
||||
)
|
||||
})
|
||||
textAreaRef.current.selectionStart = position + emoji.native.length
|
||||
textAreaRef.current.selectionEnd = position + emoji.native.length
|
||||
},
|
||||
[setMessage],
|
||||
)
|
||||
|
||||
useSaveMessageDraft(message)
|
||||
useExtractEmbedFromFacets(message, setEmbed)
|
||||
|
||||
return (
|
||||
<View style={a.p_sm}>
|
||||
{children}
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
paddingRight: a.p_sm.padding - 2,
|
||||
paddingLeft: a.p_sm.padding - 2,
|
||||
borderWidth: 1,
|
||||
borderRadius: 23,
|
||||
borderColor: 'transparent',
|
||||
height: textAreaHeight + 23,
|
||||
},
|
||||
isHovered && inputStyles.chromeHover,
|
||||
isFocused && inputStyles.chromeFocus,
|
||||
]}
|
||||
// @ts-expect-error web only
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
{loading ? null : (
|
||||
<EmojiPicker.Root
|
||||
onEmojiSelect={onEmojiInserted}
|
||||
nextFocusRef={textAreaRef}>
|
||||
<EmojiPicker.Trigger label={l`Open emoji picker`}>
|
||||
{({props, state}) => (
|
||||
<Button
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
marginTop: 5,
|
||||
height: 30,
|
||||
width: 30,
|
||||
},
|
||||
]}
|
||||
label={props.accessibilityLabel}
|
||||
{...props}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor:
|
||||
state.hovered || state.focused || state.pressed
|
||||
? t.atoms.bg.backgroundColor
|
||||
: undefined,
|
||||
},
|
||||
]}>
|
||||
<EmojiSmile size="lg" />
|
||||
</View>
|
||||
</Button>
|
||||
)}
|
||||
</EmojiPicker.Trigger>
|
||||
<EmojiPicker.Picker />
|
||||
</EmojiPicker.Root>
|
||||
)}
|
||||
<TextareaAutosize
|
||||
ref={textAreaRef}
|
||||
disabled={loading}
|
||||
style={flatten([
|
||||
a.flex_1,
|
||||
a.px_sm,
|
||||
a.border_0,
|
||||
t.atoms.text,
|
||||
{
|
||||
paddingTop: 10,
|
||||
backgroundColor: 'transparent',
|
||||
resize: 'none',
|
||||
},
|
||||
])}
|
||||
maxRows={12}
|
||||
placeholder={
|
||||
loading
|
||||
? l({message: 'Loading chat…', context: 'placeholder'})
|
||||
: l({message: 'Message', context: 'action'})
|
||||
}
|
||||
defaultValue=""
|
||||
value={message}
|
||||
dirName="ltr"
|
||||
autoFocus={true}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onCompositionStart={() => {
|
||||
isComposing.current = true
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
isComposing.current = false
|
||||
}}
|
||||
onHeightChange={height => setTextAreaHeight(height)}
|
||||
onChange={onChange}
|
||||
// On mobile web phones, we want to keep the same behavior as the native app. Do not submit the message
|
||||
// in these cases.
|
||||
onKeyDown={IS_WEB_TOUCH_DEVICE && isMobile ? undefined : onKeyDown}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={l`Send message`}
|
||||
accessibilityHint=""
|
||||
disabled={loading}
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
height: 30,
|
||||
width: 30,
|
||||
marginTop: 5,
|
||||
backgroundColor: t.palette.primary_500,
|
||||
},
|
||||
]}
|
||||
onPress={onSubmit}>
|
||||
<PaperPlane fill={t.palette.white} style={[a.relative, {left: 1}]} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {LayoutAnimation, View} from 'react-native'
|
||||
import {AppBskyEmbedRecord, ChatBskyEmbedJoinLink} from '@atproto/api'
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
@@ -8,6 +8,7 @@ import {useConvoActive} from '#/state/messages/convo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useMessageReplies} from '#/components/dms/MessageReplies'
|
||||
import {useReplyPreviewText} from '#/components/dms/replyPreview'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
@@ -16,15 +17,27 @@ import {Text} from '#/components/Typography'
|
||||
* being replied to, with a button to cancel the reply.
|
||||
*/
|
||||
export function MessageInputReply() {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {replyTo, clearReply} = useMessageReplies()
|
||||
const {replyTo} = useMessageReplies()
|
||||
|
||||
if (!replyTo) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <MessageInputReplyInner replyTo={replyTo} />
|
||||
}
|
||||
|
||||
function MessageInputReplyInner({
|
||||
replyTo,
|
||||
}: {
|
||||
replyTo: ChatBskyConvoDefs.MessageView
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const convo = useConvoActive()
|
||||
const {clearReply} = useMessageReplies()
|
||||
const getReplyPreviewText = useReplyPreviewText()
|
||||
const {text, subtle} = getReplyPreviewText(replyTo)
|
||||
|
||||
const onRemove = () => {
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
clearReply()
|
||||
@@ -35,19 +48,6 @@ export function MessageInputReply() {
|
||||
? createSanitizedDisplayName(senderProfile, false)
|
||||
: null
|
||||
|
||||
let text = replyTo.text
|
||||
let subtle = false
|
||||
if (!text.trim()) {
|
||||
subtle = true
|
||||
if (ChatBskyEmbedJoinLink.isView(replyTo.embed)) {
|
||||
text = l`(chat invite link)`
|
||||
} else if (AppBskyEmbedRecord.isView(replyTo.embed)) {
|
||||
text = l`(contains embedded content)`
|
||||
} else {
|
||||
text = l`No text`
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -82,7 +82,7 @@ export function MessageInputReply() {
|
||||
<Button
|
||||
label={l`Cancel reply`}
|
||||
onPress={onRemove}
|
||||
style={[a.px_2xs]}
|
||||
style={[a.px_2xs, {transform: [{translateX: 2}]}]}
|
||||
hitSlop={HITSLOP_20}>
|
||||
<XIcon size="xs" style={t.atoms.text_contrast_high} />
|
||||
</Button>
|
||||
|
||||
@@ -57,7 +57,6 @@ import {createEmbedViewRecordFromPost} from '#/state/queries/postgate/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {List, type ListMethods} from '#/view/com/util/List'
|
||||
import {MessageComposer} from '#/screens/Messages/components/MessageComposer'
|
||||
import {MessageInput} from '#/screens/Messages/components/MessageInput'
|
||||
import {MessageListError} from '#/screens/Messages/components/MessageListError'
|
||||
import {atoms as a, platform, tokens, useTheme, web} from '#/alf'
|
||||
import {DateDivider} from '#/components/dms/DateDivider'
|
||||
@@ -704,9 +703,6 @@ export function MessagesList({
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}
|
||||
useNewComposer={ax.features.enabled(
|
||||
ax.features.DmsNewMessageComposerEnable,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ConversationFooter>
|
||||
@@ -735,7 +731,6 @@ function Composer({
|
||||
messageEmbed,
|
||||
setEmbed,
|
||||
loading,
|
||||
useNewComposer,
|
||||
}: {
|
||||
textInputId: string
|
||||
onSendMessage: (
|
||||
@@ -746,7 +741,6 @@ function Composer({
|
||||
messageEmbed: MessageEmbedState | undefined
|
||||
setEmbed: (embedUrl: string | undefined) => void
|
||||
loading?: boolean
|
||||
useNewComposer: boolean
|
||||
}) {
|
||||
const handleSendMessage = useNonReactiveCallback(
|
||||
(
|
||||
@@ -758,31 +752,16 @@ function Composer({
|
||||
},
|
||||
)
|
||||
|
||||
const previews = (
|
||||
<>
|
||||
<MessageInputReply />
|
||||
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
|
||||
</>
|
||||
)
|
||||
|
||||
return useNewComposer ? (
|
||||
return (
|
||||
<MessageComposer
|
||||
textInputId={textInputId}
|
||||
onSendMessage={handleSendMessage}
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
{previews}
|
||||
<MessageInputReply />
|
||||
<MessageInputEmbed embed={messageEmbed} setEmbed={setEmbed} />
|
||||
</MessageComposer>
|
||||
) : (
|
||||
<MessageInput
|
||||
textInputId={textInputId}
|
||||
onSendMessage={handleSendMessage}
|
||||
messageEmbed={messageEmbed}
|
||||
setEmbed={setEmbed}
|
||||
loading={loading}>
|
||||
{previews}
|
||||
</MessageInput>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,10 @@ export function HeaderLabelerButtons({
|
||||
testID="profileHeaderEditProfileButton"
|
||||
size="small"
|
||||
color="secondary"
|
||||
onPress={editProfileControl.open}
|
||||
onPress={() => {
|
||||
playHaptic('Light')
|
||||
editProfileControl.open()
|
||||
}}
|
||||
label={_(msg`Edit profile`)}
|
||||
style={a.rounded_full}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
|
||||
import type * as Dialog from '#/components/Dialog'
|
||||
import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSettingsDialog'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||
|
||||
export function ChatNotificationDialogs({
|
||||
chatControl,
|
||||
chatRequestControl,
|
||||
}: {
|
||||
chatControl: Dialog.DialogControlProps
|
||||
chatRequestControl: Dialog.DialogControlProps
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<NotificationSettingsDialog
|
||||
control={chatControl}
|
||||
name="chat"
|
||||
icon={MessageIcon}
|
||||
titleText={<Trans>New messages</Trans>}
|
||||
subtitleText={
|
||||
<Trans>Get notifications when people send you messages.</Trans>
|
||||
}
|
||||
allowDisableInApp={false}
|
||||
/>
|
||||
<NotificationSettingsDialog
|
||||
control={chatRequestControl}
|
||||
name="chatRequest"
|
||||
icon={EnvelopeIcon}
|
||||
titleText={<Trans>New message requests</Trans>}
|
||||
subtitleText={
|
||||
<Trans>
|
||||
Get notifications when people send you message requests.
|
||||
</Trans>
|
||||
}
|
||||
allowDisableInApp={false}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import {useMemo} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
|
||||
import {
|
||||
type NotificationSettingsPreference,
|
||||
type NotificationSettingsPreferenceName,
|
||||
useNotificationSettingsUpdateMutation,
|
||||
} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a, platform, useTheme} from '#/alf'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
import {Loader} from '#/components/Loader'
|
||||
@@ -17,16 +20,13 @@ export function PreferenceControls({
|
||||
preference,
|
||||
allowDisableInApp = true,
|
||||
}: {
|
||||
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
|
||||
name: NotificationSettingsPreferenceName
|
||||
/**
|
||||
* Keep other prefs in sync with `name`. For use in the "everything else" category
|
||||
* which groups starterpack joins + verified + unverified notifications into a single toggle.
|
||||
*/
|
||||
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
|
||||
preference?:
|
||||
| AppBskyNotificationDefs.Preference
|
||||
| AppBskyNotificationDefs.FilterablePreference
|
||||
| AppBskyNotificationDefs.ChatPreference
|
||||
syncOthers?: NotificationSettingsPreferenceName[]
|
||||
preference?: NotificationSettingsPreference
|
||||
allowDisableInApp?: boolean
|
||||
}) {
|
||||
if (!preference)
|
||||
@@ -52,12 +52,9 @@ export function Inner({
|
||||
preference,
|
||||
allowDisableInApp,
|
||||
}: {
|
||||
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
|
||||
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
|
||||
preference:
|
||||
| AppBskyNotificationDefs.Preference
|
||||
| AppBskyNotificationDefs.FilterablePreference
|
||||
| AppBskyNotificationDefs.ChatPreference
|
||||
name: NotificationSettingsPreferenceName
|
||||
syncOthers?: NotificationSettingsPreferenceName[]
|
||||
preference: NotificationSettingsPreference
|
||||
allowDisableInApp: boolean
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -95,7 +92,7 @@ export function Inner({
|
||||
}
|
||||
|
||||
const onChangeFilter = ([change]: string[]) => {
|
||||
if (change !== 'all' && change !== 'follows' && change !== 'accepted')
|
||||
if (change !== 'all' && change !== 'follows')
|
||||
throw new Error('Invalid filter')
|
||||
|
||||
const newPreference = {
|
||||
@@ -135,7 +132,7 @@ export function Inner({
|
||||
</Toggle.LabelText>
|
||||
<Toggle.Platform />
|
||||
</Toggle.Item>
|
||||
{allowDisableInApp && (
|
||||
{allowDisableInApp && 'list' in preference && (
|
||||
<Toggle.Item
|
||||
label={l`Receive in-app notifications`}
|
||||
name="list"
|
||||
@@ -176,31 +173,17 @@ export function Inner({
|
||||
/>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
{name === 'chat' ? (
|
||||
<Toggle.Item
|
||||
highlightRow
|
||||
label={l`Accepted conversations`}
|
||||
name="accepted">
|
||||
{({selected}) => (
|
||||
<Toggle.RadioWithLabel
|
||||
label={l`Accepted conversations`}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
) : (
|
||||
<Toggle.Item
|
||||
highlightRow
|
||||
label={l`People I follow`}
|
||||
name="follows">
|
||||
{({selected}) => (
|
||||
<Toggle.RadioWithLabel
|
||||
label={l`People I follow`}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
)}
|
||||
<Toggle.Item
|
||||
highlightRow
|
||||
label={l`People I follow`}
|
||||
name="follows">
|
||||
{({selected}) => (
|
||||
<Toggle.RadioWithLabel
|
||||
label={l`People I follow`}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</Toggle.Item>
|
||||
</View>
|
||||
</Toggle.Group>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {type NotificationSettingsPreference} from '#/state/queries/notifications/settings'
|
||||
|
||||
export function SettingPreview({
|
||||
preference,
|
||||
}: {
|
||||
preference?: NotificationSettingsPreference
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
|
||||
if (!preference) {
|
||||
return null
|
||||
}
|
||||
|
||||
if ('include' in preference) {
|
||||
const list = 'list' in preference && preference.list
|
||||
|
||||
if (preference.include === 'all') {
|
||||
if (list && preference.push) return l`In-app, push, everyone`
|
||||
if (list) return l`In-app, everyone`
|
||||
if (preference.push) return l`Push, everyone`
|
||||
} else if (preference.include === 'follows') {
|
||||
if (list && preference.push) return l`In-app, push, people you follow`
|
||||
if (list) return l`In-app, people you follow`
|
||||
if (preference.push) return l`Push, people you follow`
|
||||
}
|
||||
} else {
|
||||
if (preference.list && preference.push) return l`In-app, push`
|
||||
if (preference.list) return l`In-app`
|
||||
if (preference.push) return l`Push`
|
||||
}
|
||||
|
||||
return l`Off`
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import {useEffect} from 'react'
|
||||
import {Linking, View} from 'react-native'
|
||||
import * as Notification from 'expo-notifications'
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
import {useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
@@ -10,7 +9,10 @@ import {
|
||||
type AllNavigatorParams,
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
|
||||
import {
|
||||
useChatNotificationSettingsQuery,
|
||||
useNotificationSettingsQuery,
|
||||
} from '#/state/queries/notifications/settings'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Admonition} from '#/components/Admonition'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
@@ -18,11 +20,13 @@ import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSetti
|
||||
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
|
||||
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
|
||||
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
|
||||
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
|
||||
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
|
||||
import {
|
||||
Heart2_Stroke2_Corner0_Rounded as HeartIcon,
|
||||
LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
|
||||
} from '#/components/icons/Heart2'
|
||||
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
|
||||
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
|
||||
import {
|
||||
@@ -33,7 +37,9 @@ import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/S
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
|
||||
import * as SettingsList from '../components/SettingsList'
|
||||
import {ChatNotificationDialogs} from './components/ChatNotificationDialogs'
|
||||
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
|
||||
import {SettingPreview} from './components/SettingPreview'
|
||||
|
||||
const RQKEY = ['notification-permissions']
|
||||
|
||||
@@ -42,6 +48,8 @@ export function NotificationSettingsScreen({}: Props) {
|
||||
const {t: l} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
const {data: settings, isError} = useNotificationSettingsQuery()
|
||||
const {data: chatSettings, isError: chatError} =
|
||||
useChatNotificationSettingsQuery()
|
||||
|
||||
const likeDialogControl = Dialog.useDialogControl()
|
||||
const followDialogControl = Dialog.useDialogControl()
|
||||
@@ -52,6 +60,8 @@ export function NotificationSettingsScreen({}: Props) {
|
||||
const activityDialogControl = Dialog.useDialogControl()
|
||||
const likeRepostDialogControl = Dialog.useDialogControl()
|
||||
const repostRepostDialogControl = Dialog.useDialogControl()
|
||||
const chatDialogControl = Dialog.useDialogControl()
|
||||
const chatRequestDialogControl = Dialog.useDialogControl()
|
||||
const miscDialogControl = Dialog.useDialogControl()
|
||||
|
||||
const {data: permissions, refetch} = useQuery({
|
||||
@@ -234,6 +244,40 @@ export function NotificationSettingsScreen({}: Props) {
|
||||
showSkeleton={!settings}
|
||||
/>
|
||||
</SettingsList.PressableItem>
|
||||
<SettingsList.PressableItem
|
||||
label={l`Settings for notifications for new messages`}
|
||||
onPress={chatDialogControl.open}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={MessageIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>New messages</Trans>}
|
||||
subtitleText={
|
||||
chatError ? (
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
) : (
|
||||
<SettingPreview preference={chatSettings?.chat} />
|
||||
)
|
||||
}
|
||||
showSkeleton={!chatSettings && !chatError}
|
||||
/>
|
||||
</SettingsList.PressableItem>
|
||||
<SettingsList.PressableItem
|
||||
label={l`Settings for notifications for new message requests`}
|
||||
onPress={chatRequestDialogControl.open}
|
||||
contentContainerStyle={[a.align_start]}>
|
||||
<SettingsList.ItemIcon icon={EnvelopeIcon} />
|
||||
<ItemTextWithSubtitle
|
||||
titleText={<Trans>New message requests</Trans>}
|
||||
subtitleText={
|
||||
chatError ? (
|
||||
<Trans>Failed to load notification settings.</Trans>
|
||||
) : (
|
||||
<SettingPreview preference={chatSettings?.chatRequest} />
|
||||
)
|
||||
}
|
||||
showSkeleton={!chatSettings && !chatError}
|
||||
/>
|
||||
</SettingsList.PressableItem>
|
||||
<SettingsList.PressableItem
|
||||
label={l`Settings for notifications for everything else`}
|
||||
onPress={miscDialogControl.open}
|
||||
@@ -333,6 +377,10 @@ export function NotificationSettingsScreen({}: Props) {
|
||||
<Trans>Get notifications when people repost your reposts.</Trans>
|
||||
}
|
||||
/>
|
||||
<ChatNotificationDialogs
|
||||
chatControl={chatDialogControl}
|
||||
chatRequestControl={chatRequestDialogControl}
|
||||
/>
|
||||
<NotificationSettingsDialog
|
||||
control={miscDialogControl}
|
||||
name="starterpackJoined"
|
||||
@@ -350,46 +398,3 @@ export function NotificationSettingsScreen({}: Props) {
|
||||
</Layout.Screen>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingPreview({
|
||||
preference,
|
||||
}: {
|
||||
preference?:
|
||||
| AppBskyNotificationDefs.Preference
|
||||
| AppBskyNotificationDefs.FilterablePreference
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
if (!preference) {
|
||||
return null
|
||||
} else {
|
||||
if ('include' in preference) {
|
||||
if (preference.include === 'all') {
|
||||
if (preference.list && preference.push) {
|
||||
return l`In-app, push, everyone`
|
||||
} else if (preference.list) {
|
||||
return l`In-app, everyone`
|
||||
} else if (preference.push) {
|
||||
return l`Push, everyone`
|
||||
}
|
||||
} else if (preference.include === 'follows') {
|
||||
if (preference.list && preference.push) {
|
||||
return l`In-app, push, people you follow`
|
||||
} else if (preference.list) {
|
||||
return l`In-app, people you follow`
|
||||
} else if (preference.push) {
|
||||
return l`Push, people you follow`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (preference.list && preference.push) {
|
||||
return l`In-app, push`
|
||||
} else if (preference.list) {
|
||||
return l`In-app`
|
||||
} else if (preference.push) {
|
||||
return l`Push`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return l`Off`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {AppState, type AppStateStatus, View} from 'react-native'
|
||||
import ReactNativeDeviceAttest from 'react-native-device-attest'
|
||||
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
|
||||
import {AppBskyGraphStarterpack} from '@atproto/api'
|
||||
import {tokens} from '@bsky.app/alf'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {FEEDBACK_FORM_URL} from '#/lib/constants'
|
||||
@@ -222,20 +223,27 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
||||
a.align_center,
|
||||
]}>
|
||||
<AppLanguageDropdown />
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
t.atoms.text_contrast_medium,
|
||||
!gtMobile && a.text_md,
|
||||
]}>
|
||||
<Trans>Having trouble?</Trans>{' '}
|
||||
<InlineLinkText
|
||||
label={l`Contact support`}
|
||||
to={FEEDBACK_FORM_URL({email: state.email})}
|
||||
style={[!gtMobile && a.text_md]}>
|
||||
<Trans>Contact support</Trans>
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
<View
|
||||
style={
|
||||
gtMobile
|
||||
? [a.flex_1, a.flex, a.flex_row, a.justify_end]
|
||||
: []
|
||||
}>
|
||||
<Text
|
||||
style={[
|
||||
t.atoms.text_contrast_medium,
|
||||
!gtMobile && a.text_md,
|
||||
{paddingInline: tokens.space.sm},
|
||||
]}>
|
||||
<Trans>Having trouble?</Trans>{' '}
|
||||
<InlineLinkText
|
||||
label={l`Contact support`}
|
||||
to={FEEDBACK_FORM_URL({email: state.email})}
|
||||
style={[!gtMobile && a.text_md]}>
|
||||
<Trans>Contact support</Trans>
|
||||
</InlineLinkText>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScreenTransition>
|
||||
|
||||
@@ -132,6 +132,12 @@ export function ConvoProvider({
|
||||
useEffect(() => {
|
||||
const [root, id] = getConvoKey(convoId)
|
||||
return queryClient.getQueryCache().subscribe(event => {
|
||||
// Only react to data updates. Other event types (e.g. `added`) can be
|
||||
// emitted synchronously while another component reads this same query
|
||||
// during its render (React Query builds the query in `getOptimisticResult`),
|
||||
// and committing to the convo store then would set state on this provider
|
||||
// mid-render of that component.
|
||||
if (event.type !== 'updated') return
|
||||
const queryKey = event.query.queryKey as string[]
|
||||
if (queryKey[0] === root && queryKey[1] === id) {
|
||||
const data = event.query.state.data as
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type ChatBskyActorDefs,
|
||||
type ChatBskyConvoDefs,
|
||||
type ChatBskyConvoGetConvo,
|
||||
type ChatBskyConvoGetUnreadCounts,
|
||||
} from '@atproto/api'
|
||||
import {
|
||||
type QueryClient,
|
||||
@@ -14,6 +15,11 @@ import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {STALE} from '#/state/queries'
|
||||
import {useOnMarkAsRead} from '#/state/queries/messages/list-conversations'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {
|
||||
RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY,
|
||||
UNREAD_ACCEPTED_CAP,
|
||||
UNREAD_REQUEST_CAP,
|
||||
} from './get-unread-counts'
|
||||
import {
|
||||
type ConvoListQueryData,
|
||||
getConvoFromQueryData,
|
||||
@@ -74,11 +80,81 @@ export function useMarkAsReadMutation() {
|
||||
},
|
||||
onMutate({convoId}) {
|
||||
if (!convoId) throw new Error('No convoId provided')
|
||||
|
||||
// snapshot the list caches before the optimistic update so onError can
|
||||
// restore the convo rows alongside the badge count
|
||||
const prevListQueries = queryClient.getQueriesData<ConvoListQueryData>({
|
||||
queryKey: [LIST_CONVOS_KEY],
|
||||
})
|
||||
|
||||
// find the convo so we know which badge counter (if any) to decrement.
|
||||
// keep scanning past a stale unreadCount === 0 cache so another cache
|
||||
// holding the true unread state still drives the decrement
|
||||
let unreadStatus: ChatBskyConvoDefs.ConvoView['status'] | undefined
|
||||
for (const [, data] of prevListQueries) {
|
||||
if (!data) continue
|
||||
const convo = getConvoFromQueryData(convoId, data)
|
||||
if (convo?.unreadCount) {
|
||||
unreadStatus = convo.status
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
optimisticUpdate(convoId)
|
||||
|
||||
// the badge count query is a separate server query that the list caches
|
||||
// don't feed, so decrement it here to keep the badge in sync
|
||||
const prevUnreadCountsQueries =
|
||||
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
|
||||
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
|
||||
})
|
||||
if (unreadStatus) {
|
||||
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
|
||||
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
|
||||
old => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
...(unreadStatus === 'request'
|
||||
? {
|
||||
unreadRequestConvos:
|
||||
old.unreadRequestConvos >= UNREAD_REQUEST_CAP
|
||||
? old.unreadRequestConvos
|
||||
: Math.max(0, old.unreadRequestConvos - 1),
|
||||
}
|
||||
: {
|
||||
unreadAcceptedConvos:
|
||||
old.unreadAcceptedConvos >= UNREAD_ACCEPTED_CAP
|
||||
? old.unreadAcceptedConvos
|
||||
: Math.max(0, old.unreadAcceptedConvos - 1),
|
||||
}),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return {prevListQueries, prevUnreadCountsQueries}
|
||||
},
|
||||
onError(_, __, context) {
|
||||
if (context?.prevListQueries) {
|
||||
for (const [queryKey, prevData] of context.prevListQueries) {
|
||||
queryClient.setQueryData(queryKey, prevData)
|
||||
}
|
||||
}
|
||||
if (context?.prevUnreadCountsQueries) {
|
||||
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
|
||||
queryClient.setQueryData(queryKey, prevData)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess(_, {convoId}) {
|
||||
if (!convoId) return
|
||||
|
||||
// the optimistic badge arithmetic can drift from the server (e.g. a convo
|
||||
// whose status differs between caches, or a sentinel-capped count). invalidate
|
||||
// so the 15s-stale count query self-corrects on next access rather than
|
||||
// waiting for a log event
|
||||
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
|
||||
|
||||
queryClient.setQueriesData(
|
||||
{queryKey: [LIST_CONVOS_KEY]},
|
||||
(old?: ConvoListQueryData) => {
|
||||
|
||||
@@ -10,6 +10,13 @@ export const RQKEY = (includeGroupChats: boolean) =>
|
||||
[RQKEY_ROOT, includeGroupChats] as const
|
||||
export const RQKEY_PARTIAL = [RQKEY_ROOT] as const
|
||||
|
||||
// the server sentinel-caps the badge counts: unreadAcceptedConvos and
|
||||
// unreadRequestConvos max out at 100 (meaning "more than 99"). at the cap the
|
||||
// value is no longer an exact count, so consumers must not treat it as one -
|
||||
// both the optimistic decrement and the badge display ceiling key off these.
|
||||
export const UNREAD_ACCEPTED_CAP = 100
|
||||
export const UNREAD_REQUEST_CAP = 100
|
||||
|
||||
export function useUnreadCountsQuery() {
|
||||
const agent = useAgent()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as bsky from '#/types/bsky'
|
||||
import {RQKEY as CONVO_KEY} from './conversation'
|
||||
import {
|
||||
RQKEY_PARTIAL as UNREAD_COUNTS_RQKEY_PARTIAL,
|
||||
UNREAD_ACCEPTED_CAP,
|
||||
useUnreadCountsQuery,
|
||||
} from './get-unread-counts'
|
||||
import {
|
||||
@@ -855,10 +856,16 @@ export function useUnreadMessageCount(): {
|
||||
const request = data?.unreadRequestConvos ?? 0
|
||||
|
||||
if (accepted > 0) {
|
||||
const total = accepted + Math.min(request, 1)
|
||||
return {
|
||||
count: total,
|
||||
numUnread: total > 10 ? '10+' : String(total),
|
||||
count: accepted,
|
||||
// accepted is sentinel-capped at UNREAD_ACCEPTED_CAP (meaning "more than
|
||||
// cap - 1"). show the "+" overflow label only when accepted is actually
|
||||
// capped, otherwise clamp the number to cap - 1 so we never surface the
|
||||
// sentinel value (100) itself
|
||||
numUnread:
|
||||
accepted >= UNREAD_ACCEPTED_CAP
|
||||
? `${UNREAD_ACCEPTED_CAP - 1}+`
|
||||
: String(Math.min(accepted, UNREAD_ACCEPTED_CAP - 1)),
|
||||
// only needed when numUnread is undefined
|
||||
hasNew: false,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {type ChatBskyConvoGetUnreadCounts} from '@atproto/api'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import {RQKEY_PARTIAL as UNREAD_COUNTS_PARTIAL_KEY} from './get-unread-counts'
|
||||
import {
|
||||
type ConvoRequestListQueryData,
|
||||
markAllRead as markAllRequestsRead,
|
||||
@@ -94,10 +96,36 @@ export function useUpdateAllRead(
|
||||
markAllRequestsRead,
|
||||
)
|
||||
}
|
||||
// zero out the badge count query that actually drives the unread badge,
|
||||
// since it's a separate server query that the list caches don't feed
|
||||
const prevUnreadCountsQueries =
|
||||
queryClient.getQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>({
|
||||
queryKey: UNREAD_COUNTS_PARTIAL_KEY,
|
||||
})
|
||||
queryClient.setQueriesData<ChatBskyConvoGetUnreadCounts.OutputSchema>(
|
||||
{queryKey: UNREAD_COUNTS_PARTIAL_KEY},
|
||||
old => {
|
||||
if (!old) return old
|
||||
return {
|
||||
...old,
|
||||
...(status === 'accepted'
|
||||
? {unreadAcceptedConvos: 0}
|
||||
: {unreadRequestConvos: 0}),
|
||||
}
|
||||
},
|
||||
)
|
||||
onMutate?.()
|
||||
return {prevConvoListQueries, prevRequestsQueries}
|
||||
return {
|
||||
prevConvoListQueries,
|
||||
prevRequestsQueries,
|
||||
prevUnreadCountsQueries,
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
// the optimistic badge zeroing can drift from the server, so invalidate
|
||||
// the count query to let it self-correct on next access rather than
|
||||
// waiting for a log event
|
||||
void queryClient.invalidateQueries({queryKey: UNREAD_COUNTS_PARTIAL_KEY})
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: CONVO_LIST_PARTIAL_KEY(status),
|
||||
})
|
||||
@@ -121,6 +149,11 @@ export function useUpdateAllRead(
|
||||
queryClient.setQueryData(queryKey, prevData)
|
||||
}
|
||||
}
|
||||
if (context?.prevUnreadCountsQueries) {
|
||||
for (const [queryKey, prevData] of context.prevUnreadCountsQueries) {
|
||||
queryClient.setQueryData(queryKey, prevData)
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
|
||||
if (status === 'request') {
|
||||
void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {type AppBskyNotificationDefs} from '@atproto/api'
|
||||
import {
|
||||
type AppBskyNotificationDefs,
|
||||
type ChatBskyNotificationDefs,
|
||||
} from '@atproto/api'
|
||||
import {t} from '@lingui/core/macro'
|
||||
import {
|
||||
type QueryClient,
|
||||
@@ -7,12 +10,59 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
|
||||
import {DM_SERVICE_HEADERS} from '#/lib/constants'
|
||||
import {logger} from '#/logger'
|
||||
import {useAgent} from '#/state/session'
|
||||
import * as Toast from '#/components/Toast'
|
||||
|
||||
const RQKEY_ROOT = 'notification-settings'
|
||||
const RQKEY = [RQKEY_ROOT]
|
||||
const RQKEY_APP = [RQKEY_ROOT, 'app']
|
||||
const RQKEY_CHAT = [RQKEY_ROOT, 'chat']
|
||||
|
||||
// App notification preferences live on the appview. Chat preferences live on a
|
||||
// separate chat service proxy that can be up or down independently, so they are
|
||||
// fetched and cached separately. This combined type names every preference for
|
||||
// the generic settings dialog, but it is never the shape of a query response.
|
||||
export type NotificationSettingsPreferences = Omit<
|
||||
AppBskyNotificationDefs.Preferences,
|
||||
'chat'
|
||||
> &
|
||||
Partial<Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'>>
|
||||
|
||||
export type AppNotificationSettingsPreferences = Omit<
|
||||
AppBskyNotificationDefs.Preferences,
|
||||
'chat'
|
||||
>
|
||||
|
||||
export type ChatNotificationSettingsPreferences = Pick<
|
||||
ChatBskyNotificationDefs.Preferences,
|
||||
'chat' | 'chatRequest'
|
||||
>
|
||||
|
||||
export type NotificationSettingsPreferenceName = Exclude<
|
||||
keyof NotificationSettingsPreferences,
|
||||
'$type'
|
||||
>
|
||||
|
||||
export type NotificationSettingsPreference =
|
||||
| AppBskyNotificationDefs.Preference
|
||||
| AppBskyNotificationDefs.FilterablePreference
|
||||
| ChatBskyNotificationDefs.ChatPreference
|
||||
|
||||
export function isChatPreferenceName(
|
||||
name: NotificationSettingsPreferenceName,
|
||||
): name is 'chat' | 'chatRequest' {
|
||||
return name === 'chat' || name === 'chatRequest'
|
||||
}
|
||||
|
||||
type NotificationSettingsUpdate = Partial<NotificationSettingsPreferences>
|
||||
|
||||
type AppNotificationSettingsUpdate = Partial<
|
||||
Omit<AppBskyNotificationDefs.Preferences, '$type' | 'chat'>
|
||||
>
|
||||
|
||||
type ChatNotificationSettingsUpdate =
|
||||
Partial<ChatNotificationSettingsPreferences>
|
||||
|
||||
export function useNotificationSettingsQuery({
|
||||
enabled,
|
||||
@@ -20,10 +70,27 @@ export function useNotificationSettingsQuery({
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: RQKEY,
|
||||
queryFn: async () => {
|
||||
const response = await agent.app.bsky.notification.getPreferences()
|
||||
return response.data.preferences
|
||||
queryKey: RQKEY_APP,
|
||||
queryFn: async (): Promise<AppNotificationSettingsPreferences> => {
|
||||
const res = await agent.app.bsky.notification.getPreferences()
|
||||
return appPreferencesWithoutChat(res.data.preferences)
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export function useChatNotificationSettingsQuery({
|
||||
enabled,
|
||||
}: {enabled?: boolean} = {}) {
|
||||
const agent = useAgent()
|
||||
|
||||
return useQuery({
|
||||
queryKey: RQKEY_CHAT,
|
||||
queryFn: async (): Promise<ChatNotificationSettingsPreferences> => {
|
||||
const res = await agent.chat.bsky.notification.getPreferences(undefined, {
|
||||
headers: DM_SERVICE_HEADERS,
|
||||
})
|
||||
return chatPreferencesForSettings(res.data.preferences)
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
@@ -33,19 +100,27 @@ export function useNotificationSettingsUpdateMutation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
update: Partial<AppBskyNotificationDefs.Preferences>,
|
||||
) => {
|
||||
const response =
|
||||
await agent.app.bsky.notification.putPreferencesV2(update)
|
||||
return response.data.preferences
|
||||
mutationFn: async (update: NotificationSettingsUpdate) => {
|
||||
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
|
||||
await Promise.all([
|
||||
hasUpdates(appUpdate)
|
||||
? agent.app.bsky.notification.putPreferencesV2(appUpdate)
|
||||
: undefined,
|
||||
hasUpdates(chatUpdate)
|
||||
? agent.chat.bsky.notification.putPreferences(chatUpdate, {
|
||||
headers: DM_SERVICE_HEADERS,
|
||||
encoding: 'application/json',
|
||||
})
|
||||
: undefined,
|
||||
])
|
||||
},
|
||||
onMutate: update => {
|
||||
optimisticUpdateNotificationSettings(queryClient, update)
|
||||
},
|
||||
onError: e => {
|
||||
logger.error('Could not update notification settings', {message: e})
|
||||
void queryClient.invalidateQueries({queryKey: RQKEY})
|
||||
void queryClient.invalidateQueries({queryKey: RQKEY_APP})
|
||||
void queryClient.invalidateQueries({queryKey: RQKEY_CHAT})
|
||||
Toast.show(t`Could not update notification settings`, {
|
||||
type: 'error',
|
||||
})
|
||||
@@ -55,13 +130,62 @@ export function useNotificationSettingsUpdateMutation() {
|
||||
|
||||
function optimisticUpdateNotificationSettings(
|
||||
queryClient: QueryClient,
|
||||
update: Partial<AppBskyNotificationDefs.Preferences>,
|
||||
update: NotificationSettingsUpdate,
|
||||
) {
|
||||
queryClient.setQueryData(
|
||||
RQKEY,
|
||||
(old?: AppBskyNotificationDefs.Preferences) => {
|
||||
if (!old) return old
|
||||
return {...old, ...update}
|
||||
},
|
||||
)
|
||||
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
|
||||
|
||||
if (hasUpdates(appUpdate)) {
|
||||
queryClient.setQueryData(
|
||||
RQKEY_APP,
|
||||
(old?: AppNotificationSettingsPreferences) => {
|
||||
if (!old) return old
|
||||
return {...old, ...appUpdate}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (hasUpdates(chatUpdate)) {
|
||||
queryClient.setQueryData(
|
||||
RQKEY_CHAT,
|
||||
(old?: ChatNotificationSettingsPreferences) => {
|
||||
if (!old) return old
|
||||
return {...old, ...chatUpdate}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function appPreferencesWithoutChat(
|
||||
preferences: AppBskyNotificationDefs.Preferences,
|
||||
): Omit<AppBskyNotificationDefs.Preferences, 'chat'> {
|
||||
const {chat: _ignoredChat, ...appPreferences} = preferences
|
||||
return appPreferences
|
||||
}
|
||||
|
||||
function chatPreferencesForSettings(
|
||||
preferences: ChatBskyNotificationDefs.Preferences,
|
||||
): Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'> {
|
||||
return {
|
||||
chat: preferences.chat,
|
||||
chatRequest: preferences.chatRequest,
|
||||
}
|
||||
}
|
||||
|
||||
function splitNotificationSettingsUpdate(update: NotificationSettingsUpdate): {
|
||||
appUpdate: AppNotificationSettingsUpdate
|
||||
chatUpdate: ChatNotificationSettingsUpdate
|
||||
} {
|
||||
const {chat, chatRequest, $type: _type, ...appUpdate} = update
|
||||
|
||||
return {
|
||||
appUpdate: appUpdate,
|
||||
chatUpdate: {
|
||||
...(chat !== undefined ? {chat} : {}),
|
||||
...(chatRequest !== undefined ? {chatRequest} : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hasUpdates(update: object) {
|
||||
return Object.keys(update).length > 0
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} from '#/lib/constants'
|
||||
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {mimeToExt} from '#/lib/media/video/util'
|
||||
import {useCallOnce} from '#/lib/once'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
@@ -179,7 +180,7 @@ import {
|
||||
type VideoState,
|
||||
} from './state/video'
|
||||
import {type TextInputRef} from './text-input/TextInput.types'
|
||||
import {getVideoMetadata} from './videos/pickVideo'
|
||||
import {getVideoMetadata} from './videos/metadata'
|
||||
import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
|
||||
|
||||
type CancelRef = {
|
||||
@@ -387,6 +388,12 @@ export const ComposePost = ({
|
||||
const selectVideo = useCallback(
|
||||
(postId: string, asset: ImagePickerAsset) => {
|
||||
const abortController = new AbortController()
|
||||
const telemetry = createVideoTelemetry({
|
||||
asset,
|
||||
signal: abortController.signal,
|
||||
metric: ax.metric,
|
||||
})
|
||||
telemetry.picked()
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId: postId,
|
||||
@@ -394,6 +401,7 @@ export const ComposePost = ({
|
||||
type: 'embed_add_video',
|
||||
asset,
|
||||
abortController,
|
||||
telemetry,
|
||||
},
|
||||
})
|
||||
void processVideo(
|
||||
@@ -412,9 +420,10 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
telemetry,
|
||||
)
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch, ax.metric],
|
||||
)
|
||||
|
||||
const onInitVideo = useNonReactiveCallback(() => {
|
||||
@@ -494,6 +503,12 @@ export const ComposePost = ({
|
||||
|
||||
// Start video processing using existing flow
|
||||
const abortController = new AbortController()
|
||||
const telemetry = createVideoTelemetry({
|
||||
asset,
|
||||
signal: abortController.signal,
|
||||
metric: ax.metric,
|
||||
})
|
||||
telemetry.picked()
|
||||
composerDispatch({
|
||||
type: 'update_post',
|
||||
postId,
|
||||
@@ -501,6 +516,7 @@ export const ComposePost = ({
|
||||
type: 'embed_add_video',
|
||||
asset,
|
||||
abortController,
|
||||
telemetry,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -559,6 +575,7 @@ export const ComposePost = ({
|
||||
currentDid,
|
||||
abortController.signal,
|
||||
i18n,
|
||||
telemetry,
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error('Failed to restore video from draft', {
|
||||
@@ -567,7 +584,7 @@ export const ComposePost = ({
|
||||
})
|
||||
}
|
||||
},
|
||||
[i18n, agent, currentDid, composerDispatch],
|
||||
[i18n, agent, currentDid, composerDispatch, ax.metric],
|
||||
)
|
||||
|
||||
const handleSelectDraft = useCallback(
|
||||
@@ -979,6 +996,15 @@ export const ComposePost = ({
|
||||
})
|
||||
).uris[0]
|
||||
|
||||
// Fire published event for every video that made it into the post.
|
||||
// The status guard upstream ensures each video.telemetry is present and
|
||||
// processing has completed by this point.
|
||||
for (const post of filteredThread.posts) {
|
||||
if (post.embed.media?.type === 'video') {
|
||||
post.embed.media.video.telemetry?.published()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Wait for app view to have received the post(s). If this fails, it's
|
||||
* ok, because the post _was_ actually published above.
|
||||
|
||||
@@ -24,6 +24,7 @@ import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Ima
|
||||
import * as toast from '#/components/Toast'
|
||||
import {IS_NATIVE, IS_WEB} from '#/env'
|
||||
import {isAnimatedGif} from './videos/isAnimatedGif'
|
||||
import {hasWebCodecs} from './videos/metadata'
|
||||
|
||||
export type SelectMediaButtonProps = {
|
||||
disabled?: boolean
|
||||
@@ -291,9 +292,15 @@ async function processImagePickerAssets(
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files on web. On native, we compress these anyway,
|
||||
* so we only check on web.
|
||||
* so we only check on web. On web, we can reject early if the browser
|
||||
* doesn't support WebCodecs.
|
||||
*/
|
||||
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
if (
|
||||
IS_WEB &&
|
||||
!hasWebCodecs() &&
|
||||
asset.fileSize &&
|
||||
asset.fileSize > VIDEO_MAX_SIZE
|
||||
) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
continue
|
||||
}
|
||||
@@ -309,8 +316,7 @@ async function processImagePickerAssets(
|
||||
if (type === 'gif') {
|
||||
/*
|
||||
* Filesize appears to be stable across all platforms, so we can use it
|
||||
* to filter out large files on web. On native, we compress GIFs as
|
||||
* videos anyway, so we only check on web.
|
||||
* to filter out large files. We can't compress GIFs on either platform.
|
||||
*/
|
||||
if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
|
||||
errors.add(SelectedAssetError.FileTooBig)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@atproto/api'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {type SelfLabel} from '#/lib/moderation'
|
||||
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
@@ -88,6 +89,7 @@ export type PostAction =
|
||||
type: 'embed_add_video'
|
||||
asset: ImagePickerAsset
|
||||
abortController: AbortController
|
||||
telemetry: VideoTelemetry
|
||||
}
|
||||
| {type: 'embed_remove_video'}
|
||||
| {type: 'embed_update_video'; videoAction: VideoAction}
|
||||
@@ -458,7 +460,11 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
||||
if (!prevMedia) {
|
||||
nextMedia = {
|
||||
type: 'video',
|
||||
video: createVideoState(action.asset, action.abortController),
|
||||
video: createVideoState(
|
||||
action.asset,
|
||||
action.abortController,
|
||||
action.telemetry,
|
||||
),
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UploadLimitError,
|
||||
VideoTooLargeError,
|
||||
} from '#/lib/media/video/errors'
|
||||
import {type VideoTelemetry} from '#/lib/media/video/telemetry'
|
||||
import {type CompressedVideo} from '#/lib/media/video/types'
|
||||
import {uploadVideo} from '#/lib/media/video/upload'
|
||||
import {createVideoAgent} from '#/lib/media/video/util'
|
||||
@@ -64,6 +65,7 @@ export const NO_VIDEO = Object.freeze({
|
||||
video: undefined,
|
||||
jobId: undefined,
|
||||
pendingPublish: undefined,
|
||||
telemetry: undefined,
|
||||
altText: '',
|
||||
captions: [],
|
||||
})
|
||||
@@ -79,6 +81,7 @@ type ErrorState = {
|
||||
jobId: string | null
|
||||
error: string
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -91,6 +94,7 @@ type CompressingState = {
|
||||
video?: undefined
|
||||
jobId?: undefined
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -103,6 +107,7 @@ type UploadingState = {
|
||||
video: CompressedVideo
|
||||
jobId?: undefined
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -116,6 +121,7 @@ type ProcessingState = {
|
||||
jobId: string
|
||||
jobStatus: AppBskyVideoDefs.JobStatus | null
|
||||
pendingPublish?: undefined
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -128,6 +134,7 @@ type DoneState = {
|
||||
video: CompressedVideo
|
||||
jobId?: undefined
|
||||
pendingPublish: {blobRef: BlobRef}
|
||||
telemetry: VideoTelemetry
|
||||
altText: string
|
||||
captions: CaptionsTrack[]
|
||||
}
|
||||
@@ -142,12 +149,14 @@ export type VideoState =
|
||||
export function createVideoState(
|
||||
asset: ImagePickerAsset,
|
||||
abortController: AbortController,
|
||||
telemetry: VideoTelemetry,
|
||||
): CompressingState {
|
||||
return {
|
||||
status: 'compressing',
|
||||
progress: 0,
|
||||
abortController,
|
||||
asset,
|
||||
telemetry,
|
||||
altText: '',
|
||||
captions: [],
|
||||
}
|
||||
@@ -170,6 +179,7 @@ export function videoReducer(
|
||||
asset: state.asset ?? null,
|
||||
video: state.video ?? null,
|
||||
jobId: state.jobId ?? null,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -198,6 +208,7 @@ export function videoReducer(
|
||||
abortController: state.abortController,
|
||||
asset: state.asset,
|
||||
video: action.video,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -213,6 +224,7 @@ export function videoReducer(
|
||||
video: state.video,
|
||||
jobId: action.jobId,
|
||||
jobStatus: null,
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -239,6 +251,7 @@ export function videoReducer(
|
||||
pendingPublish: {
|
||||
blobRef: action.blobRef,
|
||||
},
|
||||
telemetry: state.telemetry,
|
||||
altText: state.altText,
|
||||
captions: state.captions,
|
||||
}
|
||||
@@ -265,9 +278,11 @@ export async function processVideo(
|
||||
did: string,
|
||||
signal: AbortSignal,
|
||||
i18n: I18n,
|
||||
telemetry: VideoTelemetry,
|
||||
) {
|
||||
let video: CompressedVideo | undefined
|
||||
try {
|
||||
telemetry.compressStarted()
|
||||
video = await compressVideo(asset, {
|
||||
onProgress: num => {
|
||||
dispatch({type: 'update_progress', progress: trunc2dp(num), signal})
|
||||
@@ -277,6 +292,7 @@ export async function processVideo(
|
||||
} catch (e) {
|
||||
const message = getCompressErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
telemetry.compressFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: message,
|
||||
@@ -285,6 +301,15 @@ export async function processVideo(
|
||||
}
|
||||
return
|
||||
}
|
||||
if (video.passthroughReason) {
|
||||
telemetry.compressSkipped({
|
||||
size: video.size,
|
||||
mimeType: video.mimeType,
|
||||
skipReason: video.passthroughReason,
|
||||
})
|
||||
} else {
|
||||
telemetry.compressCompleted({size: video.size, mimeType: video.mimeType})
|
||||
}
|
||||
dispatch({
|
||||
type: 'compressing_to_uploading',
|
||||
video,
|
||||
@@ -293,6 +318,7 @@ export async function processVideo(
|
||||
|
||||
let uploadResponse: AppBskyVideoDefs.JobStatus | undefined
|
||||
try {
|
||||
telemetry.uploadStarted(video.size)
|
||||
uploadResponse = await uploadVideo({
|
||||
video,
|
||||
agent,
|
||||
@@ -306,6 +332,7 @@ export async function processVideo(
|
||||
} catch (e) {
|
||||
const message = getUploadErrorMessage(e, i18n)
|
||||
if (message !== null) {
|
||||
telemetry.uploadFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: message,
|
||||
@@ -316,6 +343,8 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
const jobId = uploadResponse.jobId
|
||||
telemetry.uploadCompleted(jobId)
|
||||
telemetry.processingStarted(jobId)
|
||||
dispatch({
|
||||
type: 'uploading_to_processing',
|
||||
jobId,
|
||||
@@ -354,6 +383,7 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
logger.error('Error processing video', {safeMessage: e})
|
||||
telemetry.processingFailed(e)
|
||||
dispatch({
|
||||
type: 'to_error',
|
||||
error: i18n._(msg`Video failed to process`),
|
||||
@@ -363,6 +393,7 @@ export async function processVideo(
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
telemetry.processingCompleted()
|
||||
dispatch({
|
||||
type: 'to_done',
|
||||
blobRef: blob,
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import {atoms as a, flatten} from '#/alf'
|
||||
|
||||
export function clearThumbnailCache() {
|
||||
// no-op
|
||||
// no-op on web
|
||||
}
|
||||
|
||||
export function VideoTranscodeBackdrop() {
|
||||
return null
|
||||
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
|
||||
return (
|
||||
<video
|
||||
src={uri}
|
||||
style={flatten([
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.h_full,
|
||||
a.w_full,
|
||||
{
|
||||
objectFit: 'cover',
|
||||
filter: 'blur(15px)',
|
||||
transform: 'scale(1.1)', // hide blur edges
|
||||
},
|
||||
])}
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||
import {IS_WEB} from '#/env'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
@@ -20,8 +19,6 @@ export function VideoTranscodeProgress({
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (IS_WEB) return null
|
||||
|
||||
let aspectRatio: number | undefined
|
||||
if (asset.width && asset.height) {
|
||||
const raw = asset.width / asset.height
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {getVideoMetaData} from 'react-native-compressor'
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
|
||||
import {extToMime} from '#/lib/media/video/util'
|
||||
|
||||
export async function getVideoMetadata(
|
||||
file: File | string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file !== 'string')
|
||||
throw new Error(
|
||||
'getVideoMetadata was passed a File, when on native it should be a uri',
|
||||
)
|
||||
const metadata = await getVideoMetaData(file)
|
||||
return {
|
||||
uri: file,
|
||||
mimeType: extToMime(metadata.extension),
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import {type ImagePickerAsset} from 'expo-image-picker'
|
||||
import {ALL_FORMATS, BlobSource, Input} from 'mediabunny'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
|
||||
export function hasWebCodecs(): boolean {
|
||||
return (
|
||||
typeof VideoEncoder !== 'undefined' && typeof VideoDecoder !== 'undefined'
|
||||
)
|
||||
}
|
||||
|
||||
export async function getVideoMetadata(
|
||||
file: File | string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file === 'string')
|
||||
throw new Error(
|
||||
'getVideoMetadata was passed a uri, when on web it should be a File',
|
||||
)
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
|
||||
logger.debug('metadata: starting', {
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
hasWebCodecs: hasWebCodecs(),
|
||||
})
|
||||
|
||||
if (hasWebCodecs()) {
|
||||
try {
|
||||
const result = await getMetadataWithWebCodecs(file, blobUrl)
|
||||
logger.debug('metadata: WebCodecs succeeded', {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
duration: result.duration,
|
||||
})
|
||||
return result
|
||||
} catch (e) {
|
||||
logger.warn('metadata: WebCodecs failed, using fallback', {
|
||||
safeMessage: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to old-fashioned browser APIs
|
||||
const result = await getMetadataWithBrowserAPIs(file, blobUrl)
|
||||
logger.debug('metadata: browser API succeeded', {
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
duration: result.duration,
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function getMetadataWithWebCodecs(
|
||||
file: File,
|
||||
blobUrl: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
const input = new Input({
|
||||
source: new BlobSource(file),
|
||||
formats: ALL_FORMATS,
|
||||
})
|
||||
|
||||
try {
|
||||
const [videoTrack, duration] = await Promise.all([
|
||||
input.getPrimaryVideoTrack(),
|
||||
input.computeDuration(),
|
||||
])
|
||||
|
||||
if (!videoTrack) {
|
||||
throw new Error('No video track found')
|
||||
}
|
||||
|
||||
return {
|
||||
uri: blobUrl,
|
||||
mimeType: file.type,
|
||||
width: videoTrack.displayWidth,
|
||||
height: videoTrack.displayHeight,
|
||||
duration: duration * 1000, // convert seconds to ms
|
||||
}
|
||||
} finally {
|
||||
input.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMetadataWithBrowserAPIs(
|
||||
file: File,
|
||||
blobUrl: string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (file.type === 'image/gif') {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
uri: blobUrl,
|
||||
mimeType: 'image/gif',
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
duration: null,
|
||||
})
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
reject(new Error('Failed to load GIF'))
|
||||
}
|
||||
img.src = blobUrl
|
||||
} else {
|
||||
const video = document.createElement('video')
|
||||
video.preload = 'metadata'
|
||||
video.src = blobUrl
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
resolve({
|
||||
uri: blobUrl,
|
||||
mimeType: file.type,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
duration: video.duration * 1000,
|
||||
})
|
||||
}
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
reject(new Error('Failed to load video metadata'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import {getVideoMetaData} from 'react-native-compressor'
|
||||
import {
|
||||
type ImagePickerAsset,
|
||||
launchImageLibraryAsync,
|
||||
UIImagePickerPreferredAssetRepresentationMode,
|
||||
} from 'expo-image-picker'
|
||||
|
||||
import {VIDEO_MAX_DURATION_MS} from '#/lib/constants'
|
||||
import {extToMime} from '#/lib/media/video/util'
|
||||
|
||||
export async function pickVideo() {
|
||||
return await launchImageLibraryAsync({
|
||||
exif: false,
|
||||
mediaTypes: ['videos'],
|
||||
quality: 1,
|
||||
legacy: true,
|
||||
preferredAssetRepresentationMode:
|
||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||
videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets video metadata from a file or uri, depending on the platform
|
||||
*
|
||||
* @param file File on web, uri on native
|
||||
*/
|
||||
export async function getVideoMetadata(
|
||||
file: File | string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file !== 'string')
|
||||
throw new Error(
|
||||
'getVideoMetadata was passed a File, when on native it should be a uri',
|
||||
)
|
||||
const metadata = await getVideoMetaData(file)
|
||||
return {
|
||||
uri: file,
|
||||
mimeType: extToMime(metadata.extension),
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
duration: metadata.duration,
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import {type ImagePickerAsset, type ImagePickerResult} from 'expo-image-picker'
|
||||
|
||||
import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
|
||||
|
||||
// mostly copied from expo-image-picker and adapted to support gifs
|
||||
// also adds support for reading video metadata
|
||||
|
||||
export async function pickVideo(): Promise<ImagePickerResult> {
|
||||
const input = document.createElement('input')
|
||||
input.style.display = 'none'
|
||||
input.setAttribute('type', 'file')
|
||||
// TODO: do we need video/* here? -sfn
|
||||
input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
|
||||
input.setAttribute('id', String(Math.random()))
|
||||
|
||||
document.body.appendChild(input)
|
||||
|
||||
return new Promise(resolve => {
|
||||
input.addEventListener('change', async () => {
|
||||
if (input.files) {
|
||||
const file = input.files[0]
|
||||
resolve({
|
||||
canceled: false,
|
||||
assets: [await getVideoMetadata(file)],
|
||||
})
|
||||
} else {
|
||||
resolve({canceled: true, assets: null})
|
||||
}
|
||||
document.body.removeChild(input)
|
||||
})
|
||||
|
||||
const event = new MouseEvent('click')
|
||||
input.dispatchEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: we're converting to a dataUrl here, and then converting back to an
|
||||
// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
|
||||
// lets us use the ImagePickerAsset type, which the rest of the code expects.
|
||||
// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
|
||||
// instead of a string -sfn
|
||||
export function getVideoMetadata(
|
||||
file: File | string,
|
||||
): Promise<ImagePickerAsset> {
|
||||
if (typeof file === 'string')
|
||||
throw new Error(
|
||||
'getVideoMetadata was passed a uri, when on web it should be a File',
|
||||
)
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const uri = reader.result as string
|
||||
|
||||
if (file.type === 'image/gif') {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resolve({
|
||||
uri,
|
||||
mimeType: 'image/gif',
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
// todo: calculate gif duration. seems possible if you read the bytes
|
||||
// https://codepen.io/Ryman/pen/nZpYwY
|
||||
// for now let's just let the server reject it, since that seems uncommon -sfn
|
||||
duration: null,
|
||||
})
|
||||
}
|
||||
img.onerror = (_ev, _source, _lineno, _colno, error) => {
|
||||
console.log('Failed to grab GIF metadata', error)
|
||||
reject(new Error('Failed to grab GIF metadata'))
|
||||
}
|
||||
img.src = uri
|
||||
} else {
|
||||
const video = document.createElement('video')
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
|
||||
video.preload = 'metadata'
|
||||
video.src = blobUrl
|
||||
|
||||
video.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
resolve({
|
||||
uri,
|
||||
mimeType: file.type,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
// convert seconds to ms
|
||||
duration: video.duration * 1000,
|
||||
})
|
||||
}
|
||||
video.onerror = (_ev, _source, _lineno, _colno, error) => {
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
console.log('Failed to grab video metadata', error)
|
||||
reject(new Error('Failed to grab video metadata'))
|
||||
}
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {type CountryCode} from '#/lib/international-telephone-codes'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
|
||||
import {DateField, LabelText} from '#/components/forms/DateField'
|
||||
import {DateField, LabelText, utils} from '#/components/forms/DateField'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
import * as TextField from '#/components/forms/TextField'
|
||||
import * as Toggle from '#/components/forms/Toggle'
|
||||
@@ -28,6 +28,8 @@ export function Forms() {
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [date, setDate] = useState('2001-01-01')
|
||||
const [emptyDate, setEmptyDate] = useState('')
|
||||
const [confirmDate, setConfirmDate] = useState('2001-01-01')
|
||||
const [countryCode, setCountryCode] = useState<CountryCode>('US')
|
||||
const [phoneNumber, setPhoneNumber] = useState('')
|
||||
const [lang, setLang] = useState('en')
|
||||
@@ -175,17 +177,63 @@ export function Forms() {
|
||||
<H3>DateField</H3>
|
||||
|
||||
<View style={[a.w_full]}>
|
||||
<LabelText>Date</LabelText>
|
||||
<LabelText>1. Date</LabelText>
|
||||
<DateField
|
||||
testID="date"
|
||||
value={date}
|
||||
onChangeDate={date => {
|
||||
console.log(date)
|
||||
console.log('[1] changed', date)
|
||||
setDate(date)
|
||||
}}
|
||||
label="Input"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.w_full]}>
|
||||
<LabelText>2. Empty value with placeholder</LabelText>
|
||||
<DateField
|
||||
testID="dateEmpty"
|
||||
value={emptyDate}
|
||||
onChangeDate={date => {
|
||||
console.log('[2] changed', date)
|
||||
setEmptyDate(date)
|
||||
}}
|
||||
placeholder="Select a date"
|
||||
label="Birthday"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.w_full]}>
|
||||
<LabelText>3. Empty value with placeholder and maximumDate</LabelText>
|
||||
<DateField
|
||||
testID="dateEmptyMax"
|
||||
value={emptyDate}
|
||||
onChangeDate={date => {
|
||||
console.log('[3] changed', date)
|
||||
setEmptyDate(date)
|
||||
}}
|
||||
placeholder="Select a date"
|
||||
maximumDate={utils.toSimpleDateString(new Date())}
|
||||
label="Date of birth"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[a.w_full]}>
|
||||
<LabelText>
|
||||
4. onConfirm vs onChangeDate (check the console)
|
||||
</LabelText>
|
||||
<DateField
|
||||
testID="dateConfirm"
|
||||
value={confirmDate}
|
||||
onChangeDate={date => {
|
||||
console.log('[4] changed', date)
|
||||
setConfirmDate(date)
|
||||
}}
|
||||
onConfirm={date => console.log('[4] confirmed', date)}
|
||||
label="Input"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<H3>InternationalPhoneCodeSelect</H3>
|
||||
|
||||
<View style={[a.flex_row, a.gap_sm, a.align_center]}>
|
||||
|
||||
@@ -242,12 +242,18 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
accessibilityLabel={l`Chat`}
|
||||
accessibilityHint={
|
||||
!aa.flags.chatDisabled && numUnreadMessages.count > 0
|
||||
? l({
|
||||
message: plural(numUnreadMessages.numUnread ?? 0, {
|
||||
one: '# unread item',
|
||||
other: '# unread items',
|
||||
}),
|
||||
})
|
||||
? numUnreadMessages.numUnread?.includes('+')
|
||||
? l({
|
||||
message: `${numUnreadMessages.numUnread} unread items`,
|
||||
comment:
|
||||
'Accessibility hint for the bottom bar chat icon when the number of unread messages exceeds the cap, with the + symbol already included – for example, 99+ unread items',
|
||||
})
|
||||
: l({
|
||||
message: plural(numUnreadMessages.numUnread ?? 0, {
|
||||
one: '# unread item',
|
||||
other: '# unread items',
|
||||
}),
|
||||
})
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -27,6 +27,10 @@ export function DesktopSearch() {
|
||||
if (query.length) setActive(true)
|
||||
}
|
||||
|
||||
const onBlur = () => {
|
||||
setActive(false)
|
||||
}
|
||||
|
||||
const onChangeText = (text: string) => {
|
||||
setQuery(text)
|
||||
if (!active) {
|
||||
@@ -64,6 +68,7 @@ export function DesktopSearch() {
|
||||
hotkey
|
||||
value={query}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChangeText}
|
||||
onClearText={onClearText}
|
||||
onSubmitEditing={onSubmit}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src", "app.config.js"]
|
||||
"include": ["src", "modules", "app.config.js"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user