main → zio/dev
commit
b90aca402a
|
@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Pin alice's feed
|
# Pin alice's feed
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderDrawerBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "viewHeaderDrawerBtn"
|
||||||
|
- tapOn:
|
||||||
|
id: "profileCardButton"
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
|
|
|
@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Feeds ✨"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can go to feeds page using feeds button in tab bar"
|
label: "Can go to feeds page using feeds button in tab bar"
|
||||||
text: "Feeds ✨"
|
text: "Feeds ✨"
|
||||||
|
@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can like posts"
|
label: "Can like posts"
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can repost posts"
|
label: "Can repost posts"
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Repost"
|
- tapOn: "Repost"
|
||||||
- assertVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Remove repost"
|
- tapOn: "Remove repost"
|
||||||
- assertNotVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can delete posts"
|
label: "Can delete posts"
|
||||||
|
|
|
@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
|
||||||
|
|
||||||
|
|
||||||
# Navigate to my profile
|
# Navigate to my profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "bottomBarProfileBtn"
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Navigate to another user profile
|
# Navigate to another user profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|
|
@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
|
||||||
|
|
||||||
|
|
||||||
# Navigate to thread
|
# Navigate to thread
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Thread root"
|
||||||
- tapOn: "Thread root"
|
- tapOn: "Thread root"
|
||||||
- assertVisible: "Thread reply"
|
- assertVisible: "Thread reply"
|
||||||
|
|
||||||
|
@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
|
|
||||||
# Can repost the root post
|
# Can repost the root post
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|
|
@ -191,7 +191,7 @@ module.exports = function (config) {
|
||||||
'expo-build-properties',
|
'expo-build-properties',
|
||||||
{
|
{
|
||||||
ios: {
|
ios: {
|
||||||
deploymentTarget: '14.0',
|
deploymentTarget: '15.1',
|
||||||
newArchEnabled: false,
|
newArchEnabled: false,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z" clip-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 289 B |
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#fff" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
|
After Width: | Height: | Size: 239 B |
|
@ -9,7 +9,7 @@
|
||||||
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
"lint": "eslint --cache --ext .js,.jsx,.ts,.tsx src"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "0.13.1",
|
"@atproto/api": "0.13.6",
|
||||||
"@preact/preset-vite": "^2.8.2",
|
"@preact/preset-vite": "^2.8.2",
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
"@vitejs/plugin-legacy": "^5.3.2",
|
||||||
"preact": "^10.4.8",
|
"preact": "^10.4.8",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import {
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecord,
|
AppBskyEmbedRecord,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyGraphDefs,
|
AppBskyGraphDefs,
|
||||||
|
@ -14,6 +15,7 @@ import {ComponentChildren, h} from 'preact'
|
||||||
import {useMemo} from 'preact/hooks'
|
import {useMemo} from 'preact/hooks'
|
||||||
|
|
||||||
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
import infoIcon from '../../assets/circleInfo_stroke2_corner0_rounded.svg'
|
||||||
|
import playIcon from '../../assets/play_filled_corner2_rounded.svg'
|
||||||
import starterPackIcon from '../../assets/starterPack.svg'
|
import starterPackIcon from '../../assets/starterPack.svg'
|
||||||
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
import {CONTENT_LABELS, labelsToInfo} from '../labels'
|
||||||
import {getRkey} from '../utils'
|
import {getRkey} from '../utils'
|
||||||
|
@ -160,7 +162,12 @@ export function Embed({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case 4: Record with media
|
// Case 4: Video
|
||||||
|
if (AppBskyEmbedVideo.isView(content)) {
|
||||||
|
return <VideoEmbed content={content} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 5: Record with media
|
||||||
if (
|
if (
|
||||||
AppBskyEmbedRecordWithMedia.isView(content) &&
|
AppBskyEmbedRecordWithMedia.isView(content) &&
|
||||||
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
AppBskyEmbedRecord.isViewRecord(content.record.record)
|
||||||
|
@ -354,6 +361,31 @@ function GenericWithImageEmbed({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// just the thumbnail and a play button
|
||||||
|
function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
|
||||||
|
let aspectRatio = 1
|
||||||
|
|
||||||
|
if (content.aspectRatio) {
|
||||||
|
const {width, height} = content.aspectRatio
|
||||||
|
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="w-full overflow-hidden rounded-lg aspect-square"
|
||||||
|
style={{aspectRatio: `${aspectRatio} / 1`}}>
|
||||||
|
<img
|
||||||
|
src={content.thumbnail}
|
||||||
|
alt={content.alt}
|
||||||
|
className="object-cover size-full"
|
||||||
|
/>
|
||||||
|
<div className="size-24 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/50 flex items-center justify-center">
|
||||||
|
<img src={playIcon} className="object-cover size-3/5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function StarterPackEmbed({
|
function StarterPackEmbed({
|
||||||
content,
|
content,
|
||||||
}: {
|
}: {
|
||||||
|
@ -410,3 +442,7 @@ function getStarterPackHref(
|
||||||
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
const handleOrDid = starterPack.creator.handle || starterPack.creator.did
|
||||||
return `/starter-pack/${handleOrDid}/${rkey}`
|
return `/starter-pack/${handleOrDid}/${rkey}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(num: number, min: number, max: number) {
|
||||||
|
return Math.max(min, Math.min(num, max))
|
||||||
|
}
|
||||||
|
|
|
@ -20,15 +20,15 @@
|
||||||
"@jridgewell/gen-mapping" "^0.3.5"
|
"@jridgewell/gen-mapping" "^0.3.5"
|
||||||
"@jridgewell/trace-mapping" "^0.3.24"
|
"@jridgewell/trace-mapping" "^0.3.24"
|
||||||
|
|
||||||
"@atproto/api@0.13.1":
|
"@atproto/api@0.13.6":
|
||||||
version "0.13.1"
|
version "0.13.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.1.tgz#fbf4306e4465d5467aaf031308c1b47dcc8039d0"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.6.tgz#2500e9d7143e6718089632300c42ce50149f8cd5"
|
||||||
integrity sha512-DL3iBfavn8Nnl48FmnAreQB0k0cIkW531DJ5JAHUCQZo10Nq0ZLk2/WFxcs0KuBG5wuLnGUdo+Y6/GQPVq8dYw==
|
integrity sha512-58emFFZhqY8nVWD3xFWK0yYqAmJ2un+NaTtZxBbRo00mGq1rz9VXTpVmfoHFcuXL1hoDQN3WyJfsub8r6xGOgg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
"@atproto/syntax" "^0.3.0"
|
"@atproto/syntax" "^0.3.0"
|
||||||
"@atproto/xrpc" "^0.6.0"
|
"@atproto/xrpc" "^0.6.1"
|
||||||
await-lock "^2.2.2"
|
await-lock "^2.2.2"
|
||||||
multiformats "^9.9.0"
|
multiformats "^9.9.0"
|
||||||
tlds "^1.234.0"
|
tlds "^1.234.0"
|
||||||
|
@ -59,10 +59,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.0.tgz#fafa2dbea9add37253005cb663e7373e05e618b3"
|
||||||
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
integrity sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==
|
||||||
|
|
||||||
"@atproto/xrpc@^0.6.0":
|
"@atproto/xrpc@^0.6.1":
|
||||||
version "0.6.0"
|
version "0.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.0.tgz#668c3262e67e2afa65951ea79a03bfe3720ddf5c"
|
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
|
||||||
integrity sha512-5BbhBTv5j6MC3iIQ4+vYxQE7nLy2dDGQ+LYJrH8PptOCUdq0Pwg6aRccQ3y52kUZlhE/mzOTZ8Ngiy9pSAyfVQ==
|
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/lexicon" "^0.4.1"
|
"@atproto/lexicon" "^0.4.1"
|
||||||
zod "^3.23.8"
|
zod "^3.23.8"
|
||||||
|
|
|
@ -60,6 +60,12 @@ func run(args []string) {
|
||||||
Value: "",
|
Value: "",
|
||||||
EnvVars: []string{"LINK_HOST"},
|
EnvVars: []string{"LINK_HOST"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ipcc-host",
|
||||||
|
Usage: "scheme, hostname, and port of ipcc service",
|
||||||
|
Value: "https://localhost:8730",
|
||||||
|
EnvVars: []string{"IPCC_HOST"},
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "debug",
|
Name: "debug",
|
||||||
Usage: "Enable debug mode",
|
Usage: "Enable debug mode",
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
@ -41,6 +46,7 @@ type Config struct {
|
||||||
appviewHost string
|
appviewHost string
|
||||||
ogcardHost string
|
ogcardHost string
|
||||||
linkHost string
|
linkHost string
|
||||||
|
ipccHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
|
@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
|
||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
ipccHost := cctx.String("ipcc-host")
|
||||||
basicAuthPassword := cctx.String("basic-auth-password")
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
|
@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
|
||||||
appviewHost: appviewHost,
|
appviewHost: appviewHost,
|
||||||
ogcardHost: ogcardHost,
|
ogcardHost: ogcardHost,
|
||||||
linkHost: linkHost,
|
linkHost: linkHost,
|
||||||
|
ipccHost: ipccHost,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error {
|
||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
|
// ipcc
|
||||||
|
e.GET("/ipcc", server.WebIpCC)
|
||||||
|
|
||||||
if linkHost != "" {
|
if linkHost != "" {
|
||||||
linkUrl, err := url.Parse(linkHost)
|
linkUrl, err := url.Parse(linkHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
||||||
data["requestHost"] = req.Host
|
data["requestHost"] = req.Host
|
||||||
return c.Render(http.StatusOK, "profile.html", data)
|
return c.Render(http.StatusOK, "profile.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPCCRequest struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
type IPCCResponse struct {
|
||||||
|
CC string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||||
|
realIP := c.RealIP()
|
||||||
|
addr, err := netip.ParseAddr(realIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("could not parse IP %q %s", realIP, err)
|
||||||
|
return c.JSON(400, IPCCResponse{})
|
||||||
|
}
|
||||||
|
var request []byte
|
||||||
|
if addr.Is4() {
|
||||||
|
ip4 := addr.As4()
|
||||||
|
var dest [8]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip4[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
} else if addr.Is6() {
|
||||||
|
ip6 := addr.As16()
|
||||||
|
var dest [24]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip6[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("ipcc misconfigured bad url %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
|
||||||
|
ipccUrl := ipccUrlBuilder.String()
|
||||||
|
cl := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
postBodyReader := bytes.NewReader(request)
|
||||||
|
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc backend error %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
dec := json.NewDecoder(response.Body)
|
||||||
|
var outResponse IPCCResponse
|
||||||
|
err = dec.Decode(&outResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc bad response %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
return c.JSON(200, outResponse)
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ module.exports = {
|
||||||
'ja',
|
'ja',
|
||||||
'ko',
|
'ko',
|
||||||
'pt-BR',
|
'pt-BR',
|
||||||
|
'ru',
|
||||||
'tr',
|
'tr',
|
||||||
'uk',
|
'uk',
|
||||||
'zh-CN',
|
'zh-CN',
|
||||||
|
|
|
@ -139,7 +139,7 @@
|
||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "^1.2.4",
|
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
|
||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
@ -180,6 +180,7 @@
|
||||||
"react-native-image-crop-picker": "0.40.3",
|
"react-native-image-crop-picker": "0.40.3",
|
||||||
"react-native-ios-context-menu": "^1.15.3",
|
"react-native-ios-context-menu": "^1.15.3",
|
||||||
"react-native-keyboard-controller": "^1.12.1",
|
"react-native-keyboard-controller": "^1.12.1",
|
||||||
|
"react-native-mmkv": "^2.12.2",
|
||||||
"react-native-pager-view": "6.2.3",
|
"react-native-pager-view": "6.2.3",
|
||||||
"react-native-picker-select": "^9.1.3",
|
"react-native-picker-select": "^9.1.3",
|
||||||
"react-native-progress": "bluesky-social/react-native-progress",
|
"react-native-progress": "bluesky-social/react-native-progress",
|
||||||
|
|
|
@ -1,557 +0,0 @@
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
index 473f964..f37aff9 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerEvent.kt
|
|
||||||
@@ -41,6 +41,11 @@ sealed class PlayerEvent {
|
|
||||||
override val name = "playToEnd"
|
|
||||||
}
|
|
||||||
|
|
||||||
+ data class PlayerTimeRemainingChanged(val timeRemaining: Double): PlayerEvent() {
|
|
||||||
+ override val name = "timeRemainingChange"
|
|
||||||
+ override val arguments = arrayOf(timeRemaining)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
fun emit(player: VideoPlayer, listeners: List<VideoPlayerListener>) {
|
|
||||||
when (this) {
|
|
||||||
is StatusChanged -> listeners.forEach { it.onStatusChanged(player, status, oldStatus, error) }
|
|
||||||
@@ -49,6 +54,7 @@ sealed class PlayerEvent {
|
|
||||||
is SourceChanged -> listeners.forEach { it.onSourceChanged(player, source, oldSource) }
|
|
||||||
is PlaybackRateChanged -> listeners.forEach { it.onPlaybackRateChanged(player, rate, oldRate) }
|
|
||||||
is PlayedToEnd -> listeners.forEach { it.onPlayedToEnd(player) }
|
|
||||||
+ is PlayerTimeRemainingChanged -> listeners.forEach { it.onPlayerTimeRemainingChanged(player, timeRemaining) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
index 9905e13..47342ff 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
|
|
||||||
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
|
|
||||||
setShowPreviousButton(!requireLinearPlayback)
|
|
||||||
setShowNextButton(!requireLinearPlayback)
|
|
||||||
setTimeBarInteractive(requireLinearPlayback)
|
|
||||||
+ setShowSubtitleButton(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
|
|
||||||
|
|
||||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
|
||||||
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
|
|
||||||
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
+ val fullscreenButton =
|
|
||||||
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullscreenButton?.visibility = if (visible) {
|
|
||||||
android.view.View.VISIBLE
|
|
||||||
} else {
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
|
||||||
new file mode 100644
|
|
||||||
index 0000000..0249e23
|
|
||||||
--- /dev/null
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/ProgressTracker.kt
|
|
||||||
@@ -0,0 +1,29 @@
|
|
||||||
+import android.os.Handler
|
|
||||||
+import android.os.Looper
|
|
||||||
+import androidx.annotation.OptIn
|
|
||||||
+import androidx.media3.common.util.UnstableApi
|
|
||||||
+import expo.modules.video.PlayerEvent
|
|
||||||
+import expo.modules.video.VideoPlayer
|
|
||||||
+import kotlin.math.floor
|
|
||||||
+
|
|
||||||
+@OptIn(UnstableApi::class)
|
|
||||||
+class ProgressTracker(private val videoPlayer: VideoPlayer) : Runnable {
|
|
||||||
+ private val handler: Handler = Handler(Looper.getMainLooper())
|
|
||||||
+ private val player = videoPlayer.player
|
|
||||||
+
|
|
||||||
+ init {
|
|
||||||
+ handler.post(this)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ override fun run() {
|
|
||||||
+ val currentPosition = player.currentPosition
|
|
||||||
+ val duration = player.duration
|
|
||||||
+ val timeRemaining = floor(((duration - currentPosition) / 1000).toDouble())
|
|
||||||
+ videoPlayer.sendEvent(PlayerEvent.PlayerTimeRemainingChanged(timeRemaining))
|
|
||||||
+ handler.postDelayed(this, 1000 /* ms */)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ fun remove() {
|
|
||||||
+ handler.removeCallbacks(this)
|
|
||||||
+ }
|
|
||||||
+}
|
|
||||||
\ No newline at end of file
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
index 4b6c6d8..e20f51a 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoManager.kt
|
|
||||||
@@ -1,5 +1,6 @@
|
|
||||||
package expo.modules.video
|
|
||||||
|
|
||||||
+import android.provider.MediaStore.Video
|
|
||||||
import androidx.annotation.OptIn
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import expo.modules.kotlin.AppContext
|
|
||||||
@@ -15,6 +16,8 @@ object VideoManager {
|
|
||||||
// Keeps track of all existing VideoPlayers, and whether they are attached to a VideoView
|
|
||||||
private var videoPlayersToVideoViews = mutableMapOf<VideoPlayer, MutableList<VideoView>>()
|
|
||||||
|
|
||||||
+ private var previouslyPlayingViews: MutableList<VideoView>? = null
|
|
||||||
+
|
|
||||||
private lateinit var audioFocusManager: AudioFocusManager
|
|
||||||
|
|
||||||
fun onModuleCreated(appContext: AppContext) {
|
|
||||||
@@ -69,16 +72,24 @@ object VideoManager {
|
|
||||||
return videoPlayersToVideoViews[videoPlayer]?.isNotEmpty() ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
- fun onAppForegrounded() = Unit
|
|
||||||
+ fun onAppForegrounded() {
|
|
||||||
+ val previouslyPlayingViews = this.previouslyPlayingViews ?: return
|
|
||||||
+ for (videoView in previouslyPlayingViews) {
|
|
||||||
+ val player = videoView.videoPlayer?.player ?: continue
|
|
||||||
+ player.play()
|
|
||||||
+ }
|
|
||||||
+ this.previouslyPlayingViews = null
|
|
||||||
+ }
|
|
||||||
|
|
||||||
fun onAppBackgrounded() {
|
|
||||||
+ val previouslyPlayingViews = mutableListOf<VideoView>()
|
|
||||||
for (videoView in videoViews.values) {
|
|
||||||
- if (videoView.videoPlayer?.staysActiveInBackground == false &&
|
|
||||||
- !videoView.willEnterPiP &&
|
|
||||||
- !videoView.isInFullscreen
|
|
||||||
- ) {
|
|
||||||
- videoView.videoPlayer?.player?.pause()
|
|
||||||
+ val player = videoView.videoPlayer?.player ?: continue
|
|
||||||
+ if (player.isPlaying) {
|
|
||||||
+ player.pause()
|
|
||||||
+ previouslyPlayingViews.add(videoView)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+ this.previouslyPlayingViews = previouslyPlayingViews
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
index ec3da2a..5a1397a 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt
|
|
||||||
@@ -43,7 +43,9 @@ class VideoModule : Module() {
|
|
||||||
View(VideoView::class) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { view: VideoView, player: VideoPlayer ->
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
index 58f00af..5ad8237 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayer.kt
|
|
||||||
@@ -1,5 +1,6 @@
|
|
||||||
package expo.modules.video
|
|
||||||
|
|
||||||
+import ProgressTracker
|
|
||||||
import android.content.Context
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
@@ -35,11 +36,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
.Builder(context, renderersFactory)
|
|
||||||
.setLooper(context.mainLooper)
|
|
||||||
.build()
|
|
||||||
+ var progressTracker: ProgressTracker? = null
|
|
||||||
|
|
||||||
val serviceConnection = PlaybackServiceConnection(WeakReference(player))
|
|
||||||
|
|
||||||
var playing by IgnoreSameSet(false) { new, old ->
|
|
||||||
sendEvent(PlayerEvent.IsPlayingChanged(new, old))
|
|
||||||
+ addOrRemoveProgressTracker()
|
|
||||||
}
|
|
||||||
|
|
||||||
var uncommittedSource: VideoSource? = source
|
|
||||||
@@ -141,6 +144,9 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
+ this.progressTracker?.remove()
|
|
||||||
+ this.progressTracker = null
|
|
||||||
+
|
|
||||||
appContext?.reactContext?.unbindService(serviceConnection)
|
|
||||||
serviceConnection.playbackServiceBinder?.service?.unregisterPlayer(player)
|
|
||||||
VideoManager.unregisterVideoPlayer(this@VideoPlayer)
|
|
||||||
@@ -228,7 +234,7 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
listeners.removeAll { it.get() == videoPlayerListener }
|
|
||||||
}
|
|
||||||
|
|
||||||
- private fun sendEvent(event: PlayerEvent) {
|
|
||||||
+ fun sendEvent(event: PlayerEvent) {
|
|
||||||
// Emits to the native listeners
|
|
||||||
event.emit(this, listeners.mapNotNull { it.get() })
|
|
||||||
// Emits to the JS side
|
|
||||||
@@ -240,4 +246,13 @@ class VideoPlayer(val context: Context, appContext: AppContext, source: VideoSou
|
|
||||||
sendEvent(eventName, *args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ private fun addOrRemoveProgressTracker() {
|
|
||||||
+ this.progressTracker?.remove()
|
|
||||||
+ if (this.playing) {
|
|
||||||
+ this.progressTracker = ProgressTracker(this)
|
|
||||||
+ } else {
|
|
||||||
+ this.progressTracker = null
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
index f654254..dcfe3f0 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoPlayerListener.kt
|
|
||||||
@@ -15,4 +15,5 @@ interface VideoPlayerListener {
|
|
||||||
fun onSourceChanged(player: VideoPlayer, source: VideoSource?, oldSource: VideoSource?) {}
|
|
||||||
fun onPlaybackRateChanged(player: VideoPlayer, rate: Float, oldRate: Float?) {}
|
|
||||||
fun onPlayedToEnd(player: VideoPlayer) {}
|
|
||||||
+ fun onPlayerTimeRemainingChanged(player: VideoPlayer, timeRemaining: Double) {}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
index a951d80..3932535 100644
|
|
||||||
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/VideoView.kt
|
|
||||||
@@ -36,6 +36,8 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val playerView: PlayerView = PlayerView(context.applicationContext)
|
|
||||||
val onPictureInPictureStart by EventDispatcher<Unit>()
|
|
||||||
val onPictureInPictureStop by EventDispatcher<Unit>()
|
|
||||||
+ val onEnterFullscreen by EventDispatcher()
|
|
||||||
+ val onExitFullscreen by EventDispatcher()
|
|
||||||
|
|
||||||
var willEnterPiP: Boolean = false
|
|
||||||
var isInFullscreen: Boolean = false
|
|
||||||
@@ -154,6 +156,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
currentActivity.overridePendingTransition(0, 0)
|
|
||||||
}
|
|
||||||
+ onEnterFullscreen(mapOf())
|
|
||||||
isInFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -162,6 +165,7 @@ class VideoView(context: Context, appContext: AppContext) : ExpoView(context, ap
|
|
||||||
val fullScreenButton: ImageButton = playerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
|
|
||||||
fullScreenButton.setImageResource(androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter)
|
|
||||||
videoPlayer?.changePlayerView(playerView)
|
|
||||||
+ this.onExitFullscreen(mapOf())
|
|
||||||
isInFullscreen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoPlayer.types.d.ts b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
index a09fcfe..5eac9e5 100644
|
|
||||||
--- a/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
+++ b/node_modules/expo-video/build/VideoPlayer.types.d.ts
|
|
||||||
@@ -128,6 +128,8 @@ export type VideoPlayerEvents = {
|
|
||||||
* Handler for an event emitted when the current media source of the player changes.
|
|
||||||
*/
|
|
||||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
|
||||||
+
|
|
||||||
+ timeRemainingChange(timeRemaining: number): void;
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Describes the current status of the player.
|
|
||||||
diff --git a/node_modules/expo-video/build/VideoView.types.d.ts b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
index cb9ca6d..ed8bb7e 100644
|
|
||||||
--- a/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
+++ b/node_modules/expo-video/build/VideoView.types.d.ts
|
|
||||||
@@ -89,5 +89,8 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
||||||
//# sourceMappingURL=VideoView.types.d.ts.map
|
|
||||||
\ No newline at end of file
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoManager.swift b/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
index 094a8b0..3f00525 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoManager.swift
|
|
||||||
@@ -12,6 +12,7 @@ class VideoManager {
|
|
||||||
|
|
||||||
private var videoViews = NSHashTable<VideoView>.weakObjects()
|
|
||||||
private var videoPlayers = NSHashTable<VideoPlayer>.weakObjects()
|
|
||||||
+ private var previouslyPlayingPlayers: [VideoPlayer]?
|
|
||||||
|
|
||||||
func register(videoPlayer: VideoPlayer) {
|
|
||||||
videoPlayers.add(videoPlayer)
|
|
||||||
@@ -33,63 +34,70 @@ class VideoManager {
|
|
||||||
for videoPlayer in videoPlayers.allObjects {
|
|
||||||
videoPlayer.setTracksEnabled(true)
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ if let previouslyPlayingPlayers = self.previouslyPlayingPlayers {
|
|
||||||
+ previouslyPlayingPlayers.forEach { player in
|
|
||||||
+ player.pointer.play()
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
|
|
||||||
func onAppBackgrounded() {
|
|
||||||
+ var previouslyPlayingPlayers: [VideoPlayer] = []
|
|
||||||
for videoView in videoViews.allObjects {
|
|
||||||
guard let player = videoView.player else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
- if player.staysActiveInBackground == true {
|
|
||||||
- player.setTracksEnabled(videoView.isInPictureInPicture)
|
|
||||||
- } else if !videoView.isInPictureInPicture {
|
|
||||||
+ if player.isPlaying {
|
|
||||||
player.pointer.pause()
|
|
||||||
+ previouslyPlayingPlayers.append(player)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+ self.previouslyPlayingPlayers = previouslyPlayingPlayers
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Audio Session Management
|
|
||||||
|
|
||||||
internal func setAppropriateAudioSessionOrWarn() {
|
|
||||||
- let audioSession = AVAudioSession.sharedInstance()
|
|
||||||
- var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
|
|
||||||
-
|
|
||||||
- let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
|
|
||||||
- player.isPlaying
|
|
||||||
- }
|
|
||||||
- let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
|
|
||||||
- player.isMuted
|
|
||||||
- }
|
|
||||||
- let needsPiPSupport = videoViews.allObjects.contains { view in
|
|
||||||
- view.allowPictureInPicture
|
|
||||||
- }
|
|
||||||
- let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
|
|
||||||
- player.showNowPlayingNotification
|
|
||||||
- }
|
|
||||||
- // The notification won't be shown if we allow the audio to mix with others
|
|
||||||
- let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
|
|
||||||
- let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
|
|
||||||
- let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
|
|
||||||
-
|
|
||||||
- if shouldAllowMixing {
|
|
||||||
- audioSessionCategoryOptions.insert(.mixWithOthers)
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
|
|
||||||
- do {
|
|
||||||
- try audioSession.setCategory(.playback, mode: .moviePlayback)
|
|
||||||
- } catch {
|
|
||||||
- log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- // Make sure audio session is active if any video is playing
|
|
||||||
- if isAnyPlayerPlaying {
|
|
||||||
- do {
|
|
||||||
- try audioSession.setActive(true)
|
|
||||||
- } catch {
|
|
||||||
- log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
+// let audioSession = AVAudioSession.sharedInstance()
|
|
||||||
+// var audioSessionCategoryOptions: AVAudioSession.CategoryOptions = []
|
|
||||||
+//
|
|
||||||
+// let isAnyPlayerPlaying = videoPlayers.allObjects.contains { player in
|
|
||||||
+// player.isPlaying
|
|
||||||
+// }
|
|
||||||
+// let areAllPlayersMuted = videoPlayers.allObjects.allSatisfy { player in
|
|
||||||
+// player.isMuted
|
|
||||||
+// }
|
|
||||||
+// let needsPiPSupport = videoViews.allObjects.contains { view in
|
|
||||||
+// view.allowPictureInPicture
|
|
||||||
+// }
|
|
||||||
+// let anyPlayerShowsNotification = videoPlayers.allObjects.contains { player in
|
|
||||||
+// player.showNowPlayingNotification
|
|
||||||
+// }
|
|
||||||
+// // The notification won't be shown if we allow the audio to mix with others
|
|
||||||
+// let shouldAllowMixing = (!isAnyPlayerPlaying || areAllPlayersMuted) && !anyPlayerShowsNotification
|
|
||||||
+// let isOutputtingAudio = !areAllPlayersMuted && isAnyPlayerPlaying
|
|
||||||
+// let shouldUpdateToAllowMixing = !audioSession.categoryOptions.contains(.mixWithOthers) && shouldAllowMixing
|
|
||||||
+//
|
|
||||||
+// if shouldAllowMixing {
|
|
||||||
+// audioSessionCategoryOptions.insert(.mixWithOthers)
|
|
||||||
+// }
|
|
||||||
+//
|
|
||||||
+// if isOutputtingAudio || needsPiPSupport || shouldUpdateToAllowMixing || anyPlayerShowsNotification {
|
|
||||||
+// do {
|
|
||||||
+// try audioSession.setCategory(.playback, mode: .moviePlayback)
|
|
||||||
+// } catch {
|
|
||||||
+// log.warn("Failed to set audio session category. This might cause issues with audio playback and Picture in Picture. \(error.localizedDescription)")
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
+//
|
|
||||||
+// // Make sure audio session is active if any video is playing
|
|
||||||
+// if isAnyPlayerPlaying {
|
|
||||||
+// do {
|
|
||||||
+// try audioSession.setActive(true)
|
|
||||||
+// } catch {
|
|
||||||
+// log.warn("Failed to activate the audio session. This might cause issues with audio playback. \(error.localizedDescription)")
|
|
||||||
+// }
|
|
||||||
+// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoModule.swift b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
index c537a12..e4a918f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoModule.swift
|
|
||||||
@@ -16,7 +16,9 @@ public final class VideoModule: Module {
|
|
||||||
View(VideoView.self) {
|
|
||||||
Events(
|
|
||||||
"onPictureInPictureStart",
|
|
||||||
- "onPictureInPictureStop"
|
|
||||||
+ "onPictureInPictureStop",
|
|
||||||
+ "onEnterFullscreen",
|
|
||||||
+ "onExitFullscreen"
|
|
||||||
)
|
|
||||||
|
|
||||||
Prop("player") { (view, player: VideoPlayer?) in
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoPlayer.swift b/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
index 3315b88..733ab1f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoPlayer.swift
|
|
||||||
@@ -185,6 +185,10 @@ internal final class VideoPlayer: SharedRef<AVPlayer>, Hashable, VideoPlayerObse
|
|
||||||
safeEmit(event: "sourceChange", arguments: newVideoPlayerItem?.videoSource, oldVideoPlayerItem?.videoSource)
|
|
||||||
}
|
|
||||||
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {
|
|
||||||
+ safeEmit(event: "timeRemainingChange", arguments: timeRemaining)
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
func safeEmit<each A: AnyArgument>(event: String, arguments: repeat each A) {
|
|
||||||
if self.appContext != nil {
|
|
||||||
self.emit(event: event, arguments: repeat each arguments)
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoPlayerObserver.swift b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
index d289e26..ea4d96f 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoPlayerObserver.swift
|
|
||||||
@@ -21,6 +21,7 @@ protocol VideoPlayerObserverDelegate: AnyObject {
|
|
||||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?)
|
|
||||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool)
|
|
||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status)
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default implementations for the delegate
|
|
||||||
@@ -33,6 +34,7 @@ extension VideoPlayerObserverDelegate {
|
|
||||||
func onItemChanged(player: AVPlayer, oldVideoPlayerItem: VideoPlayerItem?, newVideoPlayerItem: VideoPlayerItem?) {}
|
|
||||||
func onIsMutedChanged(player: AVPlayer, oldIsMuted: Bool?, newIsMuted: Bool) {}
|
|
||||||
func onPlayerItemStatusChanged(player: AVPlayer, oldStatus: AVPlayerItem.Status?, newStatus: AVPlayerItem.Status) {}
|
|
||||||
+ func onPlayerTimeRemainingChanged(player: AVPlayer, timeRemaining: Double) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrapper used to store WeakReferences to the observer delegate
|
|
||||||
@@ -91,6 +93,7 @@ class VideoPlayerObserver {
|
|
||||||
private var playerVolumeObserver: NSKeyValueObservation?
|
|
||||||
private var playerCurrentItemObserver: NSKeyValueObservation?
|
|
||||||
private var playerIsMutedObserver: NSKeyValueObservation?
|
|
||||||
+ private var playerPeriodicTimeObserver: Any?
|
|
||||||
|
|
||||||
// Current player item observers
|
|
||||||
private var playbackBufferEmptyObserver: NSKeyValueObservation?
|
|
||||||
@@ -152,6 +155,9 @@ class VideoPlayerObserver {
|
|
||||||
playerVolumeObserver?.invalidate()
|
|
||||||
playerIsMutedObserver?.invalidate()
|
|
||||||
playerCurrentItemObserver?.invalidate()
|
|
||||||
+ if let playerPeriodicTimeObserver = self.playerPeriodicTimeObserver {
|
|
||||||
+ player?.removeTimeObserver(playerPeriodicTimeObserver)
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func initializeCurrentPlayerItemObservers(player: AVPlayer, playerItem: AVPlayerItem) {
|
|
||||||
@@ -270,6 +276,7 @@ class VideoPlayerObserver {
|
|
||||||
|
|
||||||
if isPlaying != (player.timeControlStatus == .playing) {
|
|
||||||
isPlaying = player.timeControlStatus == .playing
|
|
||||||
+ addPeriodicTimeObserverIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -310,4 +317,28 @@ class VideoPlayerObserver {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+
|
|
||||||
+ private func onPlayerTimeRemainingChanged(_ player: AVPlayer, _ timeRemaining: Double) {
|
|
||||||
+ delegates.forEach { delegate in
|
|
||||||
+ delegate.value?.onPlayerTimeRemainingChanged(player: player, timeRemaining: timeRemaining)
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ private func addPeriodicTimeObserverIfNeeded() {
|
|
||||||
+ guard self.playerPeriodicTimeObserver == nil, let player = self.player else {
|
|
||||||
+ return
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ if isPlaying {
|
|
||||||
+ // Add the time update listener
|
|
||||||
+ playerPeriodicTimeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, preferredTimescale: Int32(NSEC_PER_SEC)), queue: nil) { event in
|
|
||||||
+ guard let duration = player.currentItem?.duration else {
|
|
||||||
+ return
|
|
||||||
+ }
|
|
||||||
+
|
|
||||||
+ let timeRemaining = (duration.seconds - event.seconds).rounded()
|
|
||||||
+ self.onPlayerTimeRemainingChanged(player, timeRemaining)
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
+ }
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/ios/VideoView.swift b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
index f4579e4..10c5908 100644
|
|
||||||
--- a/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
+++ b/node_modules/expo-video/ios/VideoView.swift
|
|
||||||
@@ -41,6 +41,8 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
|
|
||||||
let onPictureInPictureStart = EventDispatcher()
|
|
||||||
let onPictureInPictureStop = EventDispatcher()
|
|
||||||
+ let onEnterFullscreen = EventDispatcher()
|
|
||||||
+ let onExitFullscreen = EventDispatcher()
|
|
||||||
|
|
||||||
public override var bounds: CGRect {
|
|
||||||
didSet {
|
|
||||||
@@ -163,6 +165,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
_ playerViewController: AVPlayerViewController,
|
|
||||||
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator
|
|
||||||
) {
|
|
||||||
+ onEnterFullscreen()
|
|
||||||
isFullscreen = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -179,6 +182,7 @@ public final class VideoView: ExpoView, AVPlayerViewControllerDelegate {
|
|
||||||
if wasPlaying {
|
|
||||||
self.player?.pointer.play()
|
|
||||||
}
|
|
||||||
+ self.onExitFullscreen()
|
|
||||||
self.isFullscreen = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff --git a/node_modules/expo-video/src/VideoPlayer.types.ts b/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
index aaf4b63..f438196 100644
|
|
||||||
--- a/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
+++ b/node_modules/expo-video/src/VideoPlayer.types.ts
|
|
||||||
@@ -151,6 +151,8 @@ export type VideoPlayerEvents = {
|
|
||||||
* Handler for an event emitted when the current media source of the player changes.
|
|
||||||
*/
|
|
||||||
sourceChange(newSource: VideoSource, previousSource: VideoSource): void;
|
|
||||||
+
|
|
||||||
+ timeRemainingChange(timeRemaining: number): void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
diff --git a/node_modules/expo-video/src/VideoView.types.ts b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
index 29fe5db..e1fbf59 100644
|
|
||||||
--- a/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
+++ b/node_modules/expo-video/src/VideoView.types.ts
|
|
||||||
@@ -100,4 +100,7 @@ export interface VideoViewProps extends ViewProps {
|
|
||||||
* @platform ios 16.0+
|
|
||||||
*/
|
|
||||||
allowsVideoFrameAnalysis?: boolean;
|
|
||||||
+
|
|
||||||
+ onEnterFullscreen?: () => void;
|
|
||||||
+ onExitFullscreen?: () => void;
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
## uwu woad beawing, do not wemove
|
|
||||||
|
|
||||||
## `expo-video` Patch
|
|
||||||
|
|
||||||
### `onEnterFullScreen`/`onExitFullScreen`
|
|
||||||
Adds two props to `VideoView`: `onEnterFullscreen` and `onExitFullscreen` which do exactly what they say on
|
|
||||||
the tin.
|
|
||||||
|
|
||||||
### Removing audio session management
|
|
||||||
|
|
||||||
This patch also removes the audio session management that Expo does on its own, as we handle audio session management
|
|
||||||
ourselves.
|
|
||||||
|
|
||||||
### Pausing/playing on background/foreground
|
|
||||||
|
|
||||||
Instead of handling the pausing/playing of videos in React, we'll handle them here. There's some logic that we do not
|
|
||||||
need (around PIP mode) that we can remove, and just pause any playing players on background and then resume them on
|
|
||||||
foreground.
|
|
|
@ -57,7 +57,7 @@ const withXcodeTarget = (config, {targetName}) => {
|
||||||
buildSettingsObj.SWIFT_VERSION = '5.0'
|
buildSettingsObj.SWIFT_VERSION = '5.0'
|
||||||
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1"`
|
||||||
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
|
||||||
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '14.0'
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '15.1'
|
||||||
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
buildSettingsObj.ASSETCATALOG_COMPILER_APPICON_NAME = 'AppIcon'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
@ -105,52 +106,50 @@ function InnerApp() {
|
||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<Alf theme={theme}>
|
||||||
<Alf theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<ActiveVideoProvider>
|
||||||
<ActiveVideoProvider>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<React.Fragment
|
||||||
<React.Fragment
|
// Resets the entire tree below when it changes:
|
||||||
// Resets the entire tree below when it changes:
|
key={currentAccount?.did}>
|
||||||
key={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<StatsigProvider>
|
||||||
<StatsigProvider>
|
<MessagesProvider>
|
||||||
<MessagesProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<LabelDefsProvider>
|
||||||
<LabelDefsProvider>
|
<ModerationOptsProvider>
|
||||||
<ModerationOptsProvider>
|
<LoggedOutViewProvider>
|
||||||
<LoggedOutViewProvider>
|
<SelectedFeedProvider>
|
||||||
<SelectedFeedProvider>
|
<HiddenRepliesProvider>
|
||||||
<HiddenRepliesProvider>
|
<UnreadNotifsProvider>
|
||||||
<UnreadNotifsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<MutedThreadsProvider>
|
||||||
<MutedThreadsProvider>
|
<ProgressGuideProvider>
|
||||||
<ProgressGuideProvider>
|
<GestureHandlerRootView
|
||||||
<GestureHandlerRootView
|
style={s.h100pct}>
|
||||||
style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
</ProgressGuideProvider>
|
||||||
</ProgressGuideProvider>
|
</MutedThreadsProvider>
|
||||||
</MutedThreadsProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</UnreadNotifsProvider>
|
||||||
</UnreadNotifsProvider>
|
</HiddenRepliesProvider>
|
||||||
</HiddenRepliesProvider>
|
</SelectedFeedProvider>
|
||||||
</SelectedFeedProvider>
|
</LoggedOutViewProvider>
|
||||||
</LoggedOutViewProvider>
|
</ModerationOptsProvider>
|
||||||
</ModerationOptsProvider>
|
</LabelDefsProvider>
|
||||||
</LabelDefsProvider>
|
</MessagesProvider>
|
||||||
</MessagesProvider>
|
</StatsigProvider>
|
||||||
</StatsigProvider>
|
</QueryProvider>
|
||||||
</QueryProvider>
|
</React.Fragment>
|
||||||
</React.Fragment>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ActiveVideoProvider>
|
||||||
</ActiveVideoProvider>
|
</Splash>
|
||||||
</Splash>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</Alf>
|
||||||
</Alf>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,7 +183,12 @@ function App() {
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<SafeAreaProvider
|
||||||
|
initialMetrics={initialWindowMetrics}>
|
||||||
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|
|
@ -47,6 +47,7 @@ import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
|
@ -162,7 +163,9 @@ function App() {
|
||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|
|
@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||||
linking={LINKING}
|
linking={LINKING}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onStateChange={() => {
|
onStateChange={() => {
|
||||||
logEvent('router:navigate:sampled', {
|
const routeName = getCurrentRouteName()
|
||||||
from: prevLoggedRouteName.current,
|
if (routeName === 'Notifications') {
|
||||||
})
|
logEvent('router:navigate:notifications:sampled', {})
|
||||||
prevLoggedRouteName.current = getCurrentRouteName()
|
}
|
||||||
}}
|
}}
|
||||||
onReady={() => {
|
onReady={() => {
|
||||||
attachRouteToLogEvents(getCurrentRouteName)
|
attachRouteToLogEvents(getCurrentRouteName)
|
||||||
logModuleInitTime()
|
logModuleInitTime()
|
||||||
onReady()
|
onReady()
|
||||||
logEvent('router:navigate:sampled', {})
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
|
@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||||
const gate = useGate()
|
|
||||||
const [feedType, feedUri] = feed.split('|')
|
const [feedType, feedUri] = feed.split('|')
|
||||||
if (feedType === 'author') {
|
if (feedType === 'author') {
|
||||||
if (gate('show_follow_suggestions_in_profile')) {
|
return <SuggestedFollowsProfile did={feedUri} />
|
||||||
return <SuggestedFollowsProfile did={feedUri} />
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return <SuggestedFollowsHome />
|
return <SuggestedFollowsHome />
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,172 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
|
import {Image} from 'expo-image'
|
||||||
|
import {
|
||||||
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedRecordWithMedia,
|
||||||
|
AppBskyEmbedVideo,
|
||||||
|
} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streamlined MediaPreview component which just handles images, gifs, and videos
|
||||||
|
*/
|
||||||
|
export function Embed({
|
||||||
|
embed,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
embed?:
|
||||||
|
| AppBskyEmbedImages.View
|
||||||
|
| AppBskyEmbedRecordWithMedia.View
|
||||||
|
| AppBskyEmbedExternal.View
|
||||||
|
| AppBskyEmbedVideo.View
|
||||||
|
| {[k: string]: unknown}
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
let media = AppBskyEmbedRecordWithMedia.isView(embed) ? embed.media : embed
|
||||||
|
|
||||||
|
if (AppBskyEmbedImages.isView(media)) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
{media.images.map(image => (
|
||||||
|
<ImageItem
|
||||||
|
key={image.thumb}
|
||||||
|
thumbnail={image.thumb}
|
||||||
|
alt={image.alt}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
|
||||||
|
let url: URL | undefined
|
||||||
|
try {
|
||||||
|
url = new URL(embed.external.uri)
|
||||||
|
} catch {}
|
||||||
|
if (url) {
|
||||||
|
const {success} = parseTenorGif(url)
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
<GifItem
|
||||||
|
thumbnail={embed.external.thumb}
|
||||||
|
alt={embed.external.title}
|
||||||
|
/>
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (AppBskyEmbedVideo.isView(embed)) {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
|
||||||
|
</Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Outer({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
style?: StyleProp<ViewStyle>
|
||||||
|
}) {
|
||||||
|
return <View style={[a.flex_row, a.gap_xs, style]}>{children}</View>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImageItem({
|
||||||
|
thumbnail,
|
||||||
|
alt,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
thumbnail: string
|
||||||
|
alt?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
||||||
|
<Image
|
||||||
|
key={thumbnail}
|
||||||
|
source={{uri: thumbnail}}
|
||||||
|
style={[a.flex_1, a.rounded_xs]}
|
||||||
|
contentFit="cover"
|
||||||
|
accessible={true}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
|
accessibilityHint={alt}
|
||||||
|
accessibilityLabel=""
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GifItem({thumbnail, alt}: {thumbnail: string; alt?: string}) {
|
||||||
|
return (
|
||||||
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.altContainer}>
|
||||||
|
<Text style={styles.alt}>
|
||||||
|
<Trans>GIF</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</ImageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VideoItem({
|
||||||
|
thumbnail,
|
||||||
|
alt,
|
||||||
|
}: {
|
||||||
|
thumbnail?: string
|
||||||
|
alt?: string
|
||||||
|
}) {
|
||||||
|
if (!thumbnail) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
{backgroundColor: 'black'},
|
||||||
|
a.flex_1,
|
||||||
|
{aspectRatio: 1, maxWidth: 100},
|
||||||
|
a.justify_center,
|
||||||
|
a.align_center,
|
||||||
|
]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ImageItem thumbnail={thumbnail} alt={alt}>
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon size={24} />
|
||||||
|
</View>
|
||||||
|
</ImageItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
altContainer: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
position: 'absolute',
|
||||||
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 7,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
},
|
||||||
|
})
|
|
@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
|
||||||
export type FollowButtonProps = {
|
export type FollowButtonProps = {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
|
|
|
@ -8,7 +8,10 @@ import {Button, ButtonColor, ButtonProps, ButtonText} from '#/components/Button'
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export {useDialogControl as usePromptControl} from '#/components/Dialog'
|
export {
|
||||||
|
type DialogControlProps as PromptControlProps,
|
||||||
|
useDialogControl as usePromptControl,
|
||||||
|
} from '#/components/Dialog'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
titleId: string
|
titleId: string
|
||||||
|
@ -23,7 +26,7 @@ export function Outer({
|
||||||
control,
|
control,
|
||||||
testID,
|
testID,
|
||||||
}: React.PropsWithChildren<{
|
}: React.PropsWithChildren<{
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogControlProps
|
||||||
testID?: string
|
testID?: string
|
||||||
}>) {
|
}>) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
|
|
|
@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const bottomBarOffset = useBottomBarOffset(200)
|
const bottomBarOffset = useBottomBarOffset(300)
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
||||||
|
|
|
@ -2,7 +2,7 @@ import React from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {AppBskyEmbedRecord} from '@atproto/api'
|
import {AppBskyEmbedRecord} from '@atproto/api'
|
||||||
|
|
||||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
|
|
||||||
let MessageItemEmbed = ({
|
let MessageItemEmbed = ({
|
||||||
|
@ -14,7 +14,11 @@ let MessageItemEmbed = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
|
||||||
<PostEmbeds embed={embed} allowNestedQuotes />
|
<PostEmbeds
|
||||||
|
embed={embed}
|
||||||
|
allowNestedQuotes
|
||||||
|
viewContext={PostEmbedViewContext.Feed}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
ptBR,
|
ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
zhCN,
|
zhCN,
|
||||||
|
@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
|
||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
['pt-BR']: ptBR,
|
['pt-BR']: ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
['zh-CN']: zhCN,
|
['zh-CN']: zhCN,
|
||||||
|
|
|
@ -15,8 +15,8 @@ export function useFollowMethods({
|
||||||
logContext,
|
logContext,
|
||||||
}: {
|
}: {
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {createSinglePathSVG} from './TEMPLATE'
|
||||||
|
|
||||||
|
export const Crop_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||||
|
path: 'M6 2a1 1 0 0 1 1 1v2h11a1 1 0 0 1 1 1v11h2a1 1 0 1 1 0 2h-2v2a1 1 0 1 1-2 0v-2H6a1 1 0 0 1-1-1V7H3a1 1 0 0 1 0-2h2V3a1 1 0 0 1 1-1Zm1 5v10h10V7H7Z',
|
||||||
|
})
|
|
@ -19,6 +19,7 @@ export const sizes = {
|
||||||
md: 20,
|
md: 20,
|
||||||
lg: 24,
|
lg: 24,
|
||||||
xl: 28,
|
xl: 28,
|
||||||
|
'2xl': 32,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommonSVGProps(props: Props) {
|
export function useCommonSVGProps(props: Props) {
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {VerifyEmailIntentDialog} from '#/components/intents/VerifyEmailIntentDialog'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
verifyEmailDialogControl: DialogControlProps
|
||||||
|
verifyEmailState: {code: string} | undefined
|
||||||
|
setVerifyEmailState: (state: {code: string} | undefined) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = React.createContext({} as Context)
|
||||||
|
export const useIntentDialogs = () => React.useContext(Context)
|
||||||
|
|
||||||
|
export function Provider({children}: {children: React.ReactNode}) {
|
||||||
|
const verifyEmailDialogControl = Dialog.useDialogControl()
|
||||||
|
const [verifyEmailState, setVerifyEmailState] = React.useState<
|
||||||
|
{code: string} | undefined
|
||||||
|
>()
|
||||||
|
|
||||||
|
const value = React.useMemo(
|
||||||
|
() => ({
|
||||||
|
verifyEmailDialogControl,
|
||||||
|
verifyEmailState,
|
||||||
|
setVerifyEmailState,
|
||||||
|
}),
|
||||||
|
[verifyEmailDialogControl, verifyEmailState, setVerifyEmailState],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Context.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<VerifyEmailIntentDialog />
|
||||||
|
</Context.Provider>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
import {msg, Trans} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useAgent, useSession} from 'state/session'
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
|
import * as Dialog from '#/components/Dialog'
|
||||||
|
import {DialogControlProps} from '#/components/Dialog'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
|
import {Loader} from '#/components/Loader'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
|
export function VerifyEmailIntentDialog() {
|
||||||
|
const {verifyEmailDialogControl: control} = useIntentDialogs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Outer control={control}>
|
||||||
|
<Dialog.Handle />
|
||||||
|
<Inner control={control} />
|
||||||
|
</Dialog.Outer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({control}: {control: DialogControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {verifyEmailState: state} = useIntentDialogs()
|
||||||
|
const [status, setStatus] = React.useState<
|
||||||
|
'loading' | 'success' | 'failure' | 'resent'
|
||||||
|
>('loading')
|
||||||
|
const [sending, setSending] = React.useState(false)
|
||||||
|
const agent = useAgent()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
if (!state?.code) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await agent.com.atproto.server.confirmEmail({
|
||||||
|
email: (currentAccount?.email || '').trim(),
|
||||||
|
token: state.code.trim(),
|
||||||
|
})
|
||||||
|
setStatus('success')
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('failure')
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}, [agent.com.atproto.server, currentAccount?.email, state?.code])
|
||||||
|
|
||||||
|
const onPressResendEmail = async () => {
|
||||||
|
setSending(true)
|
||||||
|
await agent.com.atproto.server.requestEmailConfirmation()
|
||||||
|
setSending(false)
|
||||||
|
setStatus('resent')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.ScrollableInner label={_(msg`Verify email dialog`)}>
|
||||||
|
<Dialog.Close />
|
||||||
|
<View style={[a.gap_xl]}>
|
||||||
|
{status === 'loading' ? (
|
||||||
|
<View style={[a.py_2xl, a.align_center, a.justify_center]}>
|
||||||
|
<Loader size="xl" />
|
||||||
|
</View>
|
||||||
|
) : status === 'success' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Verified</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
Thanks, you have successfully verified your email address.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : status === 'failure' ? (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Invalid Verification Code</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
The verification code you have provided is invalid. Please make
|
||||||
|
sure that you have used the correct verification link or request
|
||||||
|
a new one.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={[a.font_bold, a.text_2xl]}>
|
||||||
|
<Trans>Email Resent</Trans>
|
||||||
|
</Text>
|
||||||
|
<Text style={[a.text_md, a.leading_tight]}>
|
||||||
|
<Trans>
|
||||||
|
We have sent another verification email to{' '}
|
||||||
|
<Text style={[a.text_md, a.font_bold]}>
|
||||||
|
{currentAccount?.email}
|
||||||
|
</Text>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status !== 'loading' ? (
|
||||||
|
<View style={[a.w_full, a.flex_row, a.gap_sm, {marginLeft: 'auto'}]}>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Close`)}
|
||||||
|
onPress={() => control.close()}
|
||||||
|
variant="solid"
|
||||||
|
color={status === 'failure' ? 'secondary' : 'primary'}
|
||||||
|
size="medium"
|
||||||
|
style={{marginLeft: 'auto'}}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Close</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
</Button>
|
||||||
|
{status === 'failure' ? (
|
||||||
|
<Button
|
||||||
|
label={_(msg`Resend Verification Email`)}
|
||||||
|
onPress={onPressResendEmail}
|
||||||
|
variant="solid"
|
||||||
|
color="primary"
|
||||||
|
size="medium"
|
||||||
|
disabled={sending}>
|
||||||
|
<ButtonText>
|
||||||
|
<Trans>Resend Email</Trans>
|
||||||
|
</ButtonText>
|
||||||
|
{sending ? <Loader size="sm" style={{color: 'white'}} /> : null}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</Dialog.ScrollableInner>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,19 +14,18 @@ import {
|
||||||
} from '#/components/moderation/LabelsOnMeDialog'
|
} from '#/components/moderation/LabelsOnMeDialog'
|
||||||
|
|
||||||
export function LabelsOnMe({
|
export function LabelsOnMe({
|
||||||
details,
|
type,
|
||||||
labels,
|
labels,
|
||||||
size,
|
size,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
details: {did: string} | {uri: string; cid: string}
|
type: 'account' | 'content'
|
||||||
labels: ComAtprotoLabelDefs.Label[] | undefined
|
labels: ComAtprotoLabelDefs.Label[] | undefined
|
||||||
size?: ButtonSize
|
size?: ButtonSize
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const isAccount = 'did' in details
|
|
||||||
const control = useLabelsOnMeDialogControl()
|
const control = useLabelsOnMeDialogControl()
|
||||||
|
|
||||||
if (!labels || !currentAccount) {
|
if (!labels || !currentAccount) {
|
||||||
|
@ -39,7 +38,7 @@ export function LabelsOnMe({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, style]}>
|
<View style={[a.flex_row, style]}>
|
||||||
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
|
<LabelsOnMeDialog control={control} labels={labels} type={type} />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="solid"
|
variant="solid"
|
||||||
|
@ -51,7 +50,7 @@ export function LabelsOnMe({
|
||||||
}}>
|
}}>
|
||||||
<ButtonIcon position="left" icon={CircleInfo} />
|
<ButtonIcon position="left" icon={CircleInfo} />
|
||||||
<ButtonText style={[a.leading_snug]}>
|
<ButtonText style={[a.leading_snug]}>
|
||||||
{isAccount ? (
|
{type === 'account' ? (
|
||||||
<Plural
|
<Plural
|
||||||
value={labels.length}
|
value={labels.length}
|
||||||
one="# label has been placed on this account"
|
one="# label has been placed on this account"
|
||||||
|
@ -82,6 +81,6 @@ export function LabelsOnMyPost({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<LabelsOnMe details={post} labels={post.labels} size="tiny" style={style} />
|
<LabelsOnMe type="content" labels={post.labels} size="tiny" style={style} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useMutation} from '@tanstack/react-query'
|
import {useMutation} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useLabelSubject} from '#/lib/moderation'
|
||||||
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
import {useLabelInfo} from '#/lib/moderation/useLabelInfo'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
|
@ -18,21 +19,13 @@ import {InlineLinkText} from '#/components/Link'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {Divider} from '../Divider'
|
import {Divider} from '../Divider'
|
||||||
import {Loader} from '../Loader'
|
import {Loader} from '../Loader'
|
||||||
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
|
||||||
|
|
||||||
type Subject =
|
export {useDialogControl as useLabelsOnMeDialogControl} from '#/components/Dialog'
|
||||||
| {
|
|
||||||
uri: string
|
|
||||||
cid: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
did: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LabelsOnMeDialogProps {
|
export interface LabelsOnMeDialogProps {
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
subject: Subject
|
|
||||||
labels: ComAtprotoLabelDefs.Label[]
|
labels: ComAtprotoLabelDefs.Label[]
|
||||||
|
type: 'account' | 'content'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
export function LabelsOnMeDialog(props: LabelsOnMeDialogProps) {
|
||||||
|
@ -51,8 +44,8 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||||
const [appealingLabel, setAppealingLabel] = React.useState<
|
const [appealingLabel, setAppealingLabel] = React.useState<
|
||||||
ComAtprotoLabelDefs.Label | undefined
|
ComAtprotoLabelDefs.Label | undefined
|
||||||
>(undefined)
|
>(undefined)
|
||||||
const {subject, labels} = props
|
const {labels} = props
|
||||||
const isAccount = 'did' in subject
|
const isAccount = props.type === 'account'
|
||||||
const containsSelfLabel = React.useMemo(
|
const containsSelfLabel = React.useMemo(
|
||||||
() => labels.some(l => l.src === currentAccount?.did),
|
() => labels.some(l => l.src === currentAccount?.did),
|
||||||
[currentAccount?.did, labels],
|
[currentAccount?.did, labels],
|
||||||
|
@ -68,7 +61,6 @@ function LabelsOnMeDialogInner(props: LabelsOnMeDialogProps) {
|
||||||
{appealingLabel ? (
|
{appealingLabel ? (
|
||||||
<AppealForm
|
<AppealForm
|
||||||
label={appealingLabel}
|
label={appealingLabel}
|
||||||
subject={subject}
|
|
||||||
control={props.control}
|
control={props.control}
|
||||||
onPressBack={() => setAppealingLabel(undefined)}
|
onPressBack={() => setAppealingLabel(undefined)}
|
||||||
/>
|
/>
|
||||||
|
@ -188,12 +180,10 @@ function Label({
|
||||||
|
|
||||||
function AppealForm({
|
function AppealForm({
|
||||||
label,
|
label,
|
||||||
subject,
|
|
||||||
control,
|
control,
|
||||||
onPressBack,
|
onPressBack,
|
||||||
}: {
|
}: {
|
||||||
label: ComAtprotoLabelDefs.Label
|
label: ComAtprotoLabelDefs.Label
|
||||||
subject: Subject
|
|
||||||
control: Dialog.DialogOuterProps['control']
|
control: Dialog.DialogOuterProps['control']
|
||||||
onPressBack: () => void
|
onPressBack: () => void
|
||||||
}) {
|
}) {
|
||||||
|
@ -201,6 +191,7 @@ function AppealForm({
|
||||||
const {labeler, strings} = useLabelInfo(label)
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const [details, setDetails] = React.useState('')
|
const [details, setDetails] = React.useState('')
|
||||||
|
const {subject} = useLabelSubject({label})
|
||||||
const isAccountReport = 'did' in subject
|
const isAccountReport = 'did' in subject
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const sourceName = labeler
|
const sourceName = labeler
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {View} from 'react-native'
|
||||||
|
|
||||||
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||||
|
|
||||||
|
export function PlayButtonIcon({size = 36}: {size?: number}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
||||||
|
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
t.atoms.shadow_lg,
|
||||||
|
{
|
||||||
|
width: size + size / 1.5,
|
||||||
|
height: size + size / 1.5,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
{
|
||||||
|
backgroundColor: bg,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PlayIcon
|
||||||
|
width={size}
|
||||||
|
fill={fg}
|
||||||
|
style={[
|
||||||
|
a.relative,
|
||||||
|
a.z_10,
|
||||||
|
{
|
||||||
|
left: size / 50,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
|
@ -271,7 +271,12 @@ export class FeedTuner {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
this.seenUris.add(item.post.uri)
|
// Reposting a reply elevates it to top-level, so its parent/root won't be displayed.
|
||||||
|
// Disable in-thread dedupe for this case since we don't want to miss them later.
|
||||||
|
const disableDedupe = slice.isReply && slice.isRepost
|
||||||
|
if (!disableDedupe) {
|
||||||
|
this.seenUris.add(item.post.uri)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,7 +21,7 @@ export const STARTER_PACK_MAX_SIZE = 150
|
||||||
// code and update this number with each release until we can get the
|
// code and update this number with each release until we can get the
|
||||||
// server route done.
|
// server route done.
|
||||||
// -prf
|
// -prf
|
||||||
export const JOINED_THIS_WEEK = 50676 // as of Aug 17, 2024
|
export const JOINED_THIS_WEEK = 3060000 // estimate as of 9/6/24
|
||||||
|
|
||||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
export function FEEDBACK_FORM_URL({
|
export function FEEDBACK_FORM_URL({
|
||||||
|
@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
|
||||||
|
|
||||||
export const MAX_LABELERS = 20
|
export const MAX_LABELERS = 20
|
||||||
|
|
||||||
|
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||||
|
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
|
||||||
|
|
||||||
export const SUPPORTED_MIME_TYPES = [
|
export const SUPPORTED_MIME_TYPES = [
|
||||||
'video/mp4',
|
'video/mp4',
|
||||||
'video/mpeg',
|
'video/mpeg',
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
// - The count is going down and is 1 less than a multiple of 100
|
// - The count is going down and is 1 less than a multiple of 100
|
||||||
export function decideShouldRoll(isSet: boolean, count: number) {
|
export function decideShouldRoll(isSet: boolean, count: number) {
|
||||||
let shouldRoll = false
|
let shouldRoll = false
|
||||||
if (!isSet && count === 0) {
|
if (!isSet && count === 1) {
|
||||||
shouldRoll = true
|
shouldRoll = true
|
||||||
} else if (count > 0 && count < 1000) {
|
} else if (count > 1 && count < 1000) {
|
||||||
shouldRoll = true
|
shouldRoll = true
|
||||||
} else if (count > 0) {
|
} else if (count > 0) {
|
||||||
const mod = count % 100
|
const mod = count % 100
|
||||||
|
|
|
@ -1,17 +1,20 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const useDedupe = () => {
|
export const useDedupe = (timeout = 250) => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useCallback((cb: () => unknown) => {
|
return React.useCallback(
|
||||||
if (canDo.current) {
|
(cb: () => unknown) => {
|
||||||
canDo.current = false
|
if (canDo.current) {
|
||||||
setTimeout(() => {
|
canDo.current = false
|
||||||
canDo.current = true
|
setTimeout(() => {
|
||||||
}, 250)
|
canDo.current = true
|
||||||
cb()
|
}, timeout)
|
||||||
return true
|
cb()
|
||||||
}
|
return true
|
||||||
return false
|
}
|
||||||
}, [])
|
return false
|
||||||
|
},
|
||||||
|
[timeout],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {useComposerControls} from 'state/shell'
|
import {useComposerControls} from 'state/shell'
|
||||||
import {useCloseAllActiveElements} from 'state/util'
|
import {useCloseAllActiveElements} from 'state/util'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
type IntentType = 'compose'
|
type IntentType = 'compose' | 'verify-email'
|
||||||
|
|
||||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||||
|
|
||||||
export function useIntentHandler() {
|
export function useIntentHandler() {
|
||||||
const incomingUrl = Linking.useURL()
|
const incomingUrl = Linking.useURL()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleIncomingURL = (url: string) => {
|
const handleIncomingURL = (url: string) => {
|
||||||
|
@ -51,12 +53,22 @@ export function useIntentHandler() {
|
||||||
text: params.get('text'),
|
text: params.get('text'),
|
||||||
imageUrisStr: params.get('imageUris'),
|
imageUrisStr: params.get('imageUris'),
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'verify-email': {
|
||||||
|
const code = params.get('code')
|
||||||
|
if (!code) return
|
||||||
|
verifyEmailIntent(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||||
}, [incomingUrl, composeIntent])
|
}, [incomingUrl, composeIntent, verifyEmailIntent])
|
||||||
}
|
}
|
||||||
|
|
||||||
function useComposeIntent() {
|
function useComposeIntent() {
|
||||||
|
@ -103,3 +115,21 @@ function useComposeIntent() {
|
||||||
[hasSession, closeAllActiveElements, openComposer],
|
[hasSession, closeAllActiveElements, openComposer],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useVerifyEmailIntent() {
|
||||||
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
|
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
||||||
|
useIntentDialogs()
|
||||||
|
return React.useCallback(
|
||||||
|
(code: string) => {
|
||||||
|
closeAllActiveElements()
|
||||||
|
setState({
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
control.open()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
[closeAllActiveElements, control, setState],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {extToMime} from '#/state/queries/video/util'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
|
const MIN_SIZE_FOR_COMPRESSION = 1024 * 1024 * 25 // 25mb
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
file: ImagePickerAsset,
|
||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
|
@ -11,12 +16,21 @@ export async function compressVideo(
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
|
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||||
|
file.mimeType as SupportedMimeTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
const minimumFileSizeForCompress = isAcceptableFormat
|
||||||
|
? MIN_SIZE_FOR_COMPRESSION
|
||||||
|
: 0
|
||||||
|
|
||||||
const compressed = await Video.compress(
|
const compressed = await Video.compress(
|
||||||
file,
|
file.uri,
|
||||||
{
|
{
|
||||||
compressionMethod: 'manual',
|
compressionMethod: 'manual',
|
||||||
bitrate: 3_000_000, // 3mbps
|
bitrate: 3_000_000, // 3mbps
|
||||||
maxSize: 1920,
|
maxSize: 1920,
|
||||||
|
minimumFileSizeForCompress,
|
||||||
getCancellationId: id => {
|
getCancellationId: id => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener('abort', () => {
|
signal.addEventListener('abort', () => {
|
||||||
|
@ -30,5 +44,5 @@ export async function compressVideo(
|
||||||
|
|
||||||
const info = await getVideoMetaData(compressed)
|
const info = await getVideoMetaData(compressed)
|
||||||
|
|
||||||
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
|
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
|
@ -5,13 +7,13 @@ const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
||||||
|
|
||||||
// doesn't actually compress, but throws if >100MB
|
// doesn't actually compress, but throws if >100MB
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
asset: ImagePickerAsset,
|
||||||
_opts?: {
|
_opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {mimeType, base64} = parseDataUrl(file)
|
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||||
const blob = base64ToBlob(base64, mimeType)
|
const blob = base64ToBlob(base64, mimeType)
|
||||||
const uri = URL.createObjectURL(blob)
|
const uri = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
|
|
@ -11,3 +11,10 @@ export class ServerError extends Error {
|
||||||
this.name = 'ServerError'
|
this.name = 'ServerError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UploadLimitError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'UploadLimitError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AppBskyLabelerDefs,
|
AppBskyLabelerDefs,
|
||||||
BskyAgent,
|
BskyAgent,
|
||||||
|
ComAtprotoLabelDefs,
|
||||||
InterpretedLabelValueDefinition,
|
InterpretedLabelValueDefinition,
|
||||||
LABELS,
|
LABELS,
|
||||||
ModerationCause,
|
ModerationCause,
|
||||||
|
@ -82,3 +84,34 @@ export function isLabelerSubscribed(
|
||||||
}
|
}
|
||||||
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
return modOpts.prefs.labelers.find(l => l.did === labeler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Subject =
|
||||||
|
| {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
did: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLabelSubject({label}: {label: ComAtprotoLabelDefs.Label}): {
|
||||||
|
subject: Subject
|
||||||
|
} {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
const {cid, uri} = label
|
||||||
|
if (cid) {
|
||||||
|
return {
|
||||||
|
subject: {
|
||||||
|
uri,
|
||||||
|
cid,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
subject: {
|
||||||
|
did: uri,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [label])
|
||||||
|
}
|
||||||
|
|
|
@ -62,6 +62,11 @@ export function useReportOptions(): ReportOptions {
|
||||||
other,
|
other,
|
||||||
],
|
],
|
||||||
post: [
|
post: [
|
||||||
|
{
|
||||||
|
reason: ComAtprotoModerationDefs.REASONMISLEADING,
|
||||||
|
title: _(msg`Misleading Post`),
|
||||||
|
description: _(msg`Impersonation, misinformation, or false claims`),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
reason: ComAtprotoModerationDefs.REASONSPAM,
|
reason: ComAtprotoModerationDefs.REASONSPAM,
|
||||||
title: _(msg`Spam`),
|
title: _(msg`Spam`),
|
||||||
|
|
|
@ -25,7 +25,7 @@ export type LogEvents = {
|
||||||
secondsActive: number
|
secondsActive: number
|
||||||
}
|
}
|
||||||
'state:foreground:sampled': {}
|
'state:foreground:sampled': {}
|
||||||
'router:navigate:sampled': {}
|
'router:navigate:notifications:sampled': {}
|
||||||
'deepLink:referrerReceived': {
|
'deepLink:referrerReceived': {
|
||||||
to: string
|
to: string
|
||||||
referrer: string
|
referrer: string
|
||||||
|
@ -127,25 +127,25 @@ export type LogEvents = {
|
||||||
langs: string
|
langs: string
|
||||||
logContext: 'Composer'
|
logContext: 'Composer'
|
||||||
}
|
}
|
||||||
'post:like': {
|
'post:like:sampled': {
|
||||||
doesLikerFollowPoster: boolean | undefined
|
doesLikerFollowPoster: boolean | undefined
|
||||||
doesPosterFollowLiker: boolean | undefined
|
doesPosterFollowLiker: boolean | undefined
|
||||||
likerClout: number | undefined
|
likerClout: number | undefined
|
||||||
postClout: number | undefined
|
postClout: number | undefined
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:repost': {
|
'post:repost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unlike': {
|
'post:unlike:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unrepost': {
|
'post:unrepost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:mute': {}
|
'post:mute': {}
|
||||||
'post:unmute': {}
|
'post:unmute': {}
|
||||||
'profile:follow': {
|
'profile:follow:sampled': {
|
||||||
didBecomeMutual: boolean | undefined
|
didBecomeMutual: boolean | undefined
|
||||||
followeeClout: number | undefined
|
followeeClout: number | undefined
|
||||||
followerClout: number | undefined
|
followerClout: number | undefined
|
||||||
|
@ -162,7 +162,7 @@ export type LogEvents = {
|
||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow:sampled': {
|
||||||
logContext:
|
logContext:
|
||||||
| 'RecommendedFollowsItem'
|
| 'RecommendedFollowsItem'
|
||||||
| 'PostThreadItem'
|
| 'PostThreadItem'
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'fixed_bottom_bar'
|
|
||||||
| 'onboarding_minimum_interests'
|
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'show_follow_suggestions_in_profile'
|
|
||||||
| 'video_debug' // not recommended
|
|
||||||
| 'video_upload' // upload videos
|
| 'video_upload' // upload videos
|
||||||
| 'video_view_on_posts' // see posted videos
|
| 'video_view_on_posts' // see posted videos
|
||||||
|
|
|
@ -89,8 +89,9 @@ export function toClout(n: number | null | undefined): number | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOWNSAMPLE_RATE = 0.95 // 95% likely
|
||||||
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||||
'router:navigate:sampled',
|
'router:navigate:notifications:sampled',
|
||||||
'state:background:sampled',
|
'state:background:sampled',
|
||||||
'state:foreground:sampled',
|
'state:foreground:sampled',
|
||||||
'home:feedDisplayed:sampled',
|
'home:feedDisplayed:sampled',
|
||||||
|
@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||||
'discover:clickthrough:sampled',
|
'discover:clickthrough:sampled',
|
||||||
'discover:engaged:sampled',
|
'discover:engaged:sampled',
|
||||||
'discover:seen:sampled',
|
'discover:seen:sampled',
|
||||||
|
'post:like:sampled',
|
||||||
|
'post:unlike:sampled',
|
||||||
|
'post:repost:sampled',
|
||||||
|
'post:unrepost:sampled',
|
||||||
|
'profile:follow:sampled',
|
||||||
|
'profile:unfollow:sampled',
|
||||||
])
|
])
|
||||||
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
|
||||||
|
|
||||||
export function logEvent<E extends keyof LogEvents>(
|
export function logEvent<E extends keyof LogEvents>(
|
||||||
eventName: E & string,
|
eventName: E & string,
|
||||||
|
@ -117,12 +124,16 @@ export function logEvent<E extends keyof LogEvents>(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
|
const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
|
||||||
|
if (isDownsampledSession && isDownsampledEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fullMetadata = {
|
const fullMetadata = {
|
||||||
...rawMetadata,
|
...rawMetadata,
|
||||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||||
|
if (isDownsampledEvent) {
|
||||||
|
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
|
||||||
|
}
|
||||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||||
if (Statsig.initializeCalled()) {
|
if (Statsig.initializeCalled()) {
|
||||||
Statsig.logEvent(eventName, null, fullMetadata)
|
Statsig.logEvent(eventName, null, fullMetadata)
|
||||||
|
@ -226,11 +237,11 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
|
||||||
let secondsActive = 0
|
let secondsActive = 0
|
||||||
if (lastActive != null) {
|
if (lastActive != null) {
|
||||||
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
secondsActive = Math.round((performance.now() - lastActive) / 1e3)
|
||||||
|
lastActive = null
|
||||||
|
logEvent('state:background:sampled', {
|
||||||
|
secondsActive,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
lastActive = null
|
|
||||||
logEvent('state:background:sampled', {
|
|
||||||
secondsActive,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
||||||
return AppLanguage.ko
|
return AppLanguage.ko
|
||||||
case 'pt-BR':
|
case 'pt-BR':
|
||||||
return AppLanguage.pt_BR
|
return AppLanguage.pt_BR
|
||||||
|
case 'ru':
|
||||||
|
return AppLanguage.ru
|
||||||
case 'tr':
|
case 'tr':
|
||||||
return AppLanguage.tr
|
return AppLanguage.tr
|
||||||
case 'uk':
|
case 'uk':
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {messages as messagesIt} from '#/locale/locales/it/messages'
|
||||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||||
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
||||||
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||||
|
import {messages as messagesRu} from '#/locale/locales/ru/messages'
|
||||||
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
||||||
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
||||||
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
||||||
|
@ -37,82 +38,138 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case AppLanguage.ca: {
|
case AppLanguage.ca: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesCa})
|
i18n.loadAndActivate({locale, messages: messagesCa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ca')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ca'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ca'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.de: {
|
case AppLanguage.de: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesDe})
|
i18n.loadAndActivate({locale, messages: messagesDe})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/de')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/de'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/de'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.es: {
|
case AppLanguage.es: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesEs})
|
i18n.loadAndActivate({locale, messages: messagesEs})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/es')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/es'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/es'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.fi: {
|
case AppLanguage.fi: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesFi})
|
i18n.loadAndActivate({locale, messages: messagesFi})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/fi')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/fi'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/fi'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.fr: {
|
case AppLanguage.fr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesFr})
|
i18n.loadAndActivate({locale, messages: messagesFr})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/fr')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/fr'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/fr'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ga: {
|
case AppLanguage.ga: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesGa})
|
i18n.loadAndActivate({locale, messages: messagesGa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ga')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ga'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ga'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.hi: {
|
case AppLanguage.hi: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesHi})
|
i18n.loadAndActivate({locale, messages: messagesHi})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/hi')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/hi'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/hi'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.id: {
|
case AppLanguage.id: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesId})
|
i18n.loadAndActivate({locale, messages: messagesId})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/id')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/id'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/id'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.it: {
|
case AppLanguage.it: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesIt})
|
i18n.loadAndActivate({locale, messages: messagesIt})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/it')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/it'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/it'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ja: {
|
case AppLanguage.ja: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesJa})
|
i18n.loadAndActivate({locale, messages: messagesJa})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ja')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ja'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ja'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.ko: {
|
case AppLanguage.ko: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesKo})
|
i18n.loadAndActivate({locale, messages: messagesKo})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/ko')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ko'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ko'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.pt_BR: {
|
case AppLanguage.pt_BR: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
i18n.loadAndActivate({locale, messages: messagesPt_BR})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/pt')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/pt'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/pt'),
|
||||||
|
])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case AppLanguage.ru: {
|
||||||
|
i18n.loadAndActivate({locale, messages: messagesRu})
|
||||||
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/ru'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/ru'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/tr')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/tr'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/tr'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.uk: {
|
case AppLanguage.uk: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesUk})
|
i18n.loadAndActivate({locale, messages: messagesUk})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/uk')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/uk'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/uk'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.zh_CN: {
|
case AppLanguage.zh_CN: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
i18n.loadAndActivate({locale, messages: messagesZh_CN})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case AppLanguage.zh_TW: {
|
case AppLanguage.zh_TW: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
i18n.loadAndActivate({locale, messages: messagesZh_TW})
|
||||||
await import('@formatjs/intl-pluralrules/locale-data/zh')
|
await Promise.all([
|
||||||
|
import('@formatjs/intl-pluralrules/locale-data/zh'),
|
||||||
|
import('@formatjs/intl-numberformat/locale-data/zh'),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
|
@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
||||||
mod = await import(`./locales/pt-BR/messages`)
|
mod = await import(`./locales/pt-BR/messages`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case AppLanguage.ru: {
|
||||||
|
mod = await import(`./locales/ru/messages`)
|
||||||
|
break
|
||||||
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
mod = await import(`./locales/tr/messages`)
|
mod = await import(`./locales/tr/messages`)
|
||||||
break
|
break
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -23,6 +23,7 @@ import {
|
||||||
useSaveMessageDraft,
|
useSaveMessageDraft,
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isIOS} from 'platform/detection'
|
import {isIOS} from 'platform/detection'
|
||||||
|
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
|
@ -41,6 +42,7 @@ export function MessageInput({
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
|
@ -12,9 +12,16 @@ import {
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isSafari, isTouchDevice} from 'lib/browser'
|
import {isSafari, isTouchDevice} from 'lib/browser'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
|
import {
|
||||||
|
Emoji,
|
||||||
|
EmojiPickerPosition,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
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 {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
|
@ -23,11 +30,13 @@ export function MessageInput({
|
||||||
hasEmbed,
|
hasEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
children,
|
children,
|
||||||
|
openEmojiPicker,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
@ -40,6 +49,7 @@ export function MessageInput({
|
||||||
const [isFocused, setIsFocused] = React.useState(false)
|
const [isFocused, setIsFocused] = React.useState(false)
|
||||||
const [isHovered, setIsHovered] = React.useState(false)
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||||
|
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => {
|
const onSubmit = React.useCallback(() => {
|
||||||
if (!hasEmbed && message.trim() === '') {
|
if (!hasEmbed && message.trim() === '') {
|
||||||
|
@ -94,6 +104,23 @@ export function MessageInput({
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onEmojiInserted = React.useCallback(
|
||||||
|
(emoji: Emoji) => {
|
||||||
|
const position = textAreaRef.current?.selectionStart ?? 0
|
||||||
|
setMessage(
|
||||||
|
message =>
|
||||||
|
message.slice(0, position) + emoji.native + message.slice(position),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setMessage],
|
||||||
|
)
|
||||||
|
React.useEffect(() => {
|
||||||
|
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||||
|
return () => {
|
||||||
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||||
|
}
|
||||||
|
}, [onEmojiInserted])
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
useSaveMessageDraft(message)
|
||||||
useExtractEmbedFromFacets(message, setEmbed)
|
useExtractEmbedFromFacets(message, setEmbed)
|
||||||
|
|
||||||
|
@ -106,7 +133,7 @@ export function MessageInput({
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
paddingRight: a.p_sm.padding - 2,
|
paddingRight: a.p_sm.padding - 2,
|
||||||
paddingLeft: a.p_md.padding - 2,
|
paddingLeft: a.p_sm.padding - 2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 23,
|
borderRadius: 23,
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
|
@ -118,7 +145,44 @@ export function MessageInput({
|
||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}>
|
onMouseLeave={() => setIsHovered(false)}>
|
||||||
|
<Button
|
||||||
|
onPress={e => {
|
||||||
|
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||||
|
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
marginTop: 5,
|
||||||
|
height: 30,
|
||||||
|
width: 30,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={_(msg`Open emoji picker`)}>
|
||||||
|
{state => (
|
||||||
|
<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>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
|
ref={textAreaRef}
|
||||||
style={StyleSheet.flatten([
|
style={StyleSheet.flatten([
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.px_sm,
|
a.px_sm,
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
import React, {useCallback, useEffect, useMemo, useState} from 'react'
|
||||||
import {LayoutAnimation, View} from 'react-native'
|
import {LayoutAnimation, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyEmbedImages,
|
|
||||||
AppBskyEmbedRecordWithMedia,
|
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AppBskyRichtextFacet,
|
AppBskyRichtextFacet,
|
||||||
AtUri,
|
AtUri,
|
||||||
|
@ -22,12 +20,12 @@ import {
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
import {usePostQuery} from '#/state/queries/post'
|
import {usePostQuery} from '#/state/queries/post'
|
||||||
import {ImageHorzList} from '#/view/com/util/images/ImageHorzList'
|
|
||||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon} from '#/components/Button'
|
import {Button, ButtonIcon} from '#/components/Button'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
|
import * as MediaPreview from '#/components/MediaPreview'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
|
@ -160,13 +158,6 @@ export function MessageInputEmbed({
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const images = AppBskyEmbedImages.isView(post.embed)
|
|
||||||
? post.embed.images
|
|
||||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
|
||||||
AppBskyEmbedImages.isView(post.embed.media)
|
|
||||||
? post.embed.media.images
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
content = (
|
content = (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
|
@ -202,9 +193,7 @@ export function MessageInputEmbed({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{images && images?.length > 0 && (
|
<MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
|
||||||
<ImageHorzList images={images} style={a.mt_xs} />
|
|
||||||
)}
|
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
|
||||||
import {clamp} from 'lib/numbers'
|
import {clamp} from 'lib/numbers'
|
||||||
import {ScrollProvider} from 'lib/ScrollContext'
|
import {ScrollProvider} from 'lib/ScrollContext'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {
|
||||||
|
EmojiPicker,
|
||||||
|
EmojiPickerState,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
||||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||||
|
@ -97,6 +101,12 @@ export function MessagesList({
|
||||||
startContentOffset: 0,
|
startContentOffset: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [emojiPickerState, setEmojiPickerState] =
|
||||||
|
React.useState<EmojiPickerState>({
|
||||||
|
isOpen: false,
|
||||||
|
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||||
|
})
|
||||||
|
|
||||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||||
// the bottom.
|
// the bottom.
|
||||||
|
@ -422,13 +432,22 @@ export function MessagesList({
|
||||||
<MessageInput
|
<MessageInput
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
hasEmbed={!!embedUri}
|
hasEmbed={!!embedUri}
|
||||||
setEmbed={setEmbed}>
|
setEmbed={setEmbed}
|
||||||
|
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
</MessageInput>
|
</MessageInput>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardStickyView>
|
</KeyboardStickyView>
|
||||||
|
|
||||||
|
{isWeb && (
|
||||||
|
<EmojiPicker
|
||||||
|
pinToTop
|
||||||
|
state={emojiPickerState}
|
||||||
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {capitalize} from '#/lib/strings/capitalize'
|
import {capitalize} from '#/lib/strings/capitalize'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {
|
import {
|
||||||
|
@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
|
||||||
import {IconCircle} from '#/components/IconCircle'
|
import {IconCircle} from '#/components/IconCircle'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
||||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
const PROMPT_HEIGHT = isWeb ? 42 : 36
|
|
||||||
// matches the padding of the OnboardingControls.Portal
|
|
||||||
const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
|
|
||||||
const MIN_INTERESTS = 3
|
|
||||||
|
|
||||||
export function StepInterests() {
|
export function StepInterests() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const gate = useGate()
|
|
||||||
const interestsDisplayNames = useInterestsDisplayNames()
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
|
||||||
const {state, dispatch} = React.useContext(Context)
|
const {state, dispatch} = React.useContext(Context)
|
||||||
|
@ -143,12 +134,6 @@ export function StepInterests() {
|
||||||
track('OnboardingV2:StepInterests:Start')
|
track('OnboardingV2:StepInterests:Start')
|
||||||
}, [track])
|
}, [track])
|
||||||
|
|
||||||
const isMinimumInterestsEnabled =
|
|
||||||
gate('onboarding_minimum_interests') && data?.interests.length !== 0
|
|
||||||
const meetsMinimumRequirement = isMinimumInterestsEnabled
|
|
||||||
? interests.length >= MIN_INTERESTS
|
|
||||||
: true
|
|
||||||
|
|
||||||
const title = isError ? (
|
const title = isError ? (
|
||||||
<Trans>Oh no! Something went wrong.</Trans>
|
<Trans>Oh no! Something went wrong.</Trans>
|
||||||
) : (
|
) : (
|
||||||
|
@ -186,13 +171,8 @@ export function StepInterests() {
|
||||||
|
|
||||||
<TitleText>{title}</TitleText>
|
<TitleText>{title}</TitleText>
|
||||||
<DescriptionText>{description}</DescriptionText>
|
<DescriptionText>{description}</DescriptionText>
|
||||||
{isMinimumInterestsEnabled && (
|
|
||||||
<DescriptionText style={[a.pt_sm]}>
|
|
||||||
<Trans>Choose 3 or more:</Trans>
|
|
||||||
</DescriptionText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[a.w_full, isMinimumInterestsEnabled ? a.pt_md : a.pt_2xl]}>
|
<View style={[a.w_full, a.pt_2xl]}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
) : isError || !data ? (
|
) : isError || !data ? (
|
||||||
|
@ -268,7 +248,7 @@ export function StepInterests() {
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
disabled={saving || !data || !meetsMinimumRequirement}
|
disabled={saving || !data}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
color="gradient_sky"
|
color="gradient_sky"
|
||||||
size="large"
|
size="large"
|
||||||
|
@ -283,53 +263,6 @@ export function StepInterests() {
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetsMinimumRequirement && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
margin: 'auto',
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY:
|
|
||||||
-1 *
|
|
||||||
(PROMPT_OFFSET + PROMPT_HEIGHT + a.pb_lg.paddingBottom),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
|
||||||
a.rounded_full,
|
|
||||||
a.border,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
t.atoms.border_contrast_medium,
|
|
||||||
{
|
|
||||||
height: PROMPT_HEIGHT,
|
|
||||||
...t.atoms.shadow_sm,
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
},
|
|
||||||
isWeb
|
|
||||||
? [a.py_md, a.px_lg, a.pr_xl]
|
|
||||||
: [a.py_sm, a.px_md, a.pr_lg],
|
|
||||||
]}>
|
|
||||||
<CircleInfo />
|
|
||||||
<Text>
|
|
||||||
<Trans>
|
|
||||||
Choose at least {MIN_INTERESTS - interests.length} more
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</OnboardingControls.Portal>
|
</OnboardingControls.Portal>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,11 +6,9 @@ import {
|
||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
|
@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
|
||||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
|
@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
|
||||||
}: Props): React.ReactNode => {
|
}: Props): React.ReactNode => {
|
||||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||||
useProfileShadow(profileUnshadowed)
|
useProfileShadow(profileUnshadowed)
|
||||||
const t = useTheme()
|
|
||||||
const gate = useGate()
|
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
|
@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
|
||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
[profile, moderationOpts],
|
[profile, moderationOpts],
|
||||||
)
|
)
|
||||||
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
|
||||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||||
profile,
|
profile,
|
||||||
'ProfileHeader',
|
'ProfileHeader',
|
||||||
|
@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
|
||||||
)
|
)
|
||||||
) : !profile.viewer?.blockedBy ? (
|
) : !profile.viewer?.blockedBy ? (
|
||||||
<>
|
<>
|
||||||
{hasSession && (
|
{hasSession && <MessageProfileButton profile={profile} />}
|
||||||
<>
|
|
||||||
<MessageProfileButton profile={profile} />
|
|
||||||
{!gate('show_follow_suggestions_in_profile') && (
|
|
||||||
<Button
|
|
||||||
testID="suggestedFollowsBtn"
|
|
||||||
size="small"
|
|
||||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
|
||||||
variant="solid"
|
|
||||||
shape="round"
|
|
||||||
onPress={() =>
|
|
||||||
setShowSuggestedFollows(!showSuggestedFollows)
|
|
||||||
}
|
|
||||||
label={_(msg`Show follows similar to ${profile.handle}`)}
|
|
||||||
style={{width: 36, height: 36}}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="user-plus"
|
|
||||||
style={
|
|
||||||
showSuggestedFollows
|
|
||||||
? {color: t.palette.white}
|
|
||||||
: t.atoms.text
|
|
||||||
}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||||
|
@ -294,19 +261,6 @@ let ProfileHeaderStandard = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{showSuggestedFollows && (
|
|
||||||
<ProfileHeaderSuggestedFollows
|
|
||||||
actorDid={profile.did}
|
|
||||||
requestDismiss={() => {
|
|
||||||
if (showSuggestedFollows) {
|
|
||||||
setShowSuggestedFollows(false)
|
|
||||||
} else {
|
|
||||||
track('ProfileHeader:SuggestedFollowsOpened')
|
|
||||||
setShowSuggestedFollows(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={unblockPromptControl}
|
control={unblockPromptControl}
|
||||||
title={_(msg`Unblock Account?`)}
|
title={_(msg`Unblock Account?`)}
|
||||||
|
|
|
@ -86,7 +86,7 @@ let ProfileHeaderShell = ({
|
||||||
style={[a.px_lg, a.py_xs]}
|
style={[a.px_lg, a.py_xs]}
|
||||||
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
pointerEvents={isIOS ? 'auto' : 'box-none'}>
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
<LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
|
<LabelsOnMe type="account" labels={profile.labels} />
|
||||||
) : (
|
) : (
|
||||||
<ProfileHeaderAlerts moderation={moderation} />
|
<ProfileHeaderAlerts moderation={moderation} />
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -99,8 +99,8 @@ export function useGetPosts() {
|
||||||
|
|
||||||
export function usePostLikeMutationQueue(
|
export function usePostLikeMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:like']['logContext'] &
|
logContext: LogEvents['post:like:sampled']['logContext'] &
|
||||||
LogEvents['post:unlike']['logContext'],
|
LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
|
@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePostLikeMutation(
|
function usePostLikeMutation(
|
||||||
logContext: LogEvents['post:like']['logContext'],
|
logContext: LogEvents['post:like:sampled']['logContext'],
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
@ -175,7 +175,7 @@ function usePostLikeMutation(
|
||||||
if (currentAccount) {
|
if (currentAccount) {
|
||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
logEvent('post:like', {
|
logEvent('post:like:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
doesPosterFollowLiker: postAuthor.viewer
|
doesPosterFollowLiker: postAuthor.viewer
|
||||||
? Boolean(postAuthor.viewer.followedBy)
|
? Boolean(postAuthor.viewer.followedBy)
|
||||||
|
@ -200,12 +200,12 @@ function usePostLikeMutation(
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnlikeMutation(
|
function usePostUnlikeMutation(
|
||||||
logContext: LogEvents['post:unlike']['logContext'],
|
logContext: LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
||||||
mutationFn: ({likeUri}) => {
|
mutationFn: ({likeUri}) => {
|
||||||
logEvent('post:unlike', {logContext})
|
logEvent('post:unlike:sampled', {logContext})
|
||||||
return agent.deleteLike(likeUri)
|
return agent.deleteLike(likeUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
@ -216,8 +216,8 @@ function usePostUnlikeMutation(
|
||||||
|
|
||||||
export function usePostRepostMutationQueue(
|
export function usePostRepostMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:repost']['logContext'] &
|
logContext: LogEvents['post:repost:sampled']['logContext'] &
|
||||||
LogEvents['post:unrepost']['logContext'],
|
LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
|
@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePostRepostMutation(
|
function usePostRepostMutation(
|
||||||
logContext: LogEvents['post:repost']['logContext'],
|
logContext: LogEvents['post:repost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<
|
return useMutation<
|
||||||
|
@ -282,7 +282,7 @@ function usePostRepostMutation(
|
||||||
{uri: string; cid: string} // the post's uri and cid
|
{uri: string; cid: string} // the post's uri and cid
|
||||||
>({
|
>({
|
||||||
mutationFn: post => {
|
mutationFn: post => {
|
||||||
logEvent('post:repost', {logContext})
|
logEvent('post:repost:sampled', {logContext})
|
||||||
return agent.repost(post.uri, post.cid)
|
return agent.repost(post.uri, post.cid)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
@ -292,12 +292,12 @@ function usePostRepostMutation(
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnrepostMutation(
|
function usePostUnrepostMutation(
|
||||||
logContext: LogEvents['post:unrepost']['logContext'],
|
logContext: LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
||||||
mutationFn: ({repostUri}) => {
|
mutationFn: ({repostUri}) => {
|
||||||
logEvent('post:unrepost', {logContext})
|
logEvent('post:unrepost:sampled', {logContext})
|
||||||
return agent.deleteRepost(repostUri)
|
return agent.deleteRepost(repostUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|
|
@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
|
||||||
|
|
||||||
export function useProfileFollowMutationQueue(
|
export function useProfileFollowMutationQueue(
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext'],
|
LogEvents['profile:follow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
|
||||||
}
|
}
|
||||||
|
|
||||||
function useProfileFollowMutation(
|
function useProfileFollowMutation(
|
||||||
logContext: LogEvents['profile:follow']['logContext'],
|
logContext: LogEvents['profile:follow:sampled']['logContext'],
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
|
@ -306,7 +306,7 @@ function useProfileFollowMutation(
|
||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
captureAction(ProgressGuideAction.Follow)
|
captureAction(ProgressGuideAction.Follow)
|
||||||
logEvent('profile:follow', {
|
logEvent('profile:follow:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
didBecomeMutual: profile.viewer
|
didBecomeMutual: profile.viewer
|
||||||
? Boolean(profile.viewer.followedBy)
|
? Boolean(profile.viewer.followedBy)
|
||||||
|
@ -323,12 +323,12 @@ function useProfileFollowMutation(
|
||||||
}
|
}
|
||||||
|
|
||||||
function useProfileUnfollowMutation(
|
function useProfileUnfollowMutation(
|
||||||
logContext: LogEvents['profile:unfollow']['logContext'],
|
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {did: string; followUri: string}>({
|
return useMutation<void, Error, {did: string; followUri: string}>({
|
||||||
mutationFn: async ({followUri}) => {
|
mutationFn: async ({followUri}) => {
|
||||||
logEvent('profile:unfollow', {logContext})
|
logEvent('profile:unfollow:sampled', {logContext})
|
||||||
track('Profile:Unfollow', {username: followUri})
|
track('Profile:Unfollow', {username: followUri})
|
||||||
return await agent.deleteFollow(followUri)
|
return await agent.deleteFollow(followUri)
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,7 +20,7 @@ export function useCompressVideoMutation({
|
||||||
mutationKey: ['video', 'compress'],
|
mutationKey: ['video', 'compress'],
|
||||||
mutationFn: cancelable(
|
mutationFn: cancelable(
|
||||||
(asset: ImagePickerAsset) =>
|
(asset: ImagePickerAsset) =>
|
||||||
compressVideo(asset.uri, {
|
compressVideo(asset, {
|
||||||
onProgress: num => onProgress(trunc2dp(num)),
|
onProgress: num => onProgress(trunc2dp(num)),
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -1,15 +1,13 @@
|
||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {AtpAgent} from '@atproto/api'
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
|
||||||
import {SupportedMimeTypes} from '#/lib/constants'
|
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||||
|
|
||||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
|
||||||
|
|
||||||
export const createVideoEndpointUrl = (
|
export const createVideoEndpointUrl = (
|
||||||
route: string,
|
route: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
) => {
|
) => {
|
||||||
const url = new URL(`${UPLOAD_ENDPOINT}`)
|
const url = new URL(VIDEO_SERVICE)
|
||||||
url.pathname = route
|
url.pathname = route
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
|
@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
|
||||||
export function useVideoAgent() {
|
export function useVideoAgent() {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return new AtpAgent({
|
return new AtpAgent({
|
||||||
service: UPLOAD_ENDPOINT,
|
service: VIDEO_SERVICE,
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
|
@ -41,3 +39,18 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
||||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extToMime(ext: string) {
|
||||||
|
switch (ext) {
|
||||||
|
case 'mp4':
|
||||||
|
return 'video/mp4'
|
||||||
|
case 'webm':
|
||||||
|
return 'video/webm'
|
||||||
|
case 'mpeg':
|
||||||
|
return 'video/mpeg'
|
||||||
|
case 'mov':
|
||||||
|
return 'video/quicktime'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported file extension: ${ext}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import {useCallback} from 'react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||||
|
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||||
|
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useVideoAgent} from './util'
|
||||||
|
|
||||||
|
export function useServiceAuthToken({
|
||||||
|
aud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
}: {
|
||||||
|
aud?: string
|
||||||
|
lxm: string
|
||||||
|
exp?: number
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
|
||||||
|
if (!pdsAud) {
|
||||||
|
throw new Error('Agent does not have a PDS URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||||
|
aud: aud ?? pdsAud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
|
||||||
|
return serviceAuth.token
|
||||||
|
}, [agent, aud, lxm, exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoUploadLimits() {
|
||||||
|
const agent = useVideoAgent()
|
||||||
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'app.bsky.video.getUploadLimits',
|
||||||
|
aud: VIDEO_SERVICE_DID,
|
||||||
|
})
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const {data: limits} = await agent.app.bsky.video
|
||||||
|
.getUploadLimits(
|
||||||
|
{},
|
||||||
|
{headers: {Authorization: `Bearer ${await getToken()}`}},
|
||||||
|
)
|
||||||
|
.catch(err => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw new UploadLimitError(err.message)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!limits.canUpload) {
|
||||||
|
if (limits.message) {
|
||||||
|
throw new UploadLimitError(limits.message)
|
||||||
|
} else {
|
||||||
|
throw new UploadLimitError(
|
||||||
|
_(
|
||||||
|
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [agent, _, getToken])
|
||||||
|
}
|
|
@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const uploadTask = createUploadTask(
|
const uploadTask = createUploadTask(
|
||||||
uri,
|
uri,
|
||||||
video.uri,
|
video.uri,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': video.mimeType,
|
'content-type': video.mimeType,
|
||||||
Authorization: `Bearer ${serviceAuth.token}`,
|
Authorization: `Bearer ${await getToken()}`,
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
|
|
@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
let bytes = video.bytes
|
let bytes = video.bytes
|
||||||
|
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = await getToken()
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
|
@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
|
||||||
}
|
}
|
||||||
xhr.open('POST', uri)
|
xhr.open('POST', uri)
|
||||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
xhr.send(bytes)
|
xhr.send(bytes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback, useEffect} from 'react'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
|
import {
|
||||||
|
ServerError,
|
||||||
|
UploadLimitError,
|
||||||
|
VideoTooLargeError,
|
||||||
|
} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from 'lib/media/video/types'
|
import {CompressedVideo} from 'lib/media/video/types'
|
||||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||||
import {useVideoAgent} from 'state/queries/video/util'
|
import {useVideoAgent} from 'state/queries/video/util'
|
||||||
|
@ -25,7 +30,7 @@ type Action =
|
||||||
| {type: 'SetDimensions'; width: number; height: number}
|
| {type: 'SetDimensions'; width: number; height: number}
|
||||||
| {type: 'SetVideo'; video: CompressedVideo}
|
| {type: 'SetVideo'; video: CompressedVideo}
|
||||||
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||||
| {type: 'SetBlobRef'; blobRef: BlobRef}
|
| {type: 'SetComplete'; blobRef: BlobRef}
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
status: Status
|
status: Status
|
||||||
|
@ -36,8 +41,11 @@ export interface State {
|
||||||
blobRef?: BlobRef
|
blobRef?: BlobRef
|
||||||
error?: string
|
error?: string
|
||||||
abortController: AbortController
|
abortController: AbortController
|
||||||
|
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoUploadDispatch = (action: Action) => void
|
||||||
|
|
||||||
function reducer(queryClient: QueryClient) {
|
function reducer(queryClient: QueryClient) {
|
||||||
return (state: State, action: Action): State => {
|
return (state: State, action: Action): State => {
|
||||||
let updatedState = state
|
let updatedState = state
|
||||||
|
@ -77,8 +85,15 @@ function reducer(queryClient: QueryClient) {
|
||||||
updatedState = {...state, video: action.video, status: 'uploading'}
|
updatedState = {...state, video: action.video, status: 'uploading'}
|
||||||
} else if (action.type === 'SetJobStatus') {
|
} else if (action.type === 'SetJobStatus') {
|
||||||
updatedState = {...state, jobStatus: action.jobStatus}
|
updatedState = {...state, jobStatus: action.jobStatus}
|
||||||
} else if (action.type === 'SetBlobRef') {
|
} else if (action.type === 'SetComplete') {
|
||||||
updatedState = {...state, blobRef: action.blobRef, status: 'done'}
|
updatedState = {
|
||||||
|
...state,
|
||||||
|
pendingPublish: {
|
||||||
|
blobRef: action.blobRef,
|
||||||
|
mutableProcessed: false,
|
||||||
|
},
|
||||||
|
status: 'done',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return updatedState
|
return updatedState
|
||||||
}
|
}
|
||||||
|
@ -86,7 +101,6 @@ function reducer(queryClient: QueryClient) {
|
||||||
|
|
||||||
export function useUploadVideo({
|
export function useUploadVideo({
|
||||||
setStatus,
|
setStatus,
|
||||||
onSuccess,
|
|
||||||
}: {
|
}: {
|
||||||
setStatus: (status: string) => void
|
setStatus: (status: string) => void
|
||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
|
@ -112,11 +126,20 @@ export function useUploadVideo({
|
||||||
},
|
},
|
||||||
onSuccess: blobRef => {
|
onSuccess: blobRef => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetBlobRef',
|
type: 'SetComplete',
|
||||||
blobRef,
|
blobRef,
|
||||||
})
|
})
|
||||||
onSuccess()
|
|
||||||
},
|
},
|
||||||
|
onError: useCallback(
|
||||||
|
error => {
|
||||||
|
logger.error('Error processing video', {safeMessage: error})
|
||||||
|
dispatch({
|
||||||
|
type: 'SetError',
|
||||||
|
error: _(msg`Video failed to process`),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[_],
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
const {mutate: onVideoCompressed} = useUploadVideoMutation({
|
||||||
|
@ -128,10 +151,42 @@ export function useUploadVideo({
|
||||||
setJobId(response.jobId)
|
setJobId(response.jobId)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
if (e instanceof ServerError) {
|
if (e instanceof AbortError) {
|
||||||
|
return
|
||||||
|
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
|
||||||
|
let message
|
||||||
|
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
|
||||||
|
switch (e.message) {
|
||||||
|
case 'User is not allowed to upload videos':
|
||||||
|
message = _(msg`You are not allowed to upload videos.`)
|
||||||
|
break
|
||||||
|
case 'Uploading is disabled at the moment':
|
||||||
|
message = _(
|
||||||
|
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "Failed to get user's upload stats":
|
||||||
|
message = _(
|
||||||
|
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload bytes limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many bytes)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload videos limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many videos)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = e.message
|
||||||
|
break
|
||||||
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: e.message,
|
error: message,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
@ -159,7 +214,9 @@ export function useUploadVideo({
|
||||||
onVideoCompressed(video)
|
onVideoCompressed(video)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
if (e instanceof VideoTooLargeError) {
|
if (e instanceof AbortError) {
|
||||||
|
return
|
||||||
|
} else if (e instanceof VideoTooLargeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: _(msg`The selected video is larger than 100MB.`),
|
error: _(msg`The selected video is larger than 100MB.`),
|
||||||
|
@ -215,15 +272,17 @@ export function useUploadVideo({
|
||||||
const useUploadStatusQuery = ({
|
const useUploadStatusQuery = ({
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
onError,
|
||||||
}: {
|
}: {
|
||||||
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
||||||
onSuccess: (blobRef: BlobRef) => void
|
onSuccess: (blobRef: BlobRef) => void
|
||||||
|
onError: (error: Error) => void
|
||||||
}) => {
|
}) => {
|
||||||
const videoAgent = useVideoAgent()
|
const videoAgent = useVideoAgent()
|
||||||
const [enabled, setEnabled] = React.useState(true)
|
const [enabled, setEnabled] = React.useState(true)
|
||||||
const [jobId, setJobId] = React.useState<string>()
|
const [jobId, setJobId] = React.useState<string>()
|
||||||
|
|
||||||
const {isLoading, isError} = useQuery({
|
const {error} = useQuery({
|
||||||
queryKey: ['video', 'upload status', jobId],
|
queryKey: ['video', 'upload status', jobId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!jobId) return // this won't happen, can ignore
|
if (!jobId) return // this won't happen, can ignore
|
||||||
|
@ -236,7 +295,7 @@ const useUploadStatusQuery = ({
|
||||||
throw new Error('Job completed, but did not return a blob')
|
throw new Error('Job completed, but did not return a blob')
|
||||||
onSuccess(status.blob)
|
onSuccess(status.blob)
|
||||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||||
throw new Error('Job failed to process')
|
throw new Error(status.error ?? 'Job failed to process')
|
||||||
}
|
}
|
||||||
onStatusChange(status)
|
onStatusChange(status)
|
||||||
return status
|
return status
|
||||||
|
@ -245,9 +304,14 @@ const useUploadStatusQuery = ({
|
||||||
refetchInterval: 1500,
|
refetchInterval: 1500,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
onError(error)
|
||||||
|
setEnabled(false)
|
||||||
|
}
|
||||||
|
}, [error, onError])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
setJobId: (_jobId: string) => {
|
setJobId: (_jobId: string) => {
|
||||||
setJobId(_jobId)
|
setJobId(_jobId)
|
||||||
setEnabled(true)
|
setEnabled(true)
|
||||||
|
|
|
@ -34,7 +34,7 @@ export interface ComposerOpts {
|
||||||
quote?: ComposerOptsQuote
|
quote?: ComposerOptsQuote
|
||||||
quoteCount?: number
|
quoteCount?: number
|
||||||
mention?: string // handle of user to mention
|
mention?: string // handle of user to mention
|
||||||
openPicker?: (pos: DOMRect | undefined) => void
|
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||||
text?: string
|
text?: string
|
||||||
imageUris?: {uri: string; width: number; height: number}[]
|
imageUris?: {uri: string; width: number; height: number}[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
# `#/storage`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Import the correctly scoped store from `#/storage`. Each instance of `Storage`
|
||||||
|
(the base class, not to be used directly), has the following interface:
|
||||||
|
|
||||||
|
- `set([...scope, key], value)`
|
||||||
|
- `get([...scope, key])`
|
||||||
|
- `remove([...scope, key])`
|
||||||
|
- `removeMany([...scope], [...keys])`
|
||||||
|
|
||||||
|
For example, using our `device` store looks like this, since it's scoped to the
|
||||||
|
device (the most base level scope):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { device } from '#/storage';
|
||||||
|
|
||||||
|
device.set(['foobar'], true);
|
||||||
|
device.get(['foobar']);
|
||||||
|
device.remove(['foobar']);
|
||||||
|
device.removeMany([], ['foobar']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript
|
||||||
|
|
||||||
|
Stores are strongly typed, and when setting a given value, it will need to
|
||||||
|
conform to the schemas defined in `#/storage/schema`. When getting a value, it
|
||||||
|
will be returned to you as the type defined in its schema.
|
||||||
|
|
||||||
|
## Scoped Stores
|
||||||
|
|
||||||
|
Some stores are (or might be) scoped to an account or other identifier. In this
|
||||||
|
case, storage instances are created with type-guards, like this:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type AccountSchema = {
|
||||||
|
language: `${string}-${string}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DID = `did:${string}`;
|
||||||
|
|
||||||
|
const account = new Storage<
|
||||||
|
[DID],
|
||||||
|
AccountSchema
|
||||||
|
>({
|
||||||
|
id: 'account',
|
||||||
|
});
|
||||||
|
|
||||||
|
account.set(
|
||||||
|
['did:plc:abc', 'language'],
|
||||||
|
'en-US',
|
||||||
|
);
|
||||||
|
|
||||||
|
const language = account.get([
|
||||||
|
'did:plc:abc',
|
||||||
|
'language',
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
Here, if `['did:plc:abc']` is not supplied along with the key of
|
||||||
|
`language`, the `get` will return undefined (and TS will yell at you).
|
|
@ -0,0 +1,81 @@
|
||||||
|
import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||||
|
|
||||||
|
import {Storage} from '#/storage'
|
||||||
|
|
||||||
|
jest.mock('react-native-mmkv', () => ({
|
||||||
|
MMKV: class MMKVMock {
|
||||||
|
_store = new Map()
|
||||||
|
|
||||||
|
set(key: string, value: unknown) {
|
||||||
|
this._store.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
getString(key: string) {
|
||||||
|
return this._store.get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string) {
|
||||||
|
return this._store.delete(key)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
type Schema = {
|
||||||
|
boo: boolean
|
||||||
|
str: string | null
|
||||||
|
num: number
|
||||||
|
obj: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = `account`
|
||||||
|
const store = new Storage<['account'], Schema>({id: 'test'})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.removeMany([scope], ['boo', 'str', 'num', 'obj'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`stores and retrieves data`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
store.set([scope, 'str'], 'string')
|
||||||
|
store.set([scope, 'num'], 1)
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(true)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual('string')
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes data`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(true)
|
||||||
|
store.remove([scope, 'boo'])
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`removes multiple keys at once`, () => {
|
||||||
|
store.set([scope, 'boo'], true)
|
||||||
|
store.set([scope, 'str'], 'string')
|
||||||
|
store.set([scope, 'num'], 1)
|
||||||
|
store.removeMany([scope], ['boo', 'str', 'num'])
|
||||||
|
expect(store.get([scope, 'boo'])).toEqual(undefined)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual(undefined)
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`concatenates keys`, () => {
|
||||||
|
store.remove([scope, 'str'])
|
||||||
|
store.set([scope, 'str'], 'concat')
|
||||||
|
// @ts-ignore accessing these properties for testing purposes only
|
||||||
|
expect(store.store.getString(`${scope}${store.sep}str`)).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store falsy values`, () => {
|
||||||
|
store.set([scope, 'str'], null)
|
||||||
|
store.set([scope, 'num'], 0)
|
||||||
|
expect(store.get([scope, 'str'])).toEqual(null)
|
||||||
|
expect(store.get([scope, 'num'])).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`can store objects`, () => {
|
||||||
|
const obj = {foo: true}
|
||||||
|
store.set([scope, 'obj'], obj)
|
||||||
|
expect(store.get([scope, 'obj'])).toEqual(obj)
|
||||||
|
})
|
|
@ -0,0 +1,72 @@
|
||||||
|
import {MMKV} from 'react-native-mmkv'
|
||||||
|
|
||||||
|
import {Device} from '#/storage/schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic storage class. DO NOT use this directly. Instead, use the exported
|
||||||
|
* storage instances below.
|
||||||
|
*/
|
||||||
|
export class Storage<Scopes extends unknown[], Schema> {
|
||||||
|
protected sep = ':'
|
||||||
|
protected store: MMKV
|
||||||
|
|
||||||
|
constructor({id}: {id: string}) {
|
||||||
|
this.store = new MMKV({id})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a value in storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `set([key], value)`
|
||||||
|
* `set([scope, key], value)`
|
||||||
|
*/
|
||||||
|
set<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
data: Schema[Key],
|
||||||
|
): void {
|
||||||
|
// stored as `{ data: <value> }` structure to ease stringification
|
||||||
|
this.store.set(scopes.join(this.sep), JSON.stringify({data}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `get([key])`
|
||||||
|
* `get([scope, key])`
|
||||||
|
*/
|
||||||
|
get<Key extends keyof Schema>(
|
||||||
|
scopes: [...Scopes, Key],
|
||||||
|
): Schema[Key] | undefined {
|
||||||
|
const res = this.store.getString(scopes.join(this.sep))
|
||||||
|
if (!res) return undefined
|
||||||
|
// parsed from storage structure `{ data: <value> }`
|
||||||
|
return JSON.parse(res).data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value from storage based on scopes and/or keys
|
||||||
|
*
|
||||||
|
* `remove([key])`
|
||||||
|
* `remove([scope, key])`
|
||||||
|
*/
|
||||||
|
remove<Key extends keyof Schema>(scopes: [...Scopes, Key]) {
|
||||||
|
this.store.delete(scopes.join(this.sep))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove many values from the same storage scope by keys
|
||||||
|
*
|
||||||
|
* `removeMany([], [key])`
|
||||||
|
* `removeMany([scope], [key])`
|
||||||
|
*/
|
||||||
|
removeMany<Key extends keyof Schema>(scopes: [...Scopes], keys: Key[]) {
|
||||||
|
keys.forEach(key => this.remove([...scopes, key]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device data that's specific to the device and does not vary based on account
|
||||||
|
*
|
||||||
|
* `device.set([key], true)`
|
||||||
|
*/
|
||||||
|
export const device = new Storage<[], Device>({id: 'device'})
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Device data that's specific to the device and does not vary based account
|
||||||
|
*/
|
||||||
|
export type Device = {}
|
|
@ -20,12 +20,19 @@ import {
|
||||||
// @ts-expect-error no type definition
|
// @ts-expect-error no type definition
|
||||||
import ProgressCircle from 'react-native-progress/Circle'
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
Easing,
|
||||||
FadeIn,
|
FadeIn,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
|
LayoutAnimationConfig,
|
||||||
|
LinearTransition,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
withTiming,
|
withTiming,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
} from 'react-native-reanimated'
|
} from 'react-native-reanimated'
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import {
|
import {
|
||||||
|
@ -39,18 +46,31 @@ import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
|
import * as apilib from '#/lib/api/index'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
|
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
import {
|
import {
|
||||||
createGIFDescription,
|
createGIFDescription,
|
||||||
parseAltFromGIFDescription,
|
parseAltFromGIFDescription,
|
||||||
} from '#/lib/gif-alt-text'
|
} from '#/lib/gif-alt-text'
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||||
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||||
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
|
import {colors, s} from '#/lib/styles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useModals} from '#/state/modals'
|
import {useModals} from '#/state/modals'
|
||||||
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
toPostLanguages,
|
toPostLanguages,
|
||||||
|
@ -62,55 +82,45 @@ import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
||||||
import {useUploadVideo} from '#/state/queries/video/video'
|
import {
|
||||||
|
State as VideoUploadState,
|
||||||
|
useUploadVideo,
|
||||||
|
VideoUploadDispatch,
|
||||||
|
} from '#/state/queries/video/video'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {ComposerOpts} from '#/state/shell/composer'
|
||||||
import * as apilib from 'lib/api/index'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {GifAltText} from '#/view/com/composer/GifAltText'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
||||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
||||||
import {colors, s} from 'lib/styles'
|
import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
|
||||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
// TODO: Prevent naming components that coincide with RN primitives
|
||||||
import {State as VideoUploadState} from 'state/queries/video/video'
|
// due to linting false positives
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
|
||||||
|
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
|
||||||
|
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||||
|
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||||
|
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
||||||
|
import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
|
||||||
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
import * as Toast from '../util/Toast'
|
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
|
||||||
import {CharProgress} from './char-progress/CharProgress'
|
|
||||||
import {ExternalEmbed} from './ExternalEmbed'
|
|
||||||
import {GifAltText} from './GifAltText'
|
|
||||||
import {LabelsBtn} from './labels/LabelsBtn'
|
|
||||||
import {Gallery} from './photos/Gallery'
|
|
||||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
|
||||||
import {SelectGifBtn} from './photos/SelectGifBtn'
|
|
||||||
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
|
||||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
|
||||||
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
|
||||||
// TODO: Prevent naming components that coincide with RN primitives
|
|
||||||
// due to linting false positives
|
|
||||||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
|
||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
|
||||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
|
||||||
import {SubtitleDialogBtn} from './videos/SubtitleDialog'
|
|
||||||
import {VideoPreview} from './videos/VideoPreview'
|
|
||||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
|
@ -123,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
quoteCount,
|
quoteCount,
|
||||||
mention: initMention,
|
mention: initMention,
|
||||||
openPicker,
|
openEmojiPicker,
|
||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
|
@ -190,6 +200,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
|
||||||
|
@ -220,7 +232,12 @@ export const ComposePost = observer(function ComposePost({
|
||||||
)
|
)
|
||||||
|
|
||||||
const onPressCancel = useCallback(() => {
|
const onPressCancel = useCallback(() => {
|
||||||
if (graphemeLength > 0 || !gallery.isEmpty || extGif) {
|
if (
|
||||||
|
graphemeLength > 0 ||
|
||||||
|
!gallery.isEmpty ||
|
||||||
|
extGif ||
|
||||||
|
videoUploadState.status !== 'idle'
|
||||||
|
) {
|
||||||
closeAllDialogs()
|
closeAllDialogs()
|
||||||
Keyboard.dismiss()
|
Keyboard.dismiss()
|
||||||
discardPromptControl.open()
|
discardPromptControl.open()
|
||||||
|
@ -234,6 +251,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
closeAllDialogs,
|
closeAllDialogs,
|
||||||
discardPromptControl,
|
discardPromptControl,
|
||||||
onClose,
|
onClose,
|
||||||
|
videoUploadState.status,
|
||||||
])
|
])
|
||||||
|
|
||||||
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
useImperativeHandle(cancelRef, () => ({onPressCancel}))
|
||||||
|
@ -303,147 +321,188 @@ export const ComposePost = observer(function ComposePost({
|
||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async (finishedUploading?: boolean) => {
|
const onPressPublish = React.useCallback(
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
async (finishedUploading?: boolean) => {
|
||||||
return
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isAltTextRequiredAndMissing) {
|
if (isAltTextRequiredAndMissing) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!finishedUploading &&
|
!finishedUploading &&
|
||||||
videoUploadState.asset &&
|
videoUploadState.asset &&
|
||||||
videoUploadState.status !== 'done'
|
videoUploadState.status !== 'done'
|
||||||
) {
|
) {
|
||||||
setPublishOnUpload(true)
|
setPublishOnUpload(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
richtext.text.trim().length === 0 &&
|
richtext.text.trim().length === 0 &&
|
||||||
gallery.isEmpty &&
|
gallery.isEmpty &&
|
||||||
!extLink &&
|
!extLink &&
|
||||||
!quote
|
!quote &&
|
||||||
) {
|
videoUploadState.status === 'idle'
|
||||||
setError(_(msg`Did you want to say anything?`))
|
) {
|
||||||
return
|
setError(_(msg`Did you want to say anything?`))
|
||||||
}
|
return
|
||||||
if (extLink?.isLoading) {
|
}
|
||||||
setError(_(msg`Please wait for your link card to finish loading`))
|
if (extLink?.isLoading) {
|
||||||
return
|
setError(_(msg`Please wait for your link card to finish loading`))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
|
|
||||||
let postUri
|
let postUri
|
||||||
try {
|
|
||||||
postUri = (
|
|
||||||
await apilib.post(agent, {
|
|
||||||
rawText: richtext.text,
|
|
||||||
replyTo: replyTo?.uri,
|
|
||||||
images: gallery.images,
|
|
||||||
quote,
|
|
||||||
extLink,
|
|
||||||
labels,
|
|
||||||
threadgate: threadgateAllowUISettings,
|
|
||||||
postgate,
|
|
||||||
onStateChange: setProcessingState,
|
|
||||||
langs: toPostLanguages(langPrefs.postLanguage),
|
|
||||||
video: videoUploadState.blobRef
|
|
||||||
? {
|
|
||||||
blobRef: videoUploadState.blobRef,
|
|
||||||
altText: videoAltText,
|
|
||||||
captions: captions,
|
|
||||||
aspectRatio: videoUploadState.asset
|
|
||||||
? {
|
|
||||||
width: videoUploadState.asset?.width,
|
|
||||||
height: videoUploadState.asset?.height,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
).uri
|
|
||||||
try {
|
try {
|
||||||
await whenAppViewReady(agent, postUri, res => {
|
postUri = (
|
||||||
const thread = res.data.thread
|
await apilib.post(agent, {
|
||||||
return AppBskyFeedDefs.isThreadViewPost(thread)
|
rawText: richtext.text,
|
||||||
})
|
replyTo: replyTo?.uri,
|
||||||
} catch (waitErr: any) {
|
images: gallery.images,
|
||||||
logger.error(waitErr, {
|
quote,
|
||||||
message: `Waiting for app view failed`,
|
extLink,
|
||||||
})
|
labels,
|
||||||
// Keep going because the post *was* published.
|
threadgate: threadgateAllowUISettings,
|
||||||
}
|
postgate,
|
||||||
} catch (e: any) {
|
onStateChange: setProcessingState,
|
||||||
logger.error(e, {
|
langs: toPostLanguages(langPrefs.postLanguage),
|
||||||
message: `Composer: create post failed`,
|
video: videoUploadState.pendingPublish?.blobRef
|
||||||
hasImages: gallery.size > 0,
|
? {
|
||||||
})
|
blobRef: videoUploadState.pendingPublish.blobRef,
|
||||||
|
altText: videoAltText,
|
||||||
if (extLink) {
|
captions: captions,
|
||||||
setExtLink({
|
aspectRatio: videoUploadState.asset
|
||||||
...extLink,
|
? {
|
||||||
isLoading: true,
|
width: videoUploadState.asset?.width,
|
||||||
localThumb: undefined,
|
height: videoUploadState.asset?.height,
|
||||||
} as apilib.ExternalEmbedDraft)
|
}
|
||||||
}
|
: undefined,
|
||||||
let err = cleanError(e.message)
|
}
|
||||||
if (err.includes('not locate record')) {
|
: undefined,
|
||||||
err = _(
|
})
|
||||||
msg`We're sorry! The post you are replying to has been deleted.`,
|
).uri
|
||||||
)
|
try {
|
||||||
}
|
await whenAppViewReady(agent, postUri, res => {
|
||||||
setError(err)
|
const thread = res.data.thread
|
||||||
setIsProcessing(false)
|
return AppBskyFeedDefs.isThreadViewPost(thread)
|
||||||
return
|
})
|
||||||
} finally {
|
} catch (waitErr: any) {
|
||||||
if (postUri) {
|
logger.error(waitErr, {
|
||||||
logEvent('post:create', {
|
message: `Waiting for app view failed`,
|
||||||
imageCount: gallery.size,
|
})
|
||||||
isReply: replyTo != null,
|
// Keep going because the post *was* published.
|
||||||
hasLink: extLink != null,
|
|
||||||
hasQuote: quote != null,
|
|
||||||
langs: langPrefs.postLanguage,
|
|
||||||
logContext: 'Composer',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
track('Create Post', {
|
|
||||||
imageCount: gallery.size,
|
|
||||||
})
|
|
||||||
if (replyTo && replyTo.uri) track('Post:Reply')
|
|
||||||
}
|
|
||||||
if (postUri && !replyTo) {
|
|
||||||
emitPostCreated()
|
|
||||||
}
|
|
||||||
setLangPrefs.savePostLanguageToHistory()
|
|
||||||
if (quote) {
|
|
||||||
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
|
||||||
whenAppViewReady(agent, quote.uri, res => {
|
|
||||||
const thread = res.data.thread
|
|
||||||
if (
|
|
||||||
AppBskyFeedDefs.isThreadViewPost(thread) &&
|
|
||||||
thread.post.quoteCount !== quoteCount
|
|
||||||
) {
|
|
||||||
onPost?.(postUri)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
} catch (e: any) {
|
||||||
})
|
logger.error(e, {
|
||||||
} else {
|
message: `Composer: create post failed`,
|
||||||
onPost?.(postUri)
|
hasImages: gallery.size > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (extLink) {
|
||||||
|
setExtLink({
|
||||||
|
...extLink,
|
||||||
|
isLoading: true,
|
||||||
|
localThumb: undefined,
|
||||||
|
} as apilib.ExternalEmbedDraft)
|
||||||
|
}
|
||||||
|
let err = cleanError(e.message)
|
||||||
|
if (err.includes('not locate record')) {
|
||||||
|
err = _(
|
||||||
|
msg`We're sorry! The post you are replying to has been deleted.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setError(err)
|
||||||
|
setIsProcessing(false)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
if (postUri) {
|
||||||
|
logEvent('post:create', {
|
||||||
|
imageCount: gallery.size,
|
||||||
|
isReply: replyTo != null,
|
||||||
|
hasLink: extLink != null,
|
||||||
|
hasQuote: quote != null,
|
||||||
|
langs: langPrefs.postLanguage,
|
||||||
|
logContext: 'Composer',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
track('Create Post', {
|
||||||
|
imageCount: gallery.size,
|
||||||
|
})
|
||||||
|
if (replyTo && replyTo.uri) track('Post:Reply')
|
||||||
|
}
|
||||||
|
if (postUri && !replyTo) {
|
||||||
|
emitPostCreated()
|
||||||
|
}
|
||||||
|
setLangPrefs.savePostLanguageToHistory()
|
||||||
|
if (quote) {
|
||||||
|
// We want to wait for the quote count to update before we call `onPost`, which will refetch data
|
||||||
|
whenAppViewReady(agent, quote.uri, res => {
|
||||||
|
const thread = res.data.thread
|
||||||
|
if (
|
||||||
|
AppBskyFeedDefs.isThreadViewPost(thread) &&
|
||||||
|
thread.post.quoteCount !== quoteCount
|
||||||
|
) {
|
||||||
|
onPost?.(postUri)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onPost?.(postUri)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
Toast.show(
|
||||||
|
replyTo
|
||||||
|
? _(msg`Your reply has been published`)
|
||||||
|
: _(msg`Your post has been published`),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
_,
|
||||||
|
agent,
|
||||||
|
captions,
|
||||||
|
extLink,
|
||||||
|
gallery.images,
|
||||||
|
gallery.isEmpty,
|
||||||
|
gallery.size,
|
||||||
|
graphemeLength,
|
||||||
|
isAltTextRequiredAndMissing,
|
||||||
|
isProcessing,
|
||||||
|
labels,
|
||||||
|
langPrefs.postLanguage,
|
||||||
|
onClose,
|
||||||
|
onPost,
|
||||||
|
postgate,
|
||||||
|
quote,
|
||||||
|
quoteCount,
|
||||||
|
replyTo,
|
||||||
|
richtext.text,
|
||||||
|
setExtLink,
|
||||||
|
setLangPrefs,
|
||||||
|
threadgateAllowUISettings,
|
||||||
|
track,
|
||||||
|
videoAltText,
|
||||||
|
videoUploadState.asset,
|
||||||
|
videoUploadState.pendingPublish,
|
||||||
|
videoUploadState.status,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (videoUploadState.pendingPublish && publishOnUpload) {
|
||||||
|
if (!videoUploadState.pendingPublish.mutableProcessed) {
|
||||||
|
videoUploadState.pendingPublish.mutableProcessed = true
|
||||||
|
onPressPublish(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onClose()
|
}, [onPressPublish, publishOnUpload, videoUploadState.pendingPublish])
|
||||||
Toast.show(
|
|
||||||
replyTo
|
|
||||||
? _(msg`Your reply has been published`)
|
|
||||||
: _(msg`Your post has been published`),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canPost = useMemo(
|
const canPost = useMemo(
|
||||||
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
|
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
|
||||||
|
@ -462,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||||
}, [openPicker])
|
}, [openEmojiPicker])
|
||||||
|
|
||||||
const focusTextInput = useCallback(() => {
|
const focusTextInput = useCallback(() => {
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
|
@ -524,7 +583,9 @@ export const ComposePost = observer(function ComposePost({
|
||||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||||
style={a.flex_1}>
|
style={a.flex_1}>
|
||||||
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||||
<Animated.View style={topBarAnimatedStyle}>
|
<Animated.View
|
||||||
|
style={topBarAnimatedStyle}
|
||||||
|
layout={native(LinearTransition)}>
|
||||||
<View style={styles.topbarInner}>
|
<View style={styles.topbarInner}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Cancel`)}
|
label={_(msg`Cancel`)}
|
||||||
|
@ -554,7 +615,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<View style={[styles.postBtnWrapper]}>
|
||||||
<LabelsBtn
|
<LabelsBtn
|
||||||
labels={labels}
|
labels={labels}
|
||||||
onChange={setLabels}
|
onChange={setLabels}
|
||||||
|
@ -590,7 +651,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
@ -608,48 +669,15 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{(error !== '' || videoUploadState.error) && (
|
<ErrorBanner
|
||||||
<View style={[a.px_lg, a.pb_sm]}>
|
error={error}
|
||||||
<View
|
videoUploadState={videoUploadState}
|
||||||
style={[
|
clearError={() => setError('')}
|
||||||
a.px_md,
|
videoUploadDispatch={videoUploadDispatch}
|
||||||
a.py_sm,
|
/>
|
||||||
a.rounded_sm,
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
paddingRight: 48,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<CircleInfo fill={t.palette.negative_400} />
|
|
||||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
|
||||||
{error || videoUploadState.error}
|
|
||||||
</NewText>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Dismiss error`)}
|
|
||||||
size="tiny"
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
top: a.py_sm.paddingTop,
|
|
||||||
right: a.px_md.paddingRight,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
if (error) setError('')
|
|
||||||
else videoUploadDispatch({type: 'Reset'})
|
|
||||||
}}>
|
|
||||||
<ButtonIcon icon={X} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
|
layout={native(LinearTransition)}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
keyboardShouldPersistTaps="always"
|
keyboardShouldPersistTaps="always"
|
||||||
|
@ -703,8 +731,37 @@ export const ComposePost = observer(function ComposePost({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<LayoutAnimationConfig skipExiting>
|
||||||
<View style={[a.mt_md]}>
|
{hasVideo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.w_full, a.mt_lg]}
|
||||||
|
entering={native(ZoomIn)}
|
||||||
|
exiting={native(ZoomOut)}>
|
||||||
|
{videoUploadState.asset &&
|
||||||
|
(videoUploadState.status === 'compressing' ? (
|
||||||
|
<VideoTranscodeProgress
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
progress={videoUploadState.progress}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : videoUploadState.video ? (
|
||||||
|
<VideoPreview
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
video={videoUploadState.video}
|
||||||
|
setDimensions={updateVideoDimensions}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : null)}
|
||||||
|
<SubtitleDialogBtn
|
||||||
|
defaultAltText={videoAltText}
|
||||||
|
saveAltText={setVideoAltText}
|
||||||
|
captions={captions}
|
||||||
|
setCaptions={setCaptions}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
<View style={!hasVideo ? [a.mt_md] : []}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={{pointerEvents: 'none'}}>
|
||||||
|
@ -715,29 +772,6 @@ export const ComposePost = observer(function ComposePost({
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{videoUploadState.asset &&
|
|
||||||
(videoUploadState.status === 'compressing' ? (
|
|
||||||
<VideoTranscodeProgress
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
progress={videoUploadState.progress}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : videoUploadState.video ? (
|
|
||||||
<VideoPreview
|
|
||||||
asset={videoUploadState.asset}
|
|
||||||
video={videoUploadState.video}
|
|
||||||
setDimensions={updateVideoDimensions}
|
|
||||||
clear={clearVideo}
|
|
||||||
/>
|
|
||||||
) : null)}
|
|
||||||
{(videoUploadState.asset || videoUploadState.video) && (
|
|
||||||
<SubtitleDialogBtn
|
|
||||||
altText={videoAltText}
|
|
||||||
setAltText={setVideoAltText}
|
|
||||||
captions={captions}
|
|
||||||
setCaptions={setCaptions}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
@ -958,6 +992,10 @@ const styles = StyleSheet.create({
|
||||||
paddingVertical: 6,
|
paddingVertical: 6,
|
||||||
marginLeft: 12,
|
marginLeft: 12,
|
||||||
},
|
},
|
||||||
|
postBtnWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
errorLine: {
|
errorLine: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
@ -1018,6 +1056,80 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ErrorBanner({
|
||||||
|
error: standardError,
|
||||||
|
videoUploadState,
|
||||||
|
clearError,
|
||||||
|
videoUploadDispatch,
|
||||||
|
}: {
|
||||||
|
error: string
|
||||||
|
videoUploadState: VideoUploadState
|
||||||
|
clearError: () => void
|
||||||
|
videoUploadDispatch: VideoUploadDispatch
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const videoError =
|
||||||
|
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
|
||||||
|
const error = standardError || videoError
|
||||||
|
|
||||||
|
const onClearError = () => {
|
||||||
|
if (standardError) {
|
||||||
|
clearError()
|
||||||
|
} else {
|
||||||
|
videoUploadDispatch({type: 'Reset'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.px_lg, a.pb_sm]}
|
||||||
|
entering={FadeIn}
|
||||||
|
exiting={FadeOut}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.px_md,
|
||||||
|
a.py_sm,
|
||||||
|
a.gap_xs,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
|
||||||
|
<CircleInfo fill={t.palette.negative_400} />
|
||||||
|
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||||
|
{error}
|
||||||
|
</NewText>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Dismiss error`)}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
style={[a.absolute, {top: 0, right: 0}]}
|
||||||
|
onPress={onClearError}>
|
||||||
|
<ButtonIcon icon={X} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{videoError && videoUploadState.jobStatus?.jobId && (
|
||||||
|
<NewText
|
||||||
|
style={[
|
||||||
|
{paddingLeft: 28},
|
||||||
|
a.text_xs,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_low,
|
||||||
|
]}>
|
||||||
|
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
|
||||||
|
</NewText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolbarWrapper({
|
function ToolbarWrapper({
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
|
@ -1039,6 +1151,31 @@ function ToolbarWrapper({
|
||||||
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const progress = state.jobStatus?.progress
|
||||||
|
? state.jobStatus.progress / 100
|
||||||
|
: state.progress
|
||||||
|
const shouldRotate =
|
||||||
|
state.status === 'processing' && (progress === 0 || progress === 1)
|
||||||
|
let wheelProgress = shouldRotate ? 0.33 : progress
|
||||||
|
|
||||||
|
const rotate = useDerivedValue(() => {
|
||||||
|
if (shouldRotate) {
|
||||||
|
return withRepeat(
|
||||||
|
withTiming(360, {
|
||||||
|
duration: 2500,
|
||||||
|
easing: Easing.out(Easing.cubic),
|
||||||
|
}),
|
||||||
|
-1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
transform: [{rotateZ: `${rotate.value}deg`}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let text = ''
|
let text = ''
|
||||||
|
|
||||||
|
@ -1057,21 +1194,22 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// we could use state.jobStatus?.progress but 99% of the time it jumps from 0 to 100
|
if (state.error) {
|
||||||
const progress =
|
text = _('Error')
|
||||||
state.status === 'compressing' || state.status === 'uploading'
|
wheelProgress = 100
|
||||||
? state.progress
|
}
|
||||||
: 100
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
<ToolbarWrapper style={[a.flex_row, a.align_center, {paddingVertical: 5}]}>
|
||||||
<ProgressCircle
|
<Animated.View style={[animatedStyle]}>
|
||||||
size={30}
|
<ProgressCircle
|
||||||
borderWidth={1}
|
size={30}
|
||||||
borderColor={t.atoms.border_contrast_low.borderColor}
|
borderWidth={1}
|
||||||
color={t.palette.primary_500}
|
borderColor={t.atoms.border_contrast_low.borderColor}
|
||||||
progress={progress}
|
color={state.error ? t.palette.negative_500 : t.palette.primary_500}
|
||||||
/>
|
progress={wheelProgress}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
<NewText style={[a.font_bold, a.ml_sm]}>{text}</NewText>
|
||||||
</ToolbarWrapper>
|
</ToolbarWrapper>
|
||||||
)
|
)
|
||||||
|
|
|
@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
||||||
import {Text as TiptapText} from '@tiptap/extension-text'
|
import {Text as TiptapText} from '@tiptap/extension-text'
|
||||||
import {generateJSON} from '@tiptap/html'
|
import {generateJSON} from '@tiptap/html'
|
||||||
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
||||||
import EventEmitter from 'eventemitter3'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {
|
import {
|
||||||
LinkFacetMatch,
|
LinkFacetMatch,
|
||||||
suggestLinkCardUri,
|
suggestLinkCardUri,
|
||||||
|
@ -46,8 +46,6 @@ interface TextInputProps {
|
||||||
onError: (err: string) => void
|
onError: (err: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textInputWebEmitter = new EventEmitter()
|
|
||||||
|
|
||||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
export const TextInput = React.forwardRef(function TextInputImpl(
|
||||||
{
|
{
|
||||||
richtext,
|
richtext,
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
export const textInputWebEmitter = new EventEmitter()
|
|
@ -7,8 +7,8 @@ import {
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
|
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {textInputWebEmitter} from '../TextInput.web'
|
|
||||||
|
|
||||||
const HEIGHT_OFFSET = 40
|
const HEIGHT_OFFSET = 40
|
||||||
const WIDTH_OFFSET = 100
|
const WIDTH_OFFSET = 100
|
||||||
|
@ -26,22 +26,41 @@ export type Emoji = {
|
||||||
unified: string
|
unified: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmojiPickerPosition {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmojiPickerState {
|
export interface EmojiPickerState {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
pos: {top: number; left: number; right: number; bottom: number}
|
pos: EmojiPickerPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
state: EmojiPickerState
|
state: EmojiPickerState
|
||||||
close: () => void
|
close: () => void
|
||||||
|
/**
|
||||||
|
* If `true`, overrides position and ensures picker is pinned to the top of
|
||||||
|
* the target element.
|
||||||
|
*/
|
||||||
|
pinToTop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({state, close}: IProps) {
|
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||||
const {height, width} = useWindowDimensions()
|
const {height, width} = useWindowDimensions()
|
||||||
|
|
||||||
const isShiftDown = React.useRef(false)
|
const isShiftDown = React.useRef(false)
|
||||||
|
|
||||||
const position = React.useMemo(() => {
|
const position = React.useMemo(() => {
|
||||||
|
if (pinToTop) {
|
||||||
|
return {
|
||||||
|
top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
|
||||||
|
left: state.pos.left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
||||||
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
||||||
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
||||||
|
@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.pos, height, width])
|
}, [state.pos, height, width, pinToTop])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!state.isOpen) return
|
if (!state.isOpen) return
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback} from 'react'
|
||||||
|
import {Keyboard} from 'react-native'
|
||||||
import {
|
import {
|
||||||
ImagePickerAsset,
|
ImagePickerAsset,
|
||||||
launchImageLibraryAsync,
|
launchImageLibraryAsync,
|
||||||
|
@ -10,11 +11,14 @@ import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||||
import {isNative} from '#/platform/detection'
|
import {isNative} from '#/platform/detection'
|
||||||
|
import {useModalControls} from '#/state/modals'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||||
|
import * as Prompt from '#/components/Prompt'
|
||||||
|
|
||||||
const VIDEO_MAX_DURATION = 90
|
const VIDEO_MAX_DURATION = 60
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelectVideo: (video: ImagePickerAsset) => void
|
onSelectVideo: (video: ImagePickerAsset) => void
|
||||||
|
@ -26,33 +30,47 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
|
||||||
|
const control = Prompt.usePromptControl()
|
||||||
|
const {currentAccount} = useSession()
|
||||||
|
|
||||||
const onPressSelectVideo = useCallback(async () => {
|
const onPressSelectVideo = useCallback(async () => {
|
||||||
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
if (isNative && !(await requestVideoAccessIfNeeded())) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await launchImageLibraryAsync({
|
if (!currentAccount?.emailConfirmed) {
|
||||||
exif: false,
|
Keyboard.dismiss()
|
||||||
mediaTypes: MediaTypeOptions.Videos,
|
control.open()
|
||||||
videoMaxDuration: VIDEO_MAX_DURATION,
|
} else {
|
||||||
quality: 1,
|
const response = await launchImageLibraryAsync({
|
||||||
legacy: true,
|
exif: false,
|
||||||
preferredAssetRepresentationMode:
|
mediaTypes: MediaTypeOptions.Videos,
|
||||||
UIImagePickerPreferredAssetRepresentationMode.Current,
|
videoMaxDuration: VIDEO_MAX_DURATION,
|
||||||
})
|
quality: 1,
|
||||||
if (response.assets && response.assets.length > 0) {
|
legacy: true,
|
||||||
try {
|
preferredAssetRepresentationMode:
|
||||||
onSelectVideo(response.assets[0])
|
UIImagePickerPreferredAssetRepresentationMode.Current,
|
||||||
} catch (err) {
|
})
|
||||||
if (err instanceof Error) {
|
if (response.assets && response.assets.length > 0) {
|
||||||
setError(err.message)
|
try {
|
||||||
} else {
|
onSelectVideo(response.assets[0])
|
||||||
setError(_(msg`An error occurred while selecting the video`))
|
} catch (err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
setError(err.message)
|
||||||
|
} else {
|
||||||
|
setError(_(msg`An error occurred while selecting the video`))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [onSelectVideo, requestVideoAccessIfNeeded, setError, _])
|
}, [
|
||||||
|
onSelectVideo,
|
||||||
|
requestVideoAccessIfNeeded,
|
||||||
|
setError,
|
||||||
|
_,
|
||||||
|
control,
|
||||||
|
currentAccount?.emailConfirmed,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -71,6 +89,32 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||||
style={disabled && t.atoms.text_contrast_low}
|
style={disabled && t.atoms.text_contrast_low}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<VerifyEmailPrompt control={control} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
|
||||||
|
const {_} = useLingui()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt.Basic
|
||||||
|
control={control}
|
||||||
|
title={_(msg`Verified email required`)}
|
||||||
|
description={_(
|
||||||
|
msg`To upload videos to Bluesky, you must first verify your email.`,
|
||||||
|
)}
|
||||||
|
confirmButtonCta={_(msg`Verify now`)}
|
||||||
|
confirmButtonColor="primary"
|
||||||
|
onConfirm={() => {
|
||||||
|
control.close(() => {
|
||||||
|
openModal({
|
||||||
|
name: 'verify-email',
|
||||||
|
showReminder: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React, {useCallback} from 'react'
|
import React, {useCallback, useState} from 'react'
|
||||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import RNPickerSelect from 'react-native-picker-select'
|
import RNPickerSelect from 'react-native-picker-select'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
||||||
import {MAX_ALT_TEXT} from '#/lib/constants'
|
import {MAX_ALT_TEXT} from '#/lib/constants'
|
||||||
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {LANGUAGES} from '#/locale/languages'
|
import {LANGUAGES} from '#/locale/languages'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isAndroid, isWeb} from '#/platform/detection'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
|
@ -21,9 +21,9 @@ import {Text} from '#/components/Typography'
|
||||||
import {SubtitleFilePicker} from './SubtitleFilePicker'
|
import {SubtitleFilePicker} from './SubtitleFilePicker'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
altText: string
|
defaultAltText: string
|
||||||
captions: {lang: string; file: File}[]
|
captions: {lang: string; file: File}[]
|
||||||
setAltText: (altText: string) => void
|
saveAltText: (altText: string) => void
|
||||||
setCaptions: React.Dispatch<
|
setCaptions: React.Dispatch<
|
||||||
React.SetStateAction<{lang: string; file: File}[]>
|
React.SetStateAction<{lang: string; file: File}[]>
|
||||||
>
|
>
|
||||||
|
@ -34,7 +34,7 @@ export function SubtitleDialogBtn(props: Props) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.mt_xs]}>
|
<View style={[a.flex_row, a.my_xs]}>
|
||||||
<Button
|
<Button
|
||||||
label={isWeb ? _('Captions & alt text') : _('Alt text')}
|
label={isWeb ? _('Captions & alt text') : _('Alt text')}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
|
@ -45,13 +45,18 @@ export function SubtitleDialogBtn(props: Props) {
|
||||||
size="xsmall"
|
size="xsmall"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onPress={control.open}>
|
onPress={() => {
|
||||||
|
if (Keyboard.isVisible()) Keyboard.dismiss()
|
||||||
|
control.open()
|
||||||
|
}}>
|
||||||
<ButtonIcon icon={CCIcon} />
|
<ButtonIcon icon={CCIcon} />
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
|
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<SubtitleDialogInner {...props} />
|
<SubtitleDialogInner {...props} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
|
@ -60,8 +65,8 @@ export function SubtitleDialogBtn(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SubtitleDialogInner({
|
function SubtitleDialogInner({
|
||||||
altText,
|
defaultAltText,
|
||||||
setAltText,
|
saveAltText,
|
||||||
captions,
|
captions,
|
||||||
setCaptions,
|
setCaptions,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
@ -71,6 +76,8 @@ function SubtitleDialogInner({
|
||||||
const enforceLen = useEnforceMaxGraphemeCount()
|
const enforceLen = useEnforceMaxGraphemeCount()
|
||||||
const {primaryLanguage} = useLanguagePrefs()
|
const {primaryLanguage} = useLanguagePrefs()
|
||||||
|
|
||||||
|
const [altText, setAltText] = useState(defaultAltText)
|
||||||
|
|
||||||
const handleSelectFile = useCallback(
|
const handleSelectFile = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
setCaptions(subs => [
|
setCaptions(subs => [
|
||||||
|
@ -102,6 +109,7 @@ function SubtitleDialogInner({
|
||||||
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
|
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
|
||||||
maxLength={MAX_ALT_TEXT * 10}
|
maxLength={MAX_ALT_TEXT * 10}
|
||||||
multiline
|
multiline
|
||||||
|
style={{maxHeight: 300}}
|
||||||
numberOfLines={3}
|
numberOfLines={3}
|
||||||
onKeyPress={({nativeEvent}) => {
|
onKeyPress={({nativeEvent}) => {
|
||||||
if (nativeEvent.key === 'Escape') {
|
if (nativeEvent.key === 'Escape') {
|
||||||
|
@ -144,22 +152,26 @@ function SubtitleDialogInner({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
{subtitleMissingLanguage && (
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Ensure you have selected a language for each subtitle file.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtitleMissingLanguage && (
|
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
|
||||||
Ensure you have selected a language for each subtitle file.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={web([a.flex_row, a.justify_end])}>
|
<View style={web([a.flex_row, a.justify_end])}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Done`)}
|
label={_(msg`Done`)}
|
||||||
size={isWeb ? 'small' : 'medium'}
|
size={isWeb ? 'small' : 'medium'}
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={() => control.close()}
|
onPress={() => {
|
||||||
|
saveAltText(altText)
|
||||||
|
control.close()
|
||||||
|
}}
|
||||||
style={a.mt_lg}>
|
style={a.mt_lg}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Done</Trans>
|
<Trans>Done</Trans>
|
||||||
|
|
|
@ -6,8 +6,10 @@ import {useVideoPlayer, VideoView} from 'expo-video'
|
||||||
|
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
|
@ -20,10 +22,13 @@ export function VideoPreview({
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
const player = useVideoPlayer(video.uri, player => {
|
const player = useVideoPlayer(video.uri, player => {
|
||||||
player.loop = true
|
player.loop = true
|
||||||
player.muted = true
|
player.muted = true
|
||||||
player.play()
|
if (!autoplayDisabled) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
@ -53,6 +58,11 @@ export function VideoPreview({
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
/>
|
/>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
|
{autoplayDisabled && (
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,16 @@
|
||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {ImagePickerAsset} from 'expo-image-picker'
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {useAutoplayDisabled} from '#/state/preferences'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
export function VideoPreview({
|
export function VideoPreview({
|
||||||
asset,
|
asset,
|
||||||
|
@ -19,6 +24,8 @@ export function VideoPreview({
|
||||||
clear: () => void
|
clear: () => void
|
||||||
}) {
|
}) {
|
||||||
const ref = useRef<HTMLVideoElement>(null)
|
const ref = useRef<HTMLVideoElement>(null)
|
||||||
|
const {_} = useLingui()
|
||||||
|
const autoplayDisabled = useAutoplayDisabled()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
|
@ -32,11 +39,19 @@ export function VideoPreview({
|
||||||
},
|
},
|
||||||
{signal},
|
{signal},
|
||||||
)
|
)
|
||||||
|
ref.current.addEventListener(
|
||||||
|
'error',
|
||||||
|
() => {
|
||||||
|
Toast.show(_(msg`Could not process your video`), 'xmark')
|
||||||
|
clear()
|
||||||
|
},
|
||||||
|
{signal},
|
||||||
|
)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort()
|
abortController.abort()
|
||||||
}
|
}
|
||||||
}, [setDimensions])
|
}, [setDimensions, _, clear])
|
||||||
|
|
||||||
let aspectRatio = asset.width / asset.height
|
let aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
|
@ -54,17 +69,23 @@ export function VideoPreview({
|
||||||
{aspectRatio},
|
{aspectRatio},
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{backgroundColor: 'black'},
|
{backgroundColor: 'black'},
|
||||||
|
a.relative,
|
||||||
]}>
|
]}>
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
<video
|
<video
|
||||||
ref={ref}
|
ref={ref}
|
||||||
src={video.uri}
|
src={video.uri}
|
||||||
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
style={{width: '100%', height: '100%', objectFit: 'cover'}}
|
||||||
autoPlay
|
autoPlay={!autoplayDisabled}
|
||||||
loop
|
loop
|
||||||
muted
|
muted
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
/>
|
||||||
|
{autoplayDisabled && (
|
||||||
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
|
<PlayButtonIcon />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,6 @@ export function VideoTranscodeProgress({
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.w_full,
|
a.w_full,
|
||||||
a.mt_md,
|
|
||||||
t.atoms.bg_contrast_50,
|
t.atoms.bg_contrast_50,
|
||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
|
|
|
@ -173,7 +173,7 @@ export function Component({
|
||||||
accessibilityLabel={_(msg`Confirmation code`)}
|
accessibilityLabel={_(msg`Confirmation code`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="off"
|
autoComplete="one-time-code"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue