Compare commits

...

56 Commits

Author SHA1 Message Date
Eric Bailey d8bb962830 Imports 2024-09-11 18:45:03 -05:00
Eric Bailey f7e2f0ced9 Naming 2024-09-11 18:37:23 -05:00
Eric Bailey 397aa7a722 Bump SDK 2024-09-11 18:36:34 -05:00
Eric Bailey b1f88b3866 Set up nux API 2024-09-11 15:57:04 -05:00
Samuel Newman 580b67ba37 disable autoplay within messages and trim feelers (#5260) 2024-09-11 16:20:32 +01:00
Samuel Newman b04ecbe54d hls buffering tweaks (#5266) 2024-09-11 16:20:20 +01:00
Samuel Newman db38438549 increase target area of scrubber (#5265) 2024-09-11 16:20:08 +01:00
Samuel Newman fc25992070 [Video] make hover state stick around if tapped (#5259) 2024-09-10 20:19:37 -07:00
Samuel Newman c22492147b remove scrollbar-gutter in fullscreen (#5258) 2024-09-10 23:18:08 +01:00
Hailey b37b64fb49 Verify Identical Domains Emit Origin (#5255) 2024-09-10 08:16:41 -07:00
Samuel Newman 08f5f37b34 [Video] Allow drag-and-drop & pasting video (#5252)
* allow DnD/pasting video

* rm await
2024-09-10 16:14:28 +01:00
Samuel Newman 6bc5a05f4b [Video] Much simpler fix to fullscreen bug (#5251)
* much simpler fix

* allow old behaviour on firefox

* rm logs
2024-09-10 16:10:13 +01:00
Frudrax Cheng db9cf92d87 Adjust the translated strings to the correct max size. (#5248)
* Update pt-BR localization

* Update ru localization
2024-09-10 11:21:28 +01:00
Samuel Newman 66239ba11d [Video] Fix fullscreen on Chrome (#5246) 2024-09-09 22:37:57 -07:00
Samuel Newman 0f6be244a6 max 1 subtitle file (#5244) 2024-09-10 02:02:53 +01:00
Samuel Newman 436e30fded [Video] use correct max size (#5245)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-09 17:01:40 -07:00
Hailey 723a5e488e [Video] Add a string for localization (#5240) 2024-09-09 12:44:41 -07:00
Jaz 9e1eb1f9f1 Add CORS to bskyweb (#5221) 2024-09-09 18:39:28 +01:00
surfdude29 f240893980 Add context to Mute and Unmute labels on video control (#5234)
Co-authored-by: Marco Buono <thecoreh@gmail.com>
2024-09-09 09:29:14 -07:00
Samuel Newman 44f1cd9fb5 [Video] Media preview improvements (#5229)
* background color for images that haven't loaded

* fix recordwithmedia not appearing
2024-09-09 16:41:27 +01:00
Samuel Newman 6c6a76b193 [Video] Upload tweaks (#5228)
* use correct mime type

* fix wheel progress
2024-09-08 08:27:50 -07:00
surfdude29 95aee146b6 Update dates.ts (#5220) 2024-09-07 21:23:51 -07:00
Paul Frazee a6a3d203fd Release 1.91 prep (#5215)
* Run intl:extract

* Test fixes

* Update pt-BR translations for video
2024-09-07 13:52:00 -07:00
Kirill 63ab16a62d Add Russian translation (#3875)
* Add Russian translation

* Update messages.po

* Update messages.po (draft)

* Добавлены новые строки для перевода

В ручную объеденил исправленный RU с самым новым EN. Могут быть ошибки но быстрый тест проблем не выявил.

* Переведены не переведенные строки, некоторые исправления перевода.

* Еще небольшие правки

* Update messages.po (draft)

* Update messages.po (stage)

* Update messages.po

* Init lingui compiling ru language

* Update messages.po

* Update messages.po (clear)

* Update messages.po

* Update messages.po

* change await import to await Promise.all

* Update messages.po

* Update messages.po (clear)

---------

Co-authored-by: DearFox <59219907+DearFox@users.noreply.github.com>
2024-09-07 13:50:18 -07:00
Hailey 9b8d62ca25 [Video] Tweak order of elements in composer (#5213) 2024-09-07 13:29:27 -07:00
Hailey f1877e44f2 [Video] Fix type on web (#5211) 2024-09-07 13:03:53 -07:00
Takayuki KUSANO 0a61b06580 Update Japanese Translation (#5031)
* Update translation

* Update translation

* Fixed wording

* Change the translation of QRcode of starterpack

* Update translation

* Update translation

* Update translation

* Update translation
2024-09-07 12:29:37 -07:00
Minseo Lee 4037e7a50d Update Korean localization (#5035)
* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po

* Update messages.po
2024-09-07 12:29:08 -07:00
Frudrax Cheng 373735ac49 Update Chinese Localization (#5036)
* CN: Update Translates

* CN: Remove superseded strings

* CN: Update Translates#1

* TW: Update Translates & Remove superseded strings

* CN: Update Translates#2

* TW: Update and clean

* CN: Update Translates

* Both: Run intl:extract

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* Update src/locale/locales/zh-TW/messages.po

Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>

* CN: Fix typos

* Update Translates

* TW: Update Translates

* CN: Update Translates

* CN: Update Translates

* CN: Remove superseded strings

* TW: Update and clean

* CN: Update Translates

---------

Co-authored-by: Kuwa Lee <kuwalee1069@gmail.com>
Co-authored-by: cirx <133132480+cirx1e@users.noreply.github.com>
2024-09-07 12:28:36 -07:00
miyun 665766b0d5 Overhaul IT locale (#5069)
* Overhaul IT locale

* Various corrections to the IT locale (thanks to surfdude29!)
2024-09-07 12:25:07 -07:00
Ivan Beà 2a7b333553 Update catalan messages.po (#5067)
* Update catalan messages.po

Another batch, take a look please @jordimas @darccio @surfdude29 @rortan134

* Update catalan messages.po

apply @jordimas corrections

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/locale/locales/ca/messages.po

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update messages.po

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
2024-09-07 12:23:50 -07:00
Samuel Newman 1b4fee3e43 [Video] Open the floodgates (partially) (#5208) 2024-09-07 12:15:15 -07:00
Hailey 51259e7c42 Revert "[Video] Ensure loop doesn't stop" (#5209) 2024-09-07 12:11:18 -07:00
Hailey 10cdc436b8 [Video] Ensure loop doesn't stop (#5207) 2024-09-07 11:54:51 -07:00
Hailey 2842f661db Add intent for verifying email (#5120) 2024-09-07 11:54:39 -07:00
Samuel Newman 45a719b256 [Video] Check upload limits before uploading (#5153)
* DRY up video service auth code

* throw error if over upload limits

* use token

* xmark on toast

* errors with nice translatable error messages

* Update src/state/queries/video/video.ts

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 19:27:32 +01:00
Samuel Newman b7d78fe59b [Video] Only compress if >25mb or unknown format (#5187)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 11:22:44 -07:00
dan 42fb92064a Set onboarding_minimum_interests to false (#5204)
Co-authored-by: Hailey <me@haileyok.com>
2024-09-07 11:22:34 -07:00
dan 292117804f Set show_follow_suggestions_in_profile to true (#5205) 2024-09-07 17:08:19 +02:00
dan 7d7431d14e Set fixed_bottom_bar to true (#5203) 2024-09-07 17:07:30 +02:00
Hailey c8be9b78c6 [Statsig] Add more events to downsample, increase downsample rate (#5198)
* add some events for sampling

* include downsample rate in metadata

* fix metadata logic

* uncomment debug
2024-09-07 13:13:51 +02:00
jlca adef9cff10 fix: remove duplicate style rounded_sm (#5201) 2024-09-06 22:29:36 -07:00
nicofercavv 275f2bb004 Add cursor pointer to 'New post' button (#5109) 2024-09-06 18:18:31 -05:00
Eric Bailey 543be17674 Add emoji picker to chat composer (#5196)
Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Co-authored-by: Adrov Igor <nucleartux@gmail.com>
2024-09-06 15:58:47 -07:00
Jaz 30d2ab8dd3 Merge pull request #5195 from brianolson/ipcc-handler
quick integration of ipcc service
2024-09-06 15:21:27 -07:00
Hailey 7e4f8cabd3 [Video] Handle push/pop on Android for autoplay (#5194) 2024-09-06 15:01:05 -07:00
Jaz dc6b04b66f nvm 2024-09-06 15:00:13 -07:00
Jaz 6620ee421b fix action 2024-09-06 14:59:02 -07:00
Jaz d41a00b373 Trigger a build maybe 2024-09-06 14:58:19 -07:00
Igor Adrov 00ce95893d Fix starter packs scroll (#5190) 2024-09-06 16:32:58 -05:00
Eric Bailey c5faa60344 Redesign play button (#5192) 2024-09-06 21:55:23 +01:00
Brian Olson eec93c4e74 cleanup 2024-09-06 16:34:57 -04:00
Brian Olson 6d4ae3d719 quick integration of ipcc service 2024-09-06 16:19:55 -04:00
Eric Bailey cd9c3bf498 Bump joined stat (#5188)
* Bump joined stat

* Ope, actually more
2024-09-06 12:34:51 -05:00
Hailey 60182cd874 [Video] Add disable autoplay for native, more tweaking (#5178) 2024-09-06 09:31:01 -07:00
Eric Bailey bdff8752fb Add support for autofill of one-time-code for email verification (#5186) 2024-09-06 11:22:29 -05:00
99 changed files with 20246 additions and 8076 deletions
+6 -1
View File
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Pin alice's feed
- extendedWaitUntil:
visible:
id: "viewHeaderDrawerBtn"
- tapOn:
id: "bottomBarProfileBtn"
id: "viewHeaderDrawerBtn"
- tapOn:
id: "profileCardButton"
- swipe:
from:
id: "profilePager-selector"
+3 -10
View File
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
- tapOn:
id: "e2eSignInAlice"
- extendedWaitUntil:
visible:
text: "Feeds ✨"
- tapOn:
label: "Can go to feeds page using feeds button in tab bar"
text: "Feeds ✨"
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
- tapOn:
label: "Can like posts"
id: "likeBtn"
- assertVisible:
id: "likeCount"
text: "1"
- tapOn:
id: "likeBtn"
- assertNotVisible:
id: "likeCount"
- tapOn:
label: "Can repost posts"
id: "repostBtn"
- tapOn: "Repost"
- assertVisible:
id: "repostCount"
text: "1"
- tapOn:
id: "repostBtn"
- tapOn: "Remove repost"
- assertNotVisible:
id: "repostCount"
- tapOn:
label: "Can delete posts"
+3
View File
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
# Navigate to my profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarProfileBtn"
+3
View File
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
id: "e2eSignInAlice"
# Navigate to another user profile
- extendedWaitUntil:
visible:
id: "bottomBarSearchBtn"
- tapOn:
id: "bottomBarSearchBtn"
- tapOn:
+2 -8
View File
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
# Navigate to thread
- extendedWaitUntil:
visible: "Thread root"
- tapOn: "Thread root"
- assertVisible: "Thread reply"
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
- tapOn:
id: "likeBtn"
childOf:
id: "postThreadItem-by-carla.test"
- assertNotVisible:
id: "likeCount"
childOf:
id: "postThreadItem-by-carla.test"
# Can repost the root post
- tapOn:
+13
View File
@@ -60,6 +60,12 @@ func run(args []string) {
Value: "",
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{
Name: "debug",
Usage: "Enable debug mode",
@@ -74,6 +80,13 @@ func run(args []string) {
Value: "",
EnvVars: []string{"BASIC_AUTH_PASSWORD"},
},
&cli.StringSliceFlag{
Name: "cors-allowed-origins",
Usage: "list of allowed origins for CORS requests",
Required: false,
Value: cli.NewStringSlice("https://bsky.app", "https://main.bsky.dev", "https://app.staging.bsky.dev"),
EnvVars: []string{"CORS_ALLOWED_ORIGINS"},
},
},
},
}
+76
View File
@@ -1,12 +1,17 @@
package main
import (
"bytes"
"context"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"net/netip"
"net/url"
"os"
"os/signal"
@@ -41,6 +46,7 @@ type Config struct {
appviewHost string
ogcardHost string
linkHost string
ipccHost string
}
func serve(cctx *cli.Context) error {
@@ -49,7 +55,9 @@ func serve(cctx *cli.Context) error {
appviewHost := cctx.String("appview-host")
ogcardHost := cctx.String("ogcard-host")
linkHost := cctx.String("link-host")
ipccHost := cctx.String("ipcc-host")
basicAuthPassword := cctx.String("basic-auth-password")
corsOrigins := cctx.StringSlice("cors-allowed-origins")
// Echo
e := echo.New()
@@ -91,6 +99,7 @@ func serve(cctx *cli.Context) error {
appviewHost: appviewHost,
ogcardHost: ogcardHost,
linkHost: linkHost,
ipccHost: ipccHost,
},
}
@@ -160,6 +169,12 @@ func serve(cctx *cli.Context) error {
RedirectCode: http.StatusFound,
}))
// CORS middleware
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: corsOrigins,
AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodOptions},
}))
//
// configure routes
//
@@ -261,6 +276,9 @@ func serve(cctx *cli.Context) error {
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// ipcc
e.GET("/ipcc", server.WebIpCC)
if linkHost != "" {
linkUrl, err := url.Parse(linkHost)
if err != nil {
@@ -520,3 +538,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
data["requestHost"] = req.Host
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)
}
+1
View File
@@ -14,6 +14,7 @@ module.exports = {
'ja',
'ko',
'pt-BR',
'ru',
'tr',
'uk',
'zh-CN',
+2 -2
View File
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "0.13.5",
"@atproto/api": "^0.13.7",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -139,7 +139,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz",
"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",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
+51 -47
View File
@@ -58,6 +58,7 @@ import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -105,52 +106,50 @@ function InnerApp() {
}, [_])
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
</SafeAreaProvider>
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
)
}
@@ -184,7 +183,12 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</SafeAreaProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+4 -1
View File
@@ -47,6 +47,7 @@ import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PortalProvider} from '#/components/Portal'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
@@ -162,7 +163,9 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<InnerApp />
<IntentDialogProvider>
<InnerApp />
</IntentDialogProvider>
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
+4 -5
View File
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
linking={LINKING}
theme={theme}
onStateChange={() => {
logEvent('router:navigate:sampled', {
from: prevLoggedRouteName.current,
})
prevLoggedRouteName.current = getCurrentRouteName()
const routeName = getCurrentRouteName()
if (routeName === 'Notifications') {
logEvent('router:navigate:notifications:sampled', {})
}
}}
onReady={() => {
attachRouteToLogEvents(getCurrentRouteName)
logModuleInitTime()
onReady()
logEvent('router:navigate:sampled', {})
}}>
{children}
</NavigationContainer>
+1 -7
View File
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
}
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const gate = useGate()
const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
if (gate('show_follow_suggestions_in_profile')) {
return <SuggestedFollowsProfile did={feedUri} />
} else {
return null
}
return <SuggestedFollowsProfile did={feedUri} />
} else {
return <SuggestedFollowsHome />
}
+9 -8
View File
@@ -10,7 +10,7 @@ import {
import {Trans} from '@lingui/macro'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -43,10 +43,10 @@ export function Embed({
))}
</Outer>
)
} else if (AppBskyEmbedExternal.isView(embed) && embed.external.thumb) {
} else if (AppBskyEmbedExternal.isView(media) && media.external.thumb) {
let url: URL | undefined
try {
url = new URL(embed.external.uri)
url = new URL(media.external.uri)
} catch {}
if (url) {
const {success} = parseTenorGif(url)
@@ -54,17 +54,17 @@ export function Embed({
return (
<Outer style={style}>
<GifItem
thumbnail={embed.external.thumb}
alt={embed.external.title}
thumbnail={media.external.thumb}
alt={media.external.title}
/>
</Outer>
)
}
}
} else if (AppBskyEmbedVideo.isView(embed)) {
} else if (AppBskyEmbedVideo.isView(media)) {
return (
<Outer style={style}>
<VideoItem thumbnail={embed.thumbnail} alt={embed.alt} />
<VideoItem thumbnail={media.thumbnail} alt={media.alt} />
</Outer>
)
}
@@ -91,12 +91,13 @@ export function ImageItem({
alt?: string
children?: React.ReactNode
}) {
const t = useTheme()
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]}
style={[a.flex_1, a.rounded_xs, t.atoms.bg_contrast_25]}
contentFit="cover"
accessible={true}
accessibilityIgnoresInvertColors
+2 -2
View File
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
export type FollowButtonProps = {
profile: AppBskyActorDefs.ProfileViewBasic
moderationOpts: ModerationOpts
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
} & Partial<ButtonProps>
export function FollowButton(props: FollowButtonProps) {
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
ref,
) {
const t = useTheme()
const bottomBarOffset = useBottomBarOffset(200)
const bottomBarOffset = useBottomBarOffset(300)
const initialNumToRender = useInitialNumToRender()
const {currentAccount} = useSession()
const {data, refetch, isError} = useAllListMembersQuery(listUri)
+17
View File
@@ -0,0 +1,17 @@
import React from 'react'
const MessageContext = React.createContext(false)
export function MessageContextProvider({
children,
}: {
children: React.ReactNode
}) {
return (
<MessageContext.Provider value={true}>{children}</MessageContext.Provider>
)
}
export function useIsWithinMessage() {
return React.useContext(MessageContext)
}
+10 -7
View File
@@ -4,6 +4,7 @@ import {AppBskyEmbedRecord} from '@atproto/api'
import {PostEmbeds, PostEmbedViewContext} from '#/view/com/util/post-embeds'
import {atoms as a, native, useTheme} from '#/alf'
import {MessageContextProvider} from './MessageContext'
let MessageItemEmbed = ({
embed,
@@ -13,13 +14,15 @@ let MessageItemEmbed = ({
const t = useTheme()
return (
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
<MessageContextProvider>
<View style={[a.my_xs, t.atoms.bg, native({flexBasis: 0})]}>
<PostEmbeds
embed={embed}
allowNestedQuotes
viewContext={PostEmbedViewContext.Feed}
/>
</View>
</MessageContextProvider>
)
}
MessageItemEmbed = React.memo(MessageItemEmbed)
+2
View File
@@ -21,6 +21,7 @@ import {
ja,
ko,
ptBR,
ru,
tr,
uk,
zhCN,
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
ja,
ko,
['pt-BR']: ptBR,
ru,
tr,
uk,
['zh-CN']: zhCN,
+2 -2
View File
@@ -15,8 +15,8 @@ export function useFollowMethods({
logContext,
}: {
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext']
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:unfollow:sampled']['logContext']
}) {
const {_} = useLingui()
const requireAuth = useRequireAuth()
+53
View File
@@ -0,0 +1,53 @@
import {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import {isFirefox, isSafari} from '#/lib/browser'
import {isWeb} from '#/platform/detection'
function fullscreenSubscribe(onChange: () => void) {
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}
export function useFullscreen(ref?: React.RefObject<HTMLElement>) {
if (!isWeb) throw new Error("'useFullscreen' is a web-only hook")
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
Boolean(document.fullscreenElement),
)
const scrollYRef = useRef<null | number>(null)
const [prevIsFullscreen, setPrevIsFullscreen] = useState(isFullscreen)
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
document.exitFullscreen()
} else {
if (!ref) throw new Error('No ref provided')
if (!ref.current) return
scrollYRef.current = window.scrollY
ref.current.requestFullscreen()
}
}, [isFullscreen, ref])
useEffect(() => {
if (prevIsFullscreen === isFullscreen) return
setPrevIsFullscreen(isFullscreen)
// Chrome has an issue where it doesn't scroll back to the top after exiting fullscreen
// Let's play it safe and do it if not FF or Safari, since anything else will probably be chromium
if (prevIsFullscreen && !isFirefox && !isSafari) {
setTimeout(() => {
if (scrollYRef.current !== null) {
window.scrollTo(0, scrollYRef.current)
scrollYRef.current = null
}
}, 100)
}
}, [isFullscreen, prevIsFullscreen])
return [isFullscreen, toggleFullscreen] as const
}
+37
View File
@@ -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>
)
}
+29 -6
View File
@@ -2,24 +2,47 @@ import React from 'react'
import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
export function PlayButtonIcon({size = 44}: {size?: number}) {
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,
{
backgroundColor: t.palette.primary_500,
width: size + 16,
height: size + 16,
width: size + size / 1.5,
height: size + size / 1.5,
},
]}>
<PlayIcon height={size} width={size} style={{color: 'white'}} />
<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>
)
}
+4 -1
View File
@@ -21,7 +21,7 @@ export const STARTER_PACK_MAX_SIZE = 150
// code and update this number with each release until we can get the
// server route done.
// -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`
export function FEEDBACK_FORM_URL({
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
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 = [
'video/mp4',
'video/mpeg',
+15 -12
View File
@@ -1,17 +1,20 @@
import React from 'react'
export const useDedupe = () => {
export const useDedupe = (timeout = 250) => {
const canDo = React.useRef(true)
return React.useCallback((cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
canDo.current = true
}, 250)
cb()
return true
}
return false
}, [])
return React.useCallback(
(cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
canDo.current = true
}, timeout)
cb()
return true
}
return false
},
[timeout],
)
}
+32 -2
View File
@@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
import {useSession} from 'state/session'
import {useComposerControls} from 'state/shell'
import {useCloseAllActiveElements} from 'state/util'
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
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+)?$/
export function useIntentHandler() {
const incomingUrl = Linking.useURL()
const composeIntent = useComposeIntent()
const verifyEmailIntent = useVerifyEmailIntent()
React.useEffect(() => {
const handleIncomingURL = (url: string) => {
@@ -51,12 +53,22 @@ export function useIntentHandler() {
text: params.get('text'),
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)
}, [incomingUrl, composeIntent])
}, [incomingUrl, composeIntent, verifyEmailIntent])
}
function useComposeIntent() {
@@ -103,3 +115,21 @@ function useComposeIntent() {
[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],
)
}
+17 -3
View File
@@ -1,9 +1,14 @@
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'
const MIN_SIZE_FOR_COMPRESSION = 1024 * 1024 * 25 // 25mb
export async function compressVideo(
file: string,
file: ImagePickerAsset,
opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
@@ -11,12 +16,21 @@ export async function compressVideo(
): Promise<CompressedVideo> {
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(
file,
file.uri,
{
compressionMethod: 'manual',
bitrate: 3_000_000, // 3mbps
maxSize: 1920,
minimumFileSizeForCompress,
getCancellationId: id => {
if (signal) {
signal.addEventListener('abort', () => {
@@ -30,5 +44,5 @@ export async function compressVideo(
const info = await getVideoMetaData(compressed)
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
}
+6 -4
View File
@@ -1,17 +1,19 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
const MAX_VIDEO_SIZE = 1024 * 1024 * 50 // 50mb
// doesn't actually compress, but throws if >100MB
// doesn't actually compress, converts to ArrayBuffer
export async function compressVideo(
file: string,
asset: ImagePickerAsset,
_opts?: {
signal?: AbortSignal
onProgress?: (progress: number) => void
},
): Promise<CompressedVideo> {
const {mimeType, base64} = parseDataUrl(file)
const {mimeType, base64} = parseDataUrl(asset.uri)
const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
+8 -1
View File
@@ -1,6 +1,6 @@
export class VideoTooLargeError extends Error {
constructor() {
super('Videos cannot be larger than 100MB')
super('Videos cannot be larger than 50mb')
this.name = 'VideoTooLargeError'
}
}
@@ -11,3 +11,10 @@ export class ServerError extends Error {
this.name = 'ServerError'
}
}
export class UploadLimitError extends Error {
constructor(message: string) {
super(message)
this.name = 'UploadLimitError'
}
}
+7 -7
View File
@@ -25,7 +25,7 @@ export type LogEvents = {
secondsActive: number
}
'state:foreground:sampled': {}
'router:navigate:sampled': {}
'router:navigate:notifications:sampled': {}
'deepLink:referrerReceived': {
to: string
referrer: string
@@ -127,25 +127,25 @@ export type LogEvents = {
langs: string
logContext: 'Composer'
}
'post:like': {
'post:like:sampled': {
doesLikerFollowPoster: boolean | undefined
doesPosterFollowLiker: boolean | undefined
likerClout: number | undefined
postClout: number | undefined
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:repost': {
'post:repost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unlike': {
'post:unlike:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:unrepost': {
'post:unrepost:sampled': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
'post:mute': {}
'post:unmute': {}
'profile:follow': {
'profile:follow:sampled': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
followerClout: number | undefined
@@ -162,7 +162,7 @@ export type LogEvents = {
| 'FeedInterstitial'
| 'ProfileHeaderSuggestedFollows'
}
'profile:unfollow': {
'profile:unfollow:sampled': {
logContext:
| 'RecommendedFollowsItem'
| 'PostThreadItem'
+1 -8
View File
@@ -1,10 +1,3 @@
export type Gate =
// Keep this alphabetic please.
| 'debug_show_feedcontext'
| 'fixed_bottom_bar'
| 'onboarding_minimum_interests'
| 'suggested_feeds_interstitial'
| 'show_follow_suggestions_in_profile'
| 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
'debug_show_feedcontext' | 'suggested_feeds_interstitial' | 'video_upload' // upload videos
+14 -3
View File
@@ -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([
'router:navigate:sampled',
'router:navigate:notifications:sampled',
'state:background:sampled',
'state:foreground:sampled',
'home:feedDisplayed:sampled',
@@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
'discover:clickthrough:sampled',
'discover:engaged: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>(
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
}
const fullMetadata = {
...rawMetadata,
} as Record<string, string> // Statsig typings are unnecessarily strict here.
if (isDownsampledEvent) {
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
}
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
if (Statsig.initializeCalled()) {
Statsig.logEvent(eventName, null, fullMetadata)
+2
View File
@@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
return AppLanguage.ko
case 'pt-BR':
return AppLanguage.pt_BR
case 'ru':
return AppLanguage.ru
case 'tr':
return AppLanguage.tr
case 'uk':
+9
View File
@@ -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 messagesKo} from '#/locale/locales/ko/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 messagesUk} from '#/locale/locales/uk/messages'
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
@@ -131,6 +132,14 @@ export async function dynamicActivate(locale: AppLanguage) {
])
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
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await Promise.all([
+4
View File
@@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
mod = await import(`./locales/pt-BR/messages`)
break
}
case AppLanguage.ru: {
mod = await import(`./locales/ru/messages`)
break
}
case AppLanguage.tr: {
mod = await import(`./locales/tr/messages`)
break
+2
View File
@@ -18,6 +18,7 @@ export enum AppLanguage {
ja = 'ja',
ko = 'ko',
pt_BR = 'pt-BR',
ru = 'ru',
tr = 'tr',
uk = 'uk',
zh_CN = 'zh-CN',
@@ -43,6 +44,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
{code2: AppLanguage.ja, name: '日本語 Japanese'},
{code2: AppLanguage.ko, name: '한국어 Korean'},
{code2: AppLanguage.pt_BR, name: 'Português (BR) Portuguese (BR)'},
{code2: AppLanguage.ru, name: 'Русский Russian'},
{code2: AppLanguage.tr, name: 'Türkçe Turkish'},
{code2: AppLanguage.uk, name: 'Українська Ukrainian'},
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
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,
} from '#/state/messages/message-drafts'
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 {atoms as a, useTheme} from '#/alf'
import {useSharedInputStyles} from '#/components/forms/TextField'
@@ -41,6 +42,7 @@ export function MessageInput({
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {_} = useLingui()
const t = useTheme()
@@ -12,9 +12,16 @@ import {
} from '#/state/messages/message-drafts'
import {isSafari, isTouchDevice} from 'lib/browser'
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 {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
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 {useExtractEmbedFromFacets} from './MessageInputEmbed'
@@ -23,11 +30,13 @@ export function MessageInput({
hasEmbed,
setEmbed,
children,
openEmojiPicker,
}: {
onSendMessage: (message: string) => void
hasEmbed: boolean
setEmbed: (embedUrl: string | undefined) => void
children?: React.ReactNode
openEmojiPicker?: (pos: EmojiPickerPosition) => void
}) {
const {isTabletOrDesktop} = useWebMediaQueries()
const {_} = useLingui()
@@ -40,6 +49,7 @@ export function MessageInput({
const [isFocused, setIsFocused] = React.useState(false)
const [isHovered, setIsHovered] = React.useState(false)
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
const onSubmit = React.useCallback(() => {
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)
useExtractEmbedFromFacets(message, setEmbed)
@@ -106,7 +133,7 @@ export function MessageInput({
t.atoms.bg_contrast_25,
{
paddingRight: a.p_sm.padding - 2,
paddingLeft: a.p_md.padding - 2,
paddingLeft: a.p_sm.padding - 2,
borderWidth: 1,
borderRadius: 23,
borderColor: 'transparent',
@@ -118,7 +145,44 @@ export function MessageInput({
// @ts-expect-error web only
onMouseEnter={() => setIsHovered(true)}
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
ref={textAreaRef}
style={StyleSheet.flatten([
a.flex_1,
a.px_sm,
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
import {clamp} from 'lib/numbers'
import {ScrollProvider} from 'lib/ScrollContext'
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 {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
@@ -97,6 +101,12 @@ export function MessagesList({
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
// 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.
@@ -422,13 +432,22 @@ export function MessagesList({
<MessageInput
onSendMessage={onSendMessage}
hasEmbed={!!embedUri}
setEmbed={setEmbed}>
setEmbed={setEmbed}
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
</MessageInput>
</>
)}
</KeyboardStickyView>
{isWeb && (
<EmojiPicker
pinToTop
state={emojiPickerState}
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
/>
)}
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
</>
)
+2 -69
View File
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {logEvent} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {capitalize} from '#/lib/strings/capitalize'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useAgent} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
import {IconCircle} from '#/components/IconCircle'
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
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 {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
import {Loader} from '#/components/Loader'
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() {
const {_} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const {track} = useAnalytics()
const gate = useGate()
const interestsDisplayNames = useInterestsDisplayNames()
const {state, dispatch} = React.useContext(Context)
@@ -143,12 +134,6 @@ export function StepInterests() {
track('OnboardingV2:StepInterests:Start')
}, [track])
const isMinimumInterestsEnabled =
gate('onboarding_minimum_interests') && data?.interests.length !== 0
const meetsMinimumRequirement = isMinimumInterestsEnabled
? interests.length >= MIN_INTERESTS
: true
const title = isError ? (
<Trans>Oh no! Something went wrong.</Trans>
) : (
@@ -186,13 +171,8 @@ export function StepInterests() {
<TitleText>{title}</TitleText>
<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 ? (
<Loader size="xl" />
) : isError || !data ? (
@@ -268,7 +248,7 @@ export function StepInterests() {
</View>
) : (
<Button
disabled={saving || !data || !meetsMinimumRequirement}
disabled={saving || !data}
variant="gradient"
color="gradient_sky"
size="large"
@@ -283,53 +263,6 @@ export function StepInterests() {
/>
</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>
</View>
)
@@ -6,11 +6,9 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
import {useAnalytics} from 'lib/analytics/analytics'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
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 {MessageProfileButton} from '#/components/dms/MessageProfileButton'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed)
const t = useTheme()
const gate = useGate()
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const {openModal} = useModalControls()
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
profile,
'ProfileHeader',
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
)
) : !profile.viewer?.blockedBy ? (
<>
{hasSession && (
<>
<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>
)}
</>
)}
{hasSession && <MessageProfileButton profile={profile} />}
<Button
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
@@ -294,19 +261,6 @@ let ProfileHeaderStandard = ({
</>
)}
</View>
{showSuggestedFollows && (
<ProfileHeaderSuggestedFollows
actorDid={profile.did}
requestDismiss={() => {
if (showSuggestedFollows) {
setShowSuggestedFollows(false)
} else {
track('ProfileHeader:SuggestedFollowsOpened')
setShowSuggestedFollows(true)
}
}}
/>
)}
<Prompt.Basic
control={unblockPromptControl}
title={_(msg`Unblock Account?`)}
+29
View File
@@ -0,0 +1,29 @@
import zod from 'zod'
import {BaseNux} from '#/state/queries/nuxs/types'
export enum Nux {
One = 'one',
Two = 'two',
}
export const nuxNames = new Set(Object.values(Nux))
export type AppNux =
| BaseNux<{
id: Nux.One
data: {
likes: number
}
}>
| BaseNux<{
id: Nux.Two
data: undefined
}>
export const NuxSchemas = {
[Nux.One]: zod.object({
likes: zod.number(),
}),
[Nux.Two]: undefined,
}
+83
View File
@@ -0,0 +1,83 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
import {
preferencesQueryKey,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export {Nux} from '#/state/queries/nuxs/definitions'
export function useNuxs() {
const {data, ...rest} = usePreferencesQuery()
if (data && rest.isSuccess) {
const nuxs = data.bskyAppState.nuxs
?.map(parseAppNux)
?.filter(Boolean) as AppNux[]
if (nuxs) {
return {
nuxs,
...rest,
}
}
}
return {
nuxs: undefined,
...rest,
}
}
export function useNux<T extends Nux>(id: T) {
const {nuxs, ...rest} = useNuxs()
if (nuxs && rest.isSuccess) {
const nux = nuxs.find(nux => nux.id === id)
if (nux) {
return {
nux: nux as Extract<AppNux, {id: T}>,
...rest,
}
}
}
return {
nux: undefined,
...rest,
}
}
export function useUpsertNuxMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async (nux: AppNux) => {
await agent.bskyAppUpsertNux(serializeAppNux(nux))
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useRemoveNuxsMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async (ids: string[]) => {
await agent.bskyAppRemoveNuxs(ids)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
+9
View File
@@ -0,0 +1,9 @@
import {AppBskyActorDefs} from '@atproto/api'
export type Data = Record<string, unknown> | undefined
export type BaseNux<
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
> = T & {
completed: boolean
}
+52
View File
@@ -0,0 +1,52 @@
import {AppBskyActorDefs, nuxSchema} from '@atproto/api'
import {
AppNux,
Nux,
nuxNames,
NuxSchemas,
} from '#/state/queries/nuxs/definitions'
export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
if (!nuxNames.has(nux.id as Nux)) return
if (!nuxSchema.safeParse(nux).success) return
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
if (schema && data) {
const parsedData = JSON.parse(data)
if (!schema.safeParse(parsedData).success) return
return {
...rest,
data: parsedData,
} as AppNux
}
return {
...rest,
data: undefined,
} as AppNux
}
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
const result: AppBskyActorDefs.Nux = {
...rest,
data: undefined,
}
if (schema) {
schema.parse(data)
result.data = JSON.stringify(data)
}
nuxSchema.parse(result)
return result
}
+12 -12
View File
@@ -99,8 +99,8 @@ export function useGetPosts() {
export function usePostLikeMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:like']['logContext'] &
LogEvents['post:unlike']['logContext'],
logContext: LogEvents['post:like:sampled']['logContext'] &
LogEvents['post:unlike:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
}
function usePostLikeMutation(
logContext: LogEvents['post:like']['logContext'],
logContext: LogEvents['post:like:sampled']['logContext'],
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const {currentAccount} = useSession()
@@ -175,7 +175,7 @@ function usePostLikeMutation(
if (currentAccount) {
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
logEvent('post:like', {
logEvent('post:like:sampled', {
logContext,
doesPosterFollowLiker: postAuthor.viewer
? Boolean(postAuthor.viewer.followedBy)
@@ -200,12 +200,12 @@ function usePostLikeMutation(
}
function usePostUnlikeMutation(
logContext: LogEvents['post:unlike']['logContext'],
logContext: LogEvents['post:unlike:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: ({likeUri}) => {
logEvent('post:unlike', {logContext})
logEvent('post:unlike:sampled', {logContext})
return agent.deleteLike(likeUri)
},
onSuccess() {
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
export function usePostRepostMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:repost']['logContext'] &
LogEvents['post:unrepost']['logContext'],
logContext: LogEvents['post:repost:sampled']['logContext'] &
LogEvents['post:unrepost:sampled']['logContext'],
) {
const queryClient = useQueryClient()
const postUri = post.uri
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
}
function usePostRepostMutation(
logContext: LogEvents['post:repost']['logContext'],
logContext: LogEvents['post:repost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<
@@ -282,7 +282,7 @@ function usePostRepostMutation(
{uri: string; cid: string} // the post's uri and cid
>({
mutationFn: post => {
logEvent('post:repost', {logContext})
logEvent('post:repost:sampled', {logContext})
return agent.repost(post.uri, post.cid)
},
onSuccess() {
@@ -292,12 +292,12 @@ function usePostRepostMutation(
}
function usePostUnrepostMutation(
logContext: LogEvents['post:unrepost']['logContext'],
logContext: LogEvents['post:unrepost:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: ({repostUri}) => {
logEvent('post:unrepost', {logContext})
logEvent('post:unrepost:sampled', {logContext})
return agent.deleteRepost(repostUri)
},
onSuccess() {
+1
View File
@@ -37,5 +37,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
bskyAppState: {
queuedNudges: [],
activeProgressGuide: undefined,
nuxs: [],
},
}
+6 -6
View File
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
export function useProfileFollowMutationQueue(
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
logContext: LogEvents['profile:follow']['logContext'] &
LogEvents['profile:unfollow']['logContext'],
logContext: LogEvents['profile:follow:sampled']['logContext'] &
LogEvents['profile:follow:sampled']['logContext'],
) {
const agent = useAgent()
const queryClient = useQueryClient()
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
}
function useProfileFollowMutation(
logContext: LogEvents['profile:follow']['logContext'],
logContext: LogEvents['profile:follow:sampled']['logContext'],
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
) {
const {currentAccount} = useSession()
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
}
captureAction(ProgressGuideAction.Follow)
logEvent('profile:follow', {
logEvent('profile:follow:sampled', {
logContext,
didBecomeMutual: profile.viewer
? Boolean(profile.viewer.followedBy)
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
}
function useProfileUnfollowMutation(
logContext: LogEvents['profile:unfollow']['logContext'],
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
) {
const agent = useAgent()
return useMutation<void, Error, {did: string; followUri: string}>({
mutationFn: async ({followUri}) => {
logEvent('profile:unfollow', {logContext})
logEvent('profile:unfollow:sampled', {logContext})
track('Profile:Unfollow', {username: followUri})
return await agent.deleteFollow(followUri)
},
+1 -1
View File
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
mutationKey: ['video', 'compress'],
mutationFn: cancelable(
(asset: ImagePickerAsset) =>
compressVideo(asset.uri, {
compressVideo(asset, {
onProgress: num => onProgress(trunc2dp(num)),
signal,
}),
+18 -5
View File
@@ -1,15 +1,13 @@
import {useMemo} from 'react'
import {AtpAgent} from '@atproto/api'
import {SupportedMimeTypes} from '#/lib/constants'
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
const url = new URL(`${UPLOAD_ENDPOINT}`)
const url = new URL(VIDEO_SERVICE)
url.pathname = route
if (params) {
for (const key in params) {
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
export function useVideoAgent() {
return useMemo(() => {
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}`)
}
}
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])
}
+10 -18
View File
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
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()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
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(
uri,
video.uri,
{
headers: {
'content-type': video.mimeType,
Authorization: `Bearer ${serviceAuth.token}`,
Authorization: `Bearer ${await getToken()}`,
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
+12 -19
View File
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
import {ServerError} from '#/lib/media/video/errors'
import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
import {useSession} from '#/state/session'
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
export const useUploadVideoMutation = ({
onSuccess,
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
signal: AbortSignal
}) => {
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()
return useMutation({
mutationKey: ['video', 'upload'],
mutationFn: cancelable(async (video: CompressedVideo) => {
await checkLimits()
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did,
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
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
const token = await getToken()
const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
(resolve, reject) => {
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
}
xhr.open('POST', uri)
xhr.setRequestHeader('Content-Type', video.mimeType)
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.send(bytes)
},
)
+43 -4
View File
@@ -9,7 +9,11 @@ import {AbortError} from '#/lib/async/cancelable'
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
import {logger} from '#/logger'
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 {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util'
@@ -149,10 +153,45 @@ export function useUploadVideo({
onError: e => {
if (e instanceof AbortError) {
return
} else if (e instanceof ServerError) {
} 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! Were gradually giving access to video, and youre 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
case 'Account is not old enough to upload videos':
message = _(
msg`Your account is not yet old enough to upload videos. Please try again later.`,
)
break
default:
message = e.message
break
}
dispatch({
type: 'SetError',
error: e.message,
error: message,
})
} else {
dispatch({
@@ -185,7 +224,7 @@ export function useUploadVideo({
} else if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
error: _(msg`The selected video is larger than 50MB.`),
})
} else {
dispatch({
+1 -1
View File
@@ -34,7 +34,7 @@ export interface ComposerOpts {
quote?: ComposerOptsQuote
quoteCount?: number
mention?: string // handle of user to mention
openPicker?: (pos: DOMRect | undefined) => void
openEmojiPicker?: (pos: DOMRect | undefined) => void
text?: string
imageUris?: {uri: string; width: number; height: number}[]
}
+45 -39
View File
@@ -133,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
quote: initQuote,
quoteCount,
mention: initMention,
openPicker,
openEmojiPicker,
text: initText,
imageUris: initImageUris,
cancelRef,
@@ -200,6 +200,7 @@ export const ComposePost = observer(function ComposePost({
}
},
})
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
const [publishOnUpload, setPublishOnUpload] = useState(false)
@@ -302,9 +303,13 @@ export const ComposePost = observer(function ComposePost({
const onPhotoPasted = useCallback(
async (uri: string) => {
track('Composer:PastedPhotos')
await gallery.paste(uri)
if (uri.startsWith('data:video/')) {
selectVideo({uri, type: 'video', height: 0, width: 0})
} else {
await gallery.paste(uri)
}
},
[gallery, track],
[gallery, track, selectVideo],
)
const isAltTextRequiredAndMissing = useMemo(() => {
@@ -520,8 +525,8 @@ export const ComposePost = observer(function ComposePost({
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
}, [openPicker])
openEmojiPicker?.(textInput.current?.getCursorPosition())
}, [openEmojiPicker])
const focusTextInput = useCallback(() => {
textInput.current?.focus()
@@ -730,8 +735,37 @@ export const ComposePost = observer(function ComposePost({
/>
</View>
)}
<View style={[a.mt_md]}>
<LayoutAnimationConfig skipExiting>
{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 ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
@@ -742,36 +776,6 @@ export const ComposePost = observer(function ComposePost({
)}
</View>
) : null}
<LayoutAnimationConfig skipExiting>
{(videoUploadState.asset || videoUploadState.video) && (
<Animated.View
style={[a.w_full, a.mt_xs]}
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>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
@@ -1154,10 +1158,12 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const progress = state.jobStatus?.progress
? state.jobStatus.progress / 100
: state.progress
let wheelProgress = progress === 0 || progress === 1 ? 0.33 : progress
const shouldRotate =
state.status === 'processing' && (progress === 0 || progress === 1)
let wheelProgress = shouldRotate ? 0.33 : progress
const rotate = useDerivedValue(() => {
if (progress === 0 || progress >= 0.99) {
if (shouldRotate) {
return withRepeat(
withTiming(360, {
duration: 2500,
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
import {Text as TiptapText} from '@tiptap/extension-text'
import {generateJSON} from '@tiptap/html'
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
import EventEmitter from 'eventemitter3'
import {usePalette} from '#/lib/hooks/usePalette'
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {blobToDataUri, isUriImage} from 'lib/media/util'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {
LinkFacetMatch,
suggestLinkCardUri,
@@ -46,8 +46,6 @@ interface TextInputProps {
onError: (err: string) => void
}
export const textInputWebEmitter = new EventEmitter()
export const TextInput = React.forwardRef(function TextInputImpl(
{
richtext,
@@ -95,9 +93,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
}
}, [onPressPublish])
React.useEffect(() => {
textInputWebEmitter.addListener('photo-pasted', onPhotoPasted)
textInputWebEmitter.addListener('media-pasted', onPhotoPasted)
return () => {
textInputWebEmitter.removeListener('photo-pasted', onPhotoPasted)
textInputWebEmitter.removeListener('media-pasted', onPhotoPasted)
}
}, [onPhotoPasted])
@@ -107,8 +105,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
if (transfer) {
const items = transfer.items
getImageFromUri(items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
getImageOrVideoFromUri(items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
})
}
@@ -162,8 +160,8 @@ export const TextInput = React.forwardRef(function TextInputImpl(
view.pasteText(text)
preventDefault = true
}
getImageFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('photo-pasted', uri)
getImageOrVideoFromUri(clipboardData.items, (uri: string) => {
textInputWebEmitter.emit('media-pasted', uri)
})
if (preventDefault) {
// Return `true` to prevent ProseMirror's default paste behavior.
@@ -348,7 +346,7 @@ const styles = StyleSheet.create({
},
})
function getImageFromUri(
function getImageOrVideoFromUri(
items: DataTransferItemList,
callback: (uri: string) => void,
) {
@@ -365,11 +363,21 @@ function getImageFromUri(
if (blob.type.startsWith('image/')) {
blobToDataUri(blob).then(callback, err => console.error(err))
}
if (blob.type.startsWith('video/')) {
blobToDataUri(blob).then(callback, err => console.error(err))
}
}
})
} else if (type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
blobToDataUri(file).then(callback, err => console.error(err))
}
} else if (type.startsWith('video/')) {
const file = item.getAsFile()
if (file) {
blobToDataUri(file).then(callback, err => console.error(err))
}
@@ -0,0 +1,3 @@
import EventEmitter from 'eventemitter3'
export const textInputWebEmitter = new EventEmitter()
@@ -7,8 +7,8 @@ import {
} from 'react-native'
import Picker from '@emoji-mart/react'
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
import {atoms as a} from '#/alf'
import {textInputWebEmitter} from '../TextInput.web'
const HEIGHT_OFFSET = 40
const WIDTH_OFFSET = 100
@@ -26,22 +26,41 @@ export type Emoji = {
unified: string
}
export interface EmojiPickerPosition {
top: number
left: number
right: number
bottom: number
}
export interface EmojiPickerState {
isOpen: boolean
pos: {top: number; left: number; right: number; bottom: number}
pos: EmojiPickerPosition
}
interface IProps {
state: EmojiPickerState
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 isShiftDown = React.useRef(false)
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 fitsAbove = PICKER_HEIGHT < state.pos.top
const placeOnLeft = PICKER_WIDTH < state.pos.left
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
: undefined,
}
}
}, [state.pos, height, width])
}, [state.pos, height, width, pinToTop])
React.useEffect(() => {
if (!state.isOpen) return
@@ -20,6 +20,8 @@ import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons
import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker'
const MAX_NUM_CAPTIONS = 1
interface Props {
defaultAltText: string
captions: {lang: string; file: File}[]
@@ -134,7 +136,9 @@ function SubtitleDialogInner({
</Text>
<SubtitleFilePicker
onSelectFile={handleSelectFile}
disabled={subtitleMissingLanguage || captions.length >= 4}
disabled={
subtitleMissingLanguage || captions.length >= MAX_NUM_CAPTIONS
}
/>
<View>
{captions.map((subtitle, i) => (
@@ -60,7 +60,7 @@ export function VideoPreview({
<ExternalEmbedRemoveBtn onRemove={clear} />
{autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={48} />
<PlayButtonIcon />
</View>
)}
</View>
@@ -42,7 +42,7 @@ export function VideoPreview({
ref.current.addEventListener(
'error',
() => {
Toast.show(_(msg`Could not process your video`))
Toast.show(_(msg`Could not process your video`), 'xmark')
clear()
},
{signal},
@@ -83,7 +83,7 @@ export function VideoPreview({
/>
{autoplayDisabled && (
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
<PlayButtonIcon size={48} />
<PlayButtonIcon />
</View>
)}
</View>
+1 -1
View File
@@ -173,7 +173,7 @@ export function Component({
accessibilityLabel={_(msg`Confirmation code`)}
accessibilityHint=""
autoCapitalize="none"
autoComplete="off"
autoComplete="one-time-code"
autoCorrect={false}
/>
) : undefined}
+1 -21
View File
@@ -1,4 +1,4 @@
import React, {memo, useId, useMemo, useState} from 'react'
import React, {memo, useMemo, useState} from 'react'
import {StyleSheet, View} from 'react-native'
import {
AppBskyActorDefs,
@@ -21,7 +21,6 @@ import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
import {MAX_POST_LINES} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {makeProfileLink} from '#/lib/routes/links'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {countLines} from '#/lib/strings/helpers'
@@ -47,7 +46,6 @@ import {AppModerationCause} from '#/components/Pills'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
import {AviFollowButton} from './AviFollowButton'
interface FeedItemProps {
@@ -386,7 +384,6 @@ let FeedItemInner = ({
post={post}
threadgateRecord={threadgateRecord}
/>
<VideoDebug />
<PostCtrls
post={post}
record={record}
@@ -564,23 +561,6 @@ function ReplyToLabel({
)
}
function VideoDebug() {
const gate = useGate()
const id = useId()
if (!gate('video_debug')) return null
return (
<VideoEmbed
embed={{
playlist: `https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`,
cid: 'Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ',
aspectRatio: {height: 9, width: 16},
}}
/>
)
}
const styles = StyleSheet.create({
outer: {
paddingLeft: 10,
@@ -1,190 +0,0 @@
import React from 'react'
import {ScrollView, View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logEvent} from '#/lib/statsig/statsig'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {isWeb} from 'platform/detection'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
const OUTER_PADDING = a.p_md.padding
const INNER_PADDING = a.p_lg.padding
const TOTAL_HEIGHT = 232
const MOBILE_CARD_WIDTH = 300
function CardOuter({
children,
style,
}: {children: React.ReactNode | React.ReactNode[]} & ViewStyleProp) {
const t = useTheme()
return (
<View
style={[
a.w_full,
a.p_lg,
a.rounded_md,
a.border,
t.atoms.bg,
t.atoms.border_contrast_low,
{
width: MOBILE_CARD_WIDTH,
},
style,
]}>
{children}
</View>
)
}
export function SuggestedFollowPlaceholder() {
const t = useTheme()
return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
<ProfileCard.Header>
<ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header>
<ProfileCard.DescriptionPlaceholder />
</CardOuter>
)
}
export function ProfileHeaderSuggestedFollows({
actorDid,
requestDismiss,
}: {
actorDid: string
requestDismiss: () => void
}) {
const t = useTheme()
const {_} = useLingui()
const {isLoading: isSuggestionsLoading, data} =
useSuggestedFollowsByActorQuery({
did: actorDid,
})
const moderationOpts = useModerationOpts()
const isLoading = isSuggestionsLoading || !moderationOpts
return (
<View
style={{paddingVertical: OUTER_PADDING, height: TOTAL_HEIGHT}}
pointerEvents="box-none">
<View
pointerEvents="box-none"
style={[
t.atoms.bg_contrast_25,
{
height: '100%',
paddingTop: INNER_PADDING / 2,
},
]}>
<View
pointerEvents="box-none"
style={[
a.flex_row,
a.justify_between,
a.align_center,
a.pt_xs,
{
paddingBottom: INNER_PADDING / 2,
paddingLeft: INNER_PADDING,
paddingRight: INNER_PADDING / 2,
},
]}>
<Text style={[a.text_md, a.font_bold, t.atoms.text_contrast_medium]}>
<Trans>Similar accounts</Trans>
</Text>
<Button
onPress={requestDismiss}
hitSlop={10}
label={_(msg`Dismiss`)}
size="xsmall"
variant="ghost"
color="secondary"
shape="round">
<ButtonIcon icon={X} size="sm" />
</Button>
</View>
<ScrollView
horizontal={true}
showsHorizontalScrollIndicator={isWeb}
persistentScrollbar={true}
scrollIndicatorInsets={{bottom: 0}}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_sm.gap}
decelerationRate="fast">
<View
style={[
a.flex_row,
a.gap_sm,
{
paddingHorizontal: INNER_PADDING,
paddingBottom: INNER_PADDING,
},
]}>
{isLoading ? (
<>
<SuggestedFollowPlaceholder />
<SuggestedFollowPlaceholder />
<SuggestedFollowPlaceholder />
<SuggestedFollowPlaceholder />
<SuggestedFollowPlaceholder />
</>
) : data ? (
data.suggestions
.filter(s => (s.associated?.labeler ? false : true))
.map(profile => (
<ProfileCard.Link
key={profile.did}
profile={profile}
onPress={() => {
logEvent('profile:header:suggestedFollowsCard:press', {})
}}
style={[a.flex_1]}>
{({hovered, pressed}) => (
<CardOuter
style={[
a.flex_1,
(hovered || pressed) && t.atoms.border_contrast_high,
]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
<ProfileCard.FollowButton
profile={profile}
moderationOpts={moderationOpts}
logContext="ProfileHeaderSuggestedFollows"
color="secondary_inverted"
shape="round"
/>
</ProfileCard.Header>
<ProfileCard.Description profile={profile} />
</ProfileCard.Outer>
</CardOuter>
)}
</ProfileCard.Link>
))
) : (
<View />
)}
</View>
</ScrollView>
</View>
</View>
)
}
+7 -2
View File
@@ -7,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles'
import {isIOS} from 'platform/detection'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views'
@@ -49,7 +50,7 @@ function ListImpl<ItemT>(
) {
const isScrolledDown = useSharedValue(false)
const pal = usePalette('default')
const dedupe = useDedupe()
const dedupe = useDedupe(400)
function handleScrolledDownChange(didScrollDown: boolean) {
onScrolledDownChange?.(didScrollDown)
@@ -68,6 +69,7 @@ function ListImpl<ItemT>(
onBeginDragFromContext?.(e, ctx)
},
onEndDrag(e, ctx) {
runOnJS(updateActiveViewAsync)()
onEndDragFromContext?.(e, ctx)
},
onScroll(e, ctx) {
@@ -81,11 +83,14 @@ function ListImpl<ItemT>(
}
}
runOnJS(dedupe)(updateActiveViewAsync)
if (isIOS) {
runOnJS(dedupe)(updateActiveViewAsync)
}
},
// Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) {
runOnJS(updateActiveViewAsync)()
onMomentumEndFromContext?.(e, ctx)
},
})
+2 -17
View File
@@ -9,7 +9,6 @@ import {
import EventEmitter from 'eventemitter3'
import {ScrollProvider} from '#/lib/ScrollContext'
import {useGate} from '#/lib/statsig/statsig'
import {useMinimalShellMode} from '#/state/shell'
import {useShellLayout} from '#/state/shell/shell-layout'
import {isNative, isWeb} from 'platform/detection'
@@ -23,12 +22,10 @@ function clamp(num: number, min: number, max: number) {
export function MainScrollProvider({children}: {children: React.ReactNode}) {
const {headerHeight} = useShellLayout()
const {headerMode, footerMode} = useMinimalShellMode()
const {headerMode} = useMinimalShellMode()
const startDragOffset = useSharedValue<number | null>(null)
const startMode = useSharedValue<number | null>(null)
const didJustRestoreScroll = useSharedValue<boolean>(false)
const gate = useGate()
const isFixedBottomBar = gate('fixed_bottom_bar')
const setMode = React.useCallback(
(v: boolean) => {
@@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
headerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
if (!isFixedBottomBar) {
cancelAnimation(footerMode)
footerMode.value = withSpring(v ? 1 : 0, {
overshootClamping: true,
})
}
},
[headerMode, footerMode, isFixedBottomBar],
[headerMode],
)
useEffect(() => {
@@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
// Cancel any any existing animation
cancelAnimation(headerMode)
headerMode.value = newValue
if (!isFixedBottomBar) {
cancelAnimation(footerMode)
footerMode.value = newValue
}
}
} else {
if (didJustRestoreScroll.value) {
@@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
[
headerHeight,
headerMode,
footerMode,
setMode,
startDragOffset,
startMode,
didJustRestoreScroll,
isFixedBottomBar,
],
)
-1
View File
@@ -59,7 +59,6 @@ function Toast({
a.flex_1,
t.atoms.bg,
a.shadow_lg,
a.rounded_sm,
t.atoms.border_contrast_medium,
a.rounded_sm,
a.px_md,
+1
View File
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
// @ts-ignore web-only
position: isWeb ? 'fixed' : 'absolute',
zIndex: 1,
cursor: 'pointer',
},
inner: {
justifyContent: 'center',
@@ -1,12 +1,12 @@
import React from 'react'
import {useVideoPlayer, VideoPlayer} from 'expo-video'
import {isNative} from '#/platform/detection'
import {isAndroid, isNative} from '#/platform/detection'
const Context = React.createContext<{
activeSource: string
activeViewId: string | undefined
setActiveSource: (src: string, viewId: string) => void
setActiveSource: (src: string | null, viewId: string | null) => void
player: VideoPlayer
} | null>(null)
@@ -21,12 +21,24 @@ export function Provider({children}: {children: React.ReactNode}) {
const player = useVideoPlayer(activeSource, p => {
p.muted = true
p.loop = true
// We want to immediately call `play` so we get the loading state
p.play()
})
const setActiveSourceOuter = (src: string, viewId: string) => {
setActiveSource(src)
setActiveViewId(viewId)
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
// HACK
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
// unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
// apply it there.
if (src === activeSource && isAndroid) {
setActiveSource('')
setTimeout(() => {
setActiveSource(src ? src : '')
}, 100)
} else {
setActiveSource(src ? src : '')
}
setActiveViewId(viewId ? viewId : '')
}
return (
+59 -49
View File
@@ -1,16 +1,17 @@
import React, {useCallback, useEffect, useId, useState} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {ImageBackground} from 'expo-image'
import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig'
import {useAutoplayDisabled} from 'state/preferences'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a} from '#/alf'
import {Button} from '#/components/Button'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
@@ -23,8 +24,6 @@ interface Props {
}
export function VideoEmbed({embed}: Props) {
const gate = useGate()
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -41,10 +40,6 @@ export function VideoEmbed({embed}: Props) {
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
}
if (!gate('video_view_on_posts')) {
return null
}
return (
<View
style={[
@@ -69,18 +64,21 @@ function InnerWrapper({embed}: Props) {
const viewId = useId()
const [playerStatus, setPlayerStatus] = useState<
VideoPlayerStatus | 'switching'
>('loading')
VideoPlayerStatus | 'paused'
>('paused')
const [isMuted, setIsMuted] = useState(player.muted)
const [isFullscreen, setIsFullscreen] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0)
const isWithinMessage = useIsWithinMessage()
const disableAutoplay = useAutoplayDisabled() || isWithinMessage
const isActive = embed.playlist === activeSource && activeViewId === viewId
// There are some different loading states that we should pay attention to and show a spinner for
const isLoading =
isActive &&
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
playerStatus === 'loading')
const isSwitching = playerStatus === 'switching'
const showOverlay = !isActive || isLoading || isSwitching
// This happens whenever the visibility view decides that another video should start playing
const showOverlay = !isActive || isLoading || playerStatus === 'paused'
// send error up to error boundary
const [error, setError] = useState<Error | PlayerError | null>(null)
@@ -102,11 +100,14 @@ function InnerWrapper({embed}: Props) {
)
const statusSub = player.addListener(
'statusChange',
(status, _oldStatus, playerError) => {
(status, oldStatus, playerError) => {
setPlayerStatus(status)
if (status === 'error') {
setError(playerError ?? new Error('Unknown player error'))
}
if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
player.play()
}
},
)
return () => {
@@ -115,35 +116,47 @@ function InnerWrapper({embed}: Props) {
statusSub.remove()
}
}
}, [player, isActive])
}, [player, isActive, disableAutoplay])
useEffect(() => {
if (!isActive && playerStatus !== 'loading') {
setPlayerStatus('loading')
}
}, [isActive, playerStatus])
const onChangeStatus = (isVisible: boolean) => {
if (isFullscreen) {
// The source might already be active (for example, if you are scrolling a list of quotes and its all the same
// video). In those cases, just start playing. Otherwise, setting the active source will result in the video
// start playback immediately
const startPlaying = (ignoreAutoplayPreference: boolean) => {
if (disableAutoplay && !ignoreAutoplayPreference) {
return
}
if (isVisible) {
setActiveSource(embed.playlist, viewId)
if (!player.playing) {
player.play()
}
if (isActive) {
player.play()
} else {
setPlayerStatus('switching')
player.muted = true
if (player.playing) {
player.pause()
setActiveSource(embed.playlist, viewId)
}
}
const onVisibilityStatusChange = (isVisible: boolean) => {
// When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change
// events
if (isFullscreen) {
return
}
if (isVisible) {
startPlaying(false)
} else {
// Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted
// until it gets replaced by another video
if (disableAutoplay) {
setActiveSource(null, null)
} else {
player.muted = true
if (player.playing) {
player.pause()
}
}
}
}
return (
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}>
{isActive ? (
<VideoEmbedInnerNative
embed={embed}
@@ -153,29 +166,26 @@ function InnerWrapper({embed}: Props) {
setIsFullscreen={setIsFullscreen}
/>
) : null}
<View
<ImageBackground
source={{uri: embed.thumbnail}}
accessibilityIgnoresInvertColors
style={[
{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here,
// the play button won't show up on the first render on android 🥴😮‍💨
display: showOverlay ? 'flex' : 'none',
},
]}>
<Image
source={{uri: embed.thumbnail}}
alt={embed.alt}
style={a.flex_1}
contentFit="cover"
accessibilityIgnoresInvertColors
/>
]}
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
>
<Button
style={[a.absolute, a.inset_0]}
onPress={() => {
setActiveSource(embed.playlist, viewId)
}}
style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => startPlaying(true)}
label={_(msg`Play video`)}
color="secondary">
{isLoading ? (
@@ -183,8 +193,8 @@ function InnerWrapper({embed}: Props) {
style={[
a.rounded_full,
a.p_xs,
a.absolute,
{top: 'auto', left: 'auto'},
a.align_center,
a.justify_center,
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}>
<Loader size="2xl" style={{color: 'white'}} />
@@ -193,7 +203,7 @@ function InnerWrapper({embed}: Props) {
<PlayButtonIcon />
)}
</Button>
</View>
</ImageBackground>
</VisibilityView>
)
}
@@ -4,27 +4,30 @@ import {AppBskyEmbedVideo} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isFirefox} from '#/lib/browser'
import {clamp} from '#/lib/numbers'
import {useGate} from '#/lib/statsig/statsig'
import {
HLSUnsupportedError,
VideoEmbedInnerWeb,
VideoNotFoundError,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a} from '#/alf'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const ref = useRef<HTMLDivElement>(null)
const gate = useGate()
const {active, setActive, sendPosition, currentActiveView} =
useActiveVideoWeb()
const [onScreen, setOnScreen] = useState(false)
const [isFullscreen] = useFullscreen()
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
@@ -38,7 +41,17 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
}, [sendPosition, isFullscreen])
// In case scrolling hasn't started yet, send up the position
const isAnyViewActive = currentActiveView !== null
useEffect(() => {
if (ref.current && !isAnyViewActive) {
const rect = ref.current.getBoundingClientRect()
const position = rect.y + rect.height / 2
sendPosition(position)
}
}, [isAnyViewActive, sendPosition])
const [key, setKey] = useState(0)
const renderError = useCallback(
@@ -48,10 +61,6 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
[key],
)
if (!gate('video_view_on_posts')) {
return null
}
let aspectRatio = 16 / 9
if (embed.aspectRatio) {
@@ -75,9 +84,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
style={{display: 'flex', flex: 1, cursor: 'default'}}
onClick={evt => evt.stopPropagation()}>
<ErrorBoundary renderError={renderError} key={key}>
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<ViewportObserver sendPosition={sendPosition}>
<VideoEmbedInnerWeb
embed={embed}
active={active}
@@ -98,19 +105,20 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
function ViewportObserver({
children,
sendPosition,
isAnyViewActive,
}: {
children: React.ReactNode
sendPosition: (position: number) => void
isAnyViewActive?: boolean
}) {
const ref = useRef<HTMLDivElement>(null)
const [nearScreen, setNearScreen] = useState(false)
const [isFullscreen] = useFullscreen()
const isWithinMessage = useIsWithinMessage()
// Send position when scrolling. This is done with an IntersectionObserver
// observing a div of 100vh height
useEffect(() => {
if (!ref.current) return
if (isFullscreen && !isFirefox) return
const observer = new IntersectionObserver(
entries => {
const entry = entries[0]
@@ -124,16 +132,7 @@ function ViewportObserver({
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [sendPosition])
// In case scrolling hasn't started yet, send up the position
useEffect(() => {
if (ref.current && !isAnyViewActive) {
const rect = ref.current.getBoundingClientRect()
const position = rect.y + rect.height / 2
sendPosition(position)
}
}, [isAnyViewActive, sendPosition])
}, [sendPosition, isFullscreen])
return (
<View style={[a.flex_1, a.flex_row]}>
@@ -141,10 +140,12 @@ function ViewportObserver({
<div
ref={ref}
style={{
// Don't escape bounds when in a message
...(isWithinMessage
? {top: 0, height: '100%'}
: {top: 'calc(50% - 50vh)', height: '100vh'}),
position: 'absolute',
top: 'calc(50% - 50vh)',
left: '50%',
height: '100vh',
width: 1,
pointerEvents: 'none',
}}
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants'
import {clamp} from '#/lib/numbers'
import {isAndroid} from 'platform/detection'
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
import {atoms as a, useTheme} from '#/alf'
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
@@ -61,15 +62,15 @@ export function VideoEmbedInnerNative({
PlatformInfo.setAudioActive(true)
player.muted = false
setIsFullscreen(true)
if (isAndroid) {
player.play()
}
}}
onFullscreenExit={() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
player.muted = true
player.playbackRate = 1
if (!player.playing) {
player.play()
}
setIsFullscreen(false)
}}
accessibilityLabel={
@@ -35,7 +35,13 @@ export function VideoEmbedInnerWeb({
if (!ref.current) return
if (!Hls.isSupported()) throw new HLSUnsupportedError()
const hls = new Hls({capLevelToPlayerSize: true})
const hls = new Hls({
capLevelToPlayerSize: true,
maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
// it will buffer until it it's greater than *both* of those values
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
})
hlsRef.current = hls
hls.attachMedia(ref.current)
@@ -1,10 +1,4 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {SvgProps} from 'react-native-svg'
import {msg, Trans} from '@lingui/macro'
@@ -21,6 +15,8 @@ import {
} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button} from '#/components/Button'
import {useIsWithinMessage} from '#/components/dms/MessageContext'
import {useFullscreen} from '#/components/hooks/useFullscreen'
import {useInteractionState} from '#/components/hooks/useInteractionState'
import {
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
@@ -100,6 +96,15 @@ export function Controls({
}
}, [interactingViaKeypress])
useEffect(() => {
if (isFullscreen) {
document.documentElement.style.scrollbarGutter = 'unset'
return () => {
document.documentElement.style.removeProperty('scrollbar-gutter')
}
}
}, [isFullscreen])
// pause + unfocus when another video is active
useEffect(() => {
if (!active) {
@@ -109,7 +114,8 @@ export function Controls({
}, [active, pause, setFocused])
// autoplay/pause based on visibility
const autoplayDisabled = useAutoplayDisabled()
const isWithinMessage = useIsWithinMessage()
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
useEffect(() => {
if (active) {
if (onScreen) {
@@ -126,8 +132,12 @@ export function Controls({
if (focused) {
// auto decide quality based on network conditions
hlsRef.current.autoLevelCapping = -1
// allow 30s of buffering
hlsRef.current.config.maxMaxBufferLength = 30
} else {
// back to what we initially set
hlsRef.current.autoLevelCapping = 0
hlsRef.current.config.maxMaxBufferLength = 10
}
}, [hlsRef, focused])
@@ -240,6 +250,39 @@ export function Controls({
}
}, [])
// these are used to trigger the hover state. on mobile, the hover state
// should stick around for a bit after they tap, and if the controls aren't
// present this initial tab should *only* show the controls and not activate anything
const onPointerDown = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
if (evt.pointerType !== 'mouse' && !hovered) {
evt.preventDefault()
}
},
[hovered],
)
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
const onHoverWithTimeout = useCallback(() => {
onHover()
clearTimeout(timeoutRef.current)
}, [onHover])
const onEndHoverWithTimeout = useCallback(
(evt: React.PointerEvent<HTMLDivElement>) => {
// if touch, end after 3s
// if mouse, end immediately
if (evt.pointerType !== 'mouse') {
setTimeout(onEndHover, 3000)
} else {
onEndHover()
}
},
[onEndHover],
)
const showControls =
((focused || autoplayDisabled) && !playing) ||
(interactingViaKeypress ? hasFocus : hovered)
@@ -257,9 +300,10 @@ export function Controls({
evt.stopPropagation()
setInteractingViaKeypress(false)
}}
onPointerEnter={onHover}
onPointerMove={onHover}
onPointerLeave={onEndHover}
onPointerEnter={onHoverWithTimeout}
onPointerMove={onHoverWithTimeout}
onPointerLeave={onEndHoverWithTimeout}
onPointerDown={onPointerDown}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}>
@@ -341,8 +385,8 @@ export function Controls({
)}
<ControlButton
active={muted}
activeLabel={_(msg`Unmute`)}
inactiveLabel={_(msg`Mute`)}
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
activeIcon={MuteIcon}
inactiveIcon={UnmuteIcon}
onPress={onPressMute}
@@ -548,7 +592,7 @@ function Scrubber({
return (
<View
testID="scrubber"
style={[{height: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs, a.py_xs]}
onPointerEnter={onStartHover}
onPointerLeave={onEndHover}>
<div
@@ -850,25 +894,3 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
canPlay,
}
}
function fullscreenSubscribe(onChange: () => void) {
document.addEventListener('fullscreenchange', onChange)
return () => document.removeEventListener('fullscreenchange', onChange)
}
function useFullscreen(ref: React.RefObject<HTMLElement>) {
const isFullscreen = useSyncExternalStore(fullscreenSubscribe, () =>
Boolean(document.fullscreenElement),
)
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
document.exitFullscreen()
} else {
if (!ref.current) return
ref.current.requestFullscreen()
}
}, [isFullscreen, ref])
return [isFullscreen, toggleFullscreen] as const
}
+2 -32
View File
@@ -1,24 +1,18 @@
import React from 'react'
import {ActivityIndicator, AppState, StyleSheet, View} from 'react-native'
import {ActivityIndicator, StyleSheet, View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
import {useGate} from '#/lib/statsig/statsig'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
import {FeedParams} from '#/state/queries/post-feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
import {useSession} from '#/state/session'
import {
useMinimalShellMode,
useSetDrawerSwipeDisabled,
useSetMinimalShellMode,
} from '#/state/shell'
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
@@ -87,7 +81,6 @@ function HomeScreenReady({
const selectedIndex = Math.max(0, maybeFoundIndex)
const selectedFeed = allFeeds[selectedIndex]
const requestNotificationsPermission = useRequestNotificationsPermission()
const gate = useGate()
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
useOTAUpdates()
@@ -134,29 +127,6 @@ function HomeScreenReady({
}),
)
const {footerMode} = useMinimalShellMode()
const {isMobile} = useWebMediaQueries()
useFocusEffect(
React.useCallback(() => {
if (gate('fixed_bottom_bar')) {
// Unnecessary because it's always there.
return
}
const listener = AppState.addEventListener('change', nextAppState => {
if (nextAppState === 'active') {
if (isMobile && footerMode.value === 1) {
// Reveal the bottom bar so you don't miss notifications or messages.
// TODO: Experiment with only doing it when unread > 0.
setMinimalShellMode(false)
}
}
})
return () => {
listener.remove()
}
}, [setMinimalShellMode, footerMode, isMobile, gate]),
)
const onPageSelected = React.useCallback(
(index: number) => {
setMinimalShellMode(false)
+1 -1
View File
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
quoteCount={state?.quoteCount}
onPost={state.onPost}
mention={state.mention}
openPicker={onOpenPicker}
openEmojiPicker={onOpenPicker}
text={state.text}
/>
</View>
+1 -1
View File
@@ -160,7 +160,6 @@ export function BottomBar({navigation}: BottomTabBarProps) {
accessibilityHint=""
/>
<Btn
testID="bottomBarSearchBtn"
icon={
isAtSearch ? (
<MagnifyingGlassFilled
@@ -169,6 +168,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
/>
) : (
<MagnifyingGlass
testID="bottomBarSearchBtn"
width={iconWidth + 2}
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
/>
+24 -1
View File
@@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import * as NavigationBar from 'expo-navigation-bar'
import {StatusBar} from 'expo-status-bar'
import {useNavigationState} from '@react-navigation/native'
import {useNavigation, useNavigationState} from '@react-navigation/native'
import {useSession} from '#/state/session'
import {
@@ -20,6 +20,7 @@ import {
useSetDrawerOpen,
} from '#/state/shell'
import {useCloseAnyActiveElement} from '#/state/util'
import {useDedupe} from 'lib/hooks/useDedupe'
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
import {usePalette} from 'lib/hooks/usePalette'
import {useNotificationsRegistration} from 'lib/notifications/notifications'
@@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {RoutesContainer, TabsNavigator} from '../../Navigation'
import {Composer} from './Composer'
import {DrawerContent} from './Drawer'
@@ -76,6 +78,27 @@ function ShellInner() {
}
}, [closeAnyActiveElement])
// HACK
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
// unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
// apply it there.
// The `state` event should only fire whenever we push or pop to a screen, and should not fire consecutively quickly.
// To be certain though, we will also dedupe these calls.
const navigation = useNavigation()
const dedupe = useDedupe(1000)
React.useEffect(() => {
if (!isAndroid) return
const onFocusOrBlur = () => {
setTimeout(() => {
dedupe(updateActiveViewAsync)
}, 500)
}
navigation.addListener('state', onFocusOrBlur)
return () => {
navigation.removeListener('state', onFocusOrBlur)
}
}, [dedupe, navigation])
return (
<>
<Animated.View
+20 -19
View File
@@ -72,19 +72,6 @@
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
"@atproto/api@0.13.5":
version "0.13.5"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.13.0":
version "0.13.0"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
@@ -98,6 +85,20 @@
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.13.7":
version "0.13.7"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.7.tgz#072eba2025d5251505f17b0b5d2de33749ea5ee4"
integrity sha512-41kSLmFWDbuPOenb52WRq1lnBkSZrL+X29tWcvEt6SZXK4xBoKAalw1MjF+oabhzff12iMtNaNvmmt2fu1L+cw==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.2"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/aws@^0.2.2":
version "0.2.2"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653"
@@ -443,10 +444,10 @@
"@atproto/lexicon" "^0.4.1"
zod "^3.23.8"
"@atproto/xrpc@^0.6.1":
version "0.6.1"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5"
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A==
"@atproto/xrpc@^0.6.2":
version "0.6.2"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.2.tgz#634228a7e533de01bda2214837d11574fdadad55"
integrity sha512-as/gb08xJb02HAGNrSQSumCe10WnOAcnM6bR6KMatQyQJuEu7OY6ZDSTM/4HfjjoxsNqdvPmbYuoUab1bKTNlA==
dependencies:
"@atproto/lexicon" "^0.4.1"
zod "^3.23.8"
@@ -12414,9 +12415,9 @@ expo-updates@~0.25.14:
ignore "^5.3.1"
resolve-from "^5.0.0"
"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz":
"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":
version "1.2.4"
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-1.tgz#57f61a72f41b86e5a587d9782d32bd32487a551e"
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz#4127dd5cea5fdf7ab745104c73b8ecf5506f5d34"
expo-web-browser@~13.0.3:
version "13.0.3"