Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -10,8 +10,13 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Pin alice's feed
|
# Pin alice's feed
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "viewHeaderDrawerBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "viewHeaderDrawerBtn"
|
||||||
|
- tapOn:
|
||||||
|
id: "profileCardButton"
|
||||||
- swipe:
|
- swipe:
|
||||||
from:
|
from:
|
||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
text: "Feeds ✨"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can go to feeds page using feeds button in tab bar"
|
label: "Can go to feeds page using feeds button in tab bar"
|
||||||
text: "Feeds ✨"
|
text: "Feeds ✨"
|
||||||
@@ -34,26 +37,16 @@ appId: xyz.blueskyweb.app
|
|||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can like posts"
|
label: "Can like posts"
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can repost posts"
|
label: "Can repost posts"
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Repost"
|
- tapOn: "Repost"
|
||||||
- assertVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
text: "1"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "repostBtn"
|
id: "repostBtn"
|
||||||
- tapOn: "Remove repost"
|
- tapOn: "Remove repost"
|
||||||
- assertNotVisible:
|
|
||||||
id: "repostCount"
|
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
label: "Can delete posts"
|
label: "Can delete posts"
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to my profile
|
# Navigate to my profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarProfileBtn"
|
id: "bottomBarProfileBtn"
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "e2eSignInAlice"
|
id: "e2eSignInAlice"
|
||||||
|
|
||||||
# Navigate to another user profile
|
# Navigate to another user profile
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible:
|
||||||
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "bottomBarSearchBtn"
|
id: "bottomBarSearchBtn"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ appId: xyz.blueskyweb.app
|
|||||||
|
|
||||||
|
|
||||||
# Navigate to thread
|
# Navigate to thread
|
||||||
|
- extendedWaitUntil:
|
||||||
|
visible: "Thread root"
|
||||||
- tapOn: "Thread root"
|
- tapOn: "Thread root"
|
||||||
- assertVisible: "Thread reply"
|
- assertVisible: "Thread reply"
|
||||||
|
|
||||||
@@ -33,18 +35,10 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "likeBtn"
|
id: "likeBtn"
|
||||||
childOf:
|
childOf:
|
||||||
id: "postThreadItem-by-carla.test"
|
id: "postThreadItem-by-carla.test"
|
||||||
- assertNotVisible:
|
|
||||||
id: "likeCount"
|
|
||||||
childOf:
|
|
||||||
id: "postThreadItem-by-carla.test"
|
|
||||||
|
|
||||||
# Can repost the root post
|
# Can repost the root post
|
||||||
- tapOn:
|
- tapOn:
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ func run(args []string) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
EnvVars: []string{"LINK_HOST"},
|
EnvVars: []string{"LINK_HOST"},
|
||||||
},
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "ipcc-host",
|
||||||
|
Usage: "scheme, hostname, and port of ipcc service",
|
||||||
|
Value: "https://localhost:8730",
|
||||||
|
EnvVars: []string{"IPCC_HOST"},
|
||||||
|
},
|
||||||
&cli.BoolFlag{
|
&cli.BoolFlag{
|
||||||
Name: "debug",
|
Name: "debug",
|
||||||
Usage: "Enable debug mode",
|
Usage: "Enable debug mode",
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -41,6 +46,7 @@ type Config struct {
|
|||||||
appviewHost string
|
appviewHost string
|
||||||
ogcardHost string
|
ogcardHost string
|
||||||
linkHost string
|
linkHost string
|
||||||
|
ipccHost string
|
||||||
}
|
}
|
||||||
|
|
||||||
func serve(cctx *cli.Context) error {
|
func serve(cctx *cli.Context) error {
|
||||||
@@ -49,6 +55,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost := cctx.String("appview-host")
|
appviewHost := cctx.String("appview-host")
|
||||||
ogcardHost := cctx.String("ogcard-host")
|
ogcardHost := cctx.String("ogcard-host")
|
||||||
linkHost := cctx.String("link-host")
|
linkHost := cctx.String("link-host")
|
||||||
|
ipccHost := cctx.String("ipcc-host")
|
||||||
basicAuthPassword := cctx.String("basic-auth-password")
|
basicAuthPassword := cctx.String("basic-auth-password")
|
||||||
|
|
||||||
// Echo
|
// Echo
|
||||||
@@ -91,6 +98,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
appviewHost: appviewHost,
|
appviewHost: appviewHost,
|
||||||
ogcardHost: ogcardHost,
|
ogcardHost: ogcardHost,
|
||||||
linkHost: linkHost,
|
linkHost: linkHost,
|
||||||
|
ipccHost: ipccHost,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +269,9 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
|
||||||
|
|
||||||
|
// ipcc
|
||||||
|
e.GET("/ipcc", server.WebIpCC)
|
||||||
|
|
||||||
if linkHost != "" {
|
if linkHost != "" {
|
||||||
linkUrl, err := url.Parse(linkHost)
|
linkUrl, err := url.Parse(linkHost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -520,3 +531,61 @@ func (srv *Server) WebProfile(c echo.Context) error {
|
|||||||
data["requestHost"] = req.Host
|
data["requestHost"] = req.Host
|
||||||
return c.Render(http.StatusOK, "profile.html", data)
|
return c.Render(http.StatusOK, "profile.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IPCCRequest struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
}
|
||||||
|
type IPCCResponse struct {
|
||||||
|
CC string `json:"countryCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *Server) WebIpCC(c echo.Context) error {
|
||||||
|
realIP := c.RealIP()
|
||||||
|
addr, err := netip.ParseAddr(realIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("could not parse IP %q %s", realIP, err)
|
||||||
|
return c.JSON(400, IPCCResponse{})
|
||||||
|
}
|
||||||
|
var request []byte
|
||||||
|
if addr.Is4() {
|
||||||
|
ip4 := addr.As4()
|
||||||
|
var dest [8]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip4[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
} else if addr.Is6() {
|
||||||
|
ip6 := addr.As16()
|
||||||
|
var dest [24]byte
|
||||||
|
base64.StdEncoding.Encode(dest[:], ip6[:])
|
||||||
|
request, _ = json.Marshal(IPCCRequest{IP: string(dest[:])})
|
||||||
|
}
|
||||||
|
|
||||||
|
ipccUrlBuilder, err := url.Parse(srv.cfg.ipccHost)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("ipcc misconfigured bad url %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
ipccUrlBuilder.Path = "ipccdata.IpCcService/Lookup"
|
||||||
|
ipccUrl := ipccUrlBuilder.String()
|
||||||
|
cl := http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
postBodyReader := bytes.NewReader(request)
|
||||||
|
response, err := cl.Post(ipccUrl, "application/json", postBodyReader)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc backend error %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
dec := json.NewDecoder(response.Body)
|
||||||
|
var outResponse IPCCResponse
|
||||||
|
err = dec.Decode(&outResponse)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("ipcc bad response %s", err)
|
||||||
|
return c.JSON(500, IPCCResponse{})
|
||||||
|
}
|
||||||
|
return c.JSON(200, outResponse)
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ module.exports = {
|
|||||||
'ja',
|
'ja',
|
||||||
'ko',
|
'ko',
|
||||||
'pt-BR',
|
'pt-BR',
|
||||||
|
'ru',
|
||||||
'tr',
|
'tr',
|
||||||
'uk',
|
'uk',
|
||||||
'zh-CN',
|
'zh-CN',
|
||||||
|
|||||||
+1
-1
@@ -139,7 +139,7 @@
|
|||||||
"expo-system-ui": "~3.0.4",
|
"expo-system-ui": "~3.0.4",
|
||||||
"expo-task-manager": "~11.8.1",
|
"expo-task-manager": "~11.8.1",
|
||||||
"expo-updates": "~0.25.14",
|
"expo-updates": "~0.25.14",
|
||||||
"expo-video": "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",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
|||||||
+51
-47
@@ -58,6 +58,7 @@ import {Shell} from '#/view/shell'
|
|||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
@@ -105,52 +106,50 @@ function InnerApp() {
|
|||||||
}, [_])
|
}, [_])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
<Alf theme={theme}>
|
||||||
<Alf theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<ActiveVideoProvider>
|
||||||
<ActiveVideoProvider>
|
<RootSiblingParent>
|
||||||
<RootSiblingParent>
|
<React.Fragment
|
||||||
<React.Fragment
|
// Resets the entire tree below when it changes:
|
||||||
// Resets the entire tree below when it changes:
|
key={currentAccount?.did}>
|
||||||
key={currentAccount?.did}>
|
<QueryProvider currentDid={currentAccount?.did}>
|
||||||
<QueryProvider currentDid={currentAccount?.did}>
|
<StatsigProvider>
|
||||||
<StatsigProvider>
|
<MessagesProvider>
|
||||||
<MessagesProvider>
|
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
<LabelDefsProvider>
|
||||||
<LabelDefsProvider>
|
<ModerationOptsProvider>
|
||||||
<ModerationOptsProvider>
|
<LoggedOutViewProvider>
|
||||||
<LoggedOutViewProvider>
|
<SelectedFeedProvider>
|
||||||
<SelectedFeedProvider>
|
<HiddenRepliesProvider>
|
||||||
<HiddenRepliesProvider>
|
<UnreadNotifsProvider>
|
||||||
<UnreadNotifsProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<MutedThreadsProvider>
|
||||||
<MutedThreadsProvider>
|
<ProgressGuideProvider>
|
||||||
<ProgressGuideProvider>
|
<GestureHandlerRootView
|
||||||
<GestureHandlerRootView
|
style={s.h100pct}>
|
||||||
style={s.h100pct}>
|
<TestCtrls />
|
||||||
<TestCtrls />
|
<Shell />
|
||||||
<Shell />
|
</GestureHandlerRootView>
|
||||||
</GestureHandlerRootView>
|
</ProgressGuideProvider>
|
||||||
</ProgressGuideProvider>
|
</MutedThreadsProvider>
|
||||||
</MutedThreadsProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</UnreadNotifsProvider>
|
||||||
</UnreadNotifsProvider>
|
</HiddenRepliesProvider>
|
||||||
</HiddenRepliesProvider>
|
</SelectedFeedProvider>
|
||||||
</SelectedFeedProvider>
|
</LoggedOutViewProvider>
|
||||||
</LoggedOutViewProvider>
|
</ModerationOptsProvider>
|
||||||
</ModerationOptsProvider>
|
</LabelDefsProvider>
|
||||||
</LabelDefsProvider>
|
</MessagesProvider>
|
||||||
</MessagesProvider>
|
</StatsigProvider>
|
||||||
</StatsigProvider>
|
</QueryProvider>
|
||||||
</QueryProvider>
|
</React.Fragment>
|
||||||
</React.Fragment>
|
</RootSiblingParent>
|
||||||
</RootSiblingParent>
|
</ActiveVideoProvider>
|
||||||
</ActiveVideoProvider>
|
</Splash>
|
||||||
</Splash>
|
</ThemeProvider>
|
||||||
</ThemeProvider>
|
</Alf>
|
||||||
</Alf>
|
|
||||||
</SafeAreaProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +183,12 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<SafeAreaProvider
|
||||||
|
initialMetrics={initialWindowMetrics}>
|
||||||
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+4
-1
@@ -47,6 +47,7 @@ import {Shell} from '#/view/shell/index'
|
|||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||||
|
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
|
|
||||||
@@ -162,7 +163,9 @@ function App() {
|
|||||||
<LightboxStateProvider>
|
<LightboxStateProvider>
|
||||||
<PortalProvider>
|
<PortalProvider>
|
||||||
<StarterPackProvider>
|
<StarterPackProvider>
|
||||||
<InnerApp />
|
<IntentDialogProvider>
|
||||||
|
<InnerApp />
|
||||||
|
</IntentDialogProvider>
|
||||||
</StarterPackProvider>
|
</StarterPackProvider>
|
||||||
</PortalProvider>
|
</PortalProvider>
|
||||||
</LightboxStateProvider>
|
</LightboxStateProvider>
|
||||||
|
|||||||
+4
-5
@@ -661,16 +661,15 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
|||||||
linking={LINKING}
|
linking={LINKING}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onStateChange={() => {
|
onStateChange={() => {
|
||||||
logEvent('router:navigate:sampled', {
|
const routeName = getCurrentRouteName()
|
||||||
from: prevLoggedRouteName.current,
|
if (routeName === 'Notifications') {
|
||||||
})
|
logEvent('router:navigate:notifications:sampled', {})
|
||||||
prevLoggedRouteName.current = getCurrentRouteName()
|
}
|
||||||
}}
|
}}
|
||||||
onReady={() => {
|
onReady={() => {
|
||||||
attachRouteToLogEvents(getCurrentRouteName)
|
attachRouteToLogEvents(getCurrentRouteName)
|
||||||
logModuleInitTime()
|
logModuleInitTime()
|
||||||
onReady()
|
onReady()
|
||||||
logEvent('router:navigate:sampled', {})
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {useNavigation} from '@react-navigation/native'
|
|||||||
|
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from '#/lib/routes/types'
|
import {NavigationProp} from '#/lib/routes/types'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||||
@@ -177,14 +176,9 @@ function useExperimentalSuggestedUsersQuery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||||
const gate = useGate()
|
|
||||||
const [feedType, feedUri] = feed.split('|')
|
const [feedType, feedUri] = feed.split('|')
|
||||||
if (feedType === 'author') {
|
if (feedType === 'author') {
|
||||||
if (gate('show_follow_suggestions_in_profile')) {
|
return <SuggestedFollowsProfile did={feedUri} />
|
||||||
return <SuggestedFollowsProfile did={feedUri} />
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return <SuggestedFollowsHome />
|
return <SuggestedFollowsHome />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -276,8 +276,8 @@ export function DescriptionPlaceholder() {
|
|||||||
export type FollowButtonProps = {
|
export type FollowButtonProps = {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
moderationOpts: ModerationOpts
|
moderationOpts: ModerationOpts
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
} & Partial<ButtonProps>
|
} & Partial<ButtonProps>
|
||||||
|
|
||||||
export function FollowButton(props: FollowButtonProps) {
|
export function FollowButton(props: FollowButtonProps) {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
|
|||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const bottomBarOffset = useBottomBarOffset(200)
|
const bottomBarOffset = useBottomBarOffset(300)
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
const {data, refetch, isError} = useAllListMembersQuery(listUri)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
ptBR,
|
ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
zhCN,
|
zhCN,
|
||||||
@@ -47,6 +48,7 @@ const locales: Record<AppLanguage, Locale | undefined> = {
|
|||||||
ja,
|
ja,
|
||||||
ko,
|
ko,
|
||||||
['pt-BR']: ptBR,
|
['pt-BR']: ptBR,
|
||||||
|
ru,
|
||||||
tr,
|
tr,
|
||||||
uk,
|
uk,
|
||||||
['zh-CN']: zhCN,
|
['zh-CN']: zhCN,
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ export function useFollowMethods({
|
|||||||
logContext,
|
logContext,
|
||||||
}: {
|
}: {
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext']
|
LogEvents['profile:unfollow:sampled']['logContext']
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const requireAuth = useRequireAuth()
|
const requireAuth = useRequireAuth()
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,24 +2,47 @@ import React from 'react'
|
|||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
|
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
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 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 (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
a.align_center,
|
a.align_center,
|
||||||
a.justify_center,
|
a.justify_center,
|
||||||
|
t.atoms.shadow_lg,
|
||||||
{
|
{
|
||||||
backgroundColor: t.palette.primary_500,
|
width: size + size / 1.5,
|
||||||
width: size + 16,
|
height: size + size / 1.5,
|
||||||
height: size + 16,
|
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
<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>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const STARTER_PACK_MAX_SIZE = 150
|
|||||||
// code and update this number with each release until we can get the
|
// code and update this number with each release until we can get the
|
||||||
// server route done.
|
// server route done.
|
||||||
// -prf
|
// -prf
|
||||||
export const JOINED_THIS_WEEK = 50676 // as of Aug 17, 2024
|
export const JOINED_THIS_WEEK = 3060000 // estimate as of 9/6/24
|
||||||
|
|
||||||
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
const BASE_FEEDBACK_FORM_URL = `${HELP_DESK_URL}/requests/new`
|
||||||
export function FEEDBACK_FORM_URL({
|
export function FEEDBACK_FORM_URL({
|
||||||
@@ -137,6 +137,9 @@ export const GIF_FEATURED = (params: string) =>
|
|||||||
|
|
||||||
export const MAX_LABELERS = 20
|
export const MAX_LABELERS = 20
|
||||||
|
|
||||||
|
export const VIDEO_SERVICE = 'https://video.bsky.app'
|
||||||
|
export const VIDEO_SERVICE_DID = 'did:web:video.bsky.app'
|
||||||
|
|
||||||
export const SUPPORTED_MIME_TYPES = [
|
export const SUPPORTED_MIME_TYPES = [
|
||||||
'video/mp4',
|
'video/mp4',
|
||||||
'video/mpeg',
|
'video/mpeg',
|
||||||
|
|||||||
+15
-12
@@ -1,17 +1,20 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export const useDedupe = () => {
|
export const useDedupe = (timeout = 250) => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useCallback((cb: () => unknown) => {
|
return React.useCallback(
|
||||||
if (canDo.current) {
|
(cb: () => unknown) => {
|
||||||
canDo.current = false
|
if (canDo.current) {
|
||||||
setTimeout(() => {
|
canDo.current = false
|
||||||
canDo.current = true
|
setTimeout(() => {
|
||||||
}, 250)
|
canDo.current = true
|
||||||
cb()
|
}, timeout)
|
||||||
return true
|
cb()
|
||||||
}
|
return true
|
||||||
return false
|
}
|
||||||
}, [])
|
return false
|
||||||
|
},
|
||||||
|
[timeout],
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import {isNative} from 'platform/detection'
|
|||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {useComposerControls} from 'state/shell'
|
import {useComposerControls} from 'state/shell'
|
||||||
import {useCloseAllActiveElements} from 'state/util'
|
import {useCloseAllActiveElements} from 'state/util'
|
||||||
|
import {useIntentDialogs} from '#/components/intents/IntentDialogs'
|
||||||
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
import {Referrer} from '../../../modules/expo-bluesky-swiss-army'
|
||||||
|
|
||||||
type IntentType = 'compose'
|
type IntentType = 'compose' | 'verify-email'
|
||||||
|
|
||||||
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
|
||||||
|
|
||||||
export function useIntentHandler() {
|
export function useIntentHandler() {
|
||||||
const incomingUrl = Linking.useURL()
|
const incomingUrl = Linking.useURL()
|
||||||
const composeIntent = useComposeIntent()
|
const composeIntent = useComposeIntent()
|
||||||
|
const verifyEmailIntent = useVerifyEmailIntent()
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleIncomingURL = (url: string) => {
|
const handleIncomingURL = (url: string) => {
|
||||||
@@ -51,12 +53,22 @@ export function useIntentHandler() {
|
|||||||
text: params.get('text'),
|
text: params.get('text'),
|
||||||
imageUrisStr: params.get('imageUris'),
|
imageUrisStr: params.get('imageUris'),
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case 'verify-email': {
|
||||||
|
const code = params.get('code')
|
||||||
|
if (!code) return
|
||||||
|
verifyEmailIntent(code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (incomingUrl) handleIncomingURL(incomingUrl)
|
if (incomingUrl) handleIncomingURL(incomingUrl)
|
||||||
}, [incomingUrl, composeIntent])
|
}, [incomingUrl, composeIntent, verifyEmailIntent])
|
||||||
}
|
}
|
||||||
|
|
||||||
function useComposeIntent() {
|
function useComposeIntent() {
|
||||||
@@ -103,3 +115,21 @@ function useComposeIntent() {
|
|||||||
[hasSession, closeAllActiveElements, openComposer],
|
[hasSession, closeAllActiveElements, openComposer],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useVerifyEmailIntent() {
|
||||||
|
const closeAllActiveElements = useCloseAllActiveElements()
|
||||||
|
const {verifyEmailDialogControl: control, setVerifyEmailState: setState} =
|
||||||
|
useIntentDialogs()
|
||||||
|
return React.useCallback(
|
||||||
|
(code: string) => {
|
||||||
|
closeAllActiveElements()
|
||||||
|
setState({
|
||||||
|
code,
|
||||||
|
})
|
||||||
|
setTimeout(() => {
|
||||||
|
control.open()
|
||||||
|
}, 1000)
|
||||||
|
},
|
||||||
|
[closeAllActiveElements, control, setState],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import {getVideoMetaData, Video} from 'react-native-compressor'
|
import {getVideoMetaData, Video} from 'react-native-compressor'
|
||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
|
import {extToMime} from '#/state/queries/video/util'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
|
const MIN_SIZE_FOR_COMPRESSION = 1024 * 1024 * 25 // 25mb
|
||||||
|
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
file: ImagePickerAsset,
|
||||||
opts?: {
|
opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
@@ -11,12 +16,21 @@ export async function compressVideo(
|
|||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {onProgress, signal} = opts || {}
|
const {onProgress, signal} = opts || {}
|
||||||
|
|
||||||
|
const isAcceptableFormat = SUPPORTED_MIME_TYPES.includes(
|
||||||
|
file.mimeType as SupportedMimeTypes,
|
||||||
|
)
|
||||||
|
|
||||||
|
const minimumFileSizeForCompress = isAcceptableFormat
|
||||||
|
? MIN_SIZE_FOR_COMPRESSION
|
||||||
|
: 0
|
||||||
|
|
||||||
const compressed = await Video.compress(
|
const compressed = await Video.compress(
|
||||||
file,
|
file.uri,
|
||||||
{
|
{
|
||||||
compressionMethod: 'manual',
|
compressionMethod: 'manual',
|
||||||
bitrate: 3_000_000, // 3mbps
|
bitrate: 3_000_000, // 3mbps
|
||||||
maxSize: 1920,
|
maxSize: 1920,
|
||||||
|
minimumFileSizeForCompress,
|
||||||
getCancellationId: id => {
|
getCancellationId: id => {
|
||||||
if (signal) {
|
if (signal) {
|
||||||
signal.addEventListener('abort', () => {
|
signal.addEventListener('abort', () => {
|
||||||
@@ -30,5 +44,5 @@ export async function compressVideo(
|
|||||||
|
|
||||||
const info = await getVideoMetaData(compressed)
|
const info = await getVideoMetaData(compressed)
|
||||||
|
|
||||||
return {uri: compressed, size: info.size, mimeType: `video/mp4`}
|
return {uri: compressed, size: info.size, mimeType: extToMime(info.extension)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {ImagePickerAsset} from 'expo-image-picker'
|
||||||
|
|
||||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from './types'
|
import {CompressedVideo} from './types'
|
||||||
|
|
||||||
@@ -5,13 +7,13 @@ const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
|
|||||||
|
|
||||||
// doesn't actually compress, but throws if >100MB
|
// doesn't actually compress, but throws if >100MB
|
||||||
export async function compressVideo(
|
export async function compressVideo(
|
||||||
file: string,
|
asset: ImagePickerAsset,
|
||||||
_opts?: {
|
_opts?: {
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
},
|
},
|
||||||
): Promise<CompressedVideo> {
|
): Promise<CompressedVideo> {
|
||||||
const {mimeType, base64} = parseDataUrl(file)
|
const {mimeType, base64} = parseDataUrl(asset.uri)
|
||||||
const blob = base64ToBlob(base64, mimeType)
|
const blob = base64ToBlob(base64, mimeType)
|
||||||
const uri = URL.createObjectURL(blob)
|
const uri = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,10 @@ export class ServerError extends Error {
|
|||||||
this.name = 'ServerError'
|
this.name = 'ServerError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UploadLimitError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'UploadLimitError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type LogEvents = {
|
|||||||
secondsActive: number
|
secondsActive: number
|
||||||
}
|
}
|
||||||
'state:foreground:sampled': {}
|
'state:foreground:sampled': {}
|
||||||
'router:navigate:sampled': {}
|
'router:navigate:notifications:sampled': {}
|
||||||
'deepLink:referrerReceived': {
|
'deepLink:referrerReceived': {
|
||||||
to: string
|
to: string
|
||||||
referrer: string
|
referrer: string
|
||||||
@@ -127,25 +127,25 @@ export type LogEvents = {
|
|||||||
langs: string
|
langs: string
|
||||||
logContext: 'Composer'
|
logContext: 'Composer'
|
||||||
}
|
}
|
||||||
'post:like': {
|
'post:like:sampled': {
|
||||||
doesLikerFollowPoster: boolean | undefined
|
doesLikerFollowPoster: boolean | undefined
|
||||||
doesPosterFollowLiker: boolean | undefined
|
doesPosterFollowLiker: boolean | undefined
|
||||||
likerClout: number | undefined
|
likerClout: number | undefined
|
||||||
postClout: number | undefined
|
postClout: number | undefined
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:repost': {
|
'post:repost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unlike': {
|
'post:unlike:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:unrepost': {
|
'post:unrepost:sampled': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
'post:mute': {}
|
'post:mute': {}
|
||||||
'post:unmute': {}
|
'post:unmute': {}
|
||||||
'profile:follow': {
|
'profile:follow:sampled': {
|
||||||
didBecomeMutual: boolean | undefined
|
didBecomeMutual: boolean | undefined
|
||||||
followeeClout: number | undefined
|
followeeClout: number | undefined
|
||||||
followerClout: number | undefined
|
followerClout: number | undefined
|
||||||
@@ -162,7 +162,7 @@ export type LogEvents = {
|
|||||||
| 'FeedInterstitial'
|
| 'FeedInterstitial'
|
||||||
| 'ProfileHeaderSuggestedFollows'
|
| 'ProfileHeaderSuggestedFollows'
|
||||||
}
|
}
|
||||||
'profile:unfollow': {
|
'profile:unfollow:sampled': {
|
||||||
logContext:
|
logContext:
|
||||||
| 'RecommendedFollowsItem'
|
| 'RecommendedFollowsItem'
|
||||||
| 'PostThreadItem'
|
| 'PostThreadItem'
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
export type Gate =
|
export type Gate =
|
||||||
// Keep this alphabetic please.
|
// Keep this alphabetic please.
|
||||||
| 'debug_show_feedcontext'
|
| 'debug_show_feedcontext'
|
||||||
| 'fixed_bottom_bar'
|
|
||||||
| 'onboarding_minimum_interests'
|
|
||||||
| 'suggested_feeds_interstitial'
|
| 'suggested_feeds_interstitial'
|
||||||
| 'show_follow_suggestions_in_profile'
|
|
||||||
| 'video_debug' // not recommended
|
|
||||||
| 'video_upload' // upload videos
|
| 'video_upload' // upload videos
|
||||||
| 'video_view_on_posts' // see posted videos
|
| 'video_view_on_posts' // see posted videos
|
||||||
|
|||||||
@@ -89,8 +89,9 @@ export function toClout(n: number | null | undefined): number | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DOWNSAMPLE_RATE = 0.95 // 95% likely
|
||||||
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
||||||
'router:navigate:sampled',
|
'router:navigate:notifications:sampled',
|
||||||
'state:background:sampled',
|
'state:background:sampled',
|
||||||
'state:foreground:sampled',
|
'state:foreground:sampled',
|
||||||
'home:feedDisplayed:sampled',
|
'home:feedDisplayed:sampled',
|
||||||
@@ -99,8 +100,14 @@ const DOWNSAMPLED_EVENTS: Set<keyof LogEvents> = new Set([
|
|||||||
'discover:clickthrough:sampled',
|
'discover:clickthrough:sampled',
|
||||||
'discover:engaged:sampled',
|
'discover:engaged:sampled',
|
||||||
'discover:seen:sampled',
|
'discover:seen:sampled',
|
||||||
|
'post:like:sampled',
|
||||||
|
'post:unlike:sampled',
|
||||||
|
'post:repost:sampled',
|
||||||
|
'post:unrepost:sampled',
|
||||||
|
'profile:follow:sampled',
|
||||||
|
'profile:unfollow:sampled',
|
||||||
])
|
])
|
||||||
const isDownsampledSession = Math.random() < 0.9 // 90% likely
|
const isDownsampledSession = Math.random() < DOWNSAMPLE_RATE
|
||||||
|
|
||||||
export function logEvent<E extends keyof LogEvents>(
|
export function logEvent<E extends keyof LogEvents>(
|
||||||
eventName: E & string,
|
eventName: E & string,
|
||||||
@@ -117,12 +124,16 @@ export function logEvent<E extends keyof LogEvents>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDownsampledSession && DOWNSAMPLED_EVENTS.has(eventName)) {
|
const isDownsampledEvent = DOWNSAMPLED_EVENTS.has(eventName)
|
||||||
|
if (isDownsampledSession && isDownsampledEvent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const fullMetadata = {
|
const fullMetadata = {
|
||||||
...rawMetadata,
|
...rawMetadata,
|
||||||
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
} as Record<string, string> // Statsig typings are unnecessarily strict here.
|
||||||
|
if (isDownsampledEvent) {
|
||||||
|
fullMetadata.downsampleRate = DOWNSAMPLE_RATE.toString()
|
||||||
|
}
|
||||||
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)'
|
||||||
if (Statsig.initializeCalled()) {
|
if (Statsig.initializeCalled()) {
|
||||||
Statsig.logEvent(eventName, null, fullMetadata)
|
Statsig.logEvent(eventName, null, fullMetadata)
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage {
|
|||||||
return AppLanguage.ko
|
return AppLanguage.ko
|
||||||
case 'pt-BR':
|
case 'pt-BR':
|
||||||
return AppLanguage.pt_BR
|
return AppLanguage.pt_BR
|
||||||
|
case 'ru':
|
||||||
|
return AppLanguage.ru
|
||||||
case 'tr':
|
case 'tr':
|
||||||
return AppLanguage.tr
|
return AppLanguage.tr
|
||||||
case 'uk':
|
case 'uk':
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {messages as messagesIt} from '#/locale/locales/it/messages'
|
|||||||
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
import {messages as messagesJa} from '#/locale/locales/ja/messages'
|
||||||
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
import {messages as messagesKo} from '#/locale/locales/ko/messages'
|
||||||
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
import {messages as messagesPt_BR} from '#/locale/locales/pt-BR/messages'
|
||||||
|
import {messages as messagesRu} from '#/locale/locales/ru/messages'
|
||||||
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
import {messages as messagesTr} from '#/locale/locales/tr/messages'
|
||||||
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
import {messages as messagesUk} from '#/locale/locales/uk/messages'
|
||||||
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
import {messages as messagesZh_CN} from '#/locale/locales/zh-CN/messages'
|
||||||
@@ -131,6 +132,14 @@ export async function dynamicActivate(locale: AppLanguage) {
|
|||||||
])
|
])
|
||||||
break
|
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: {
|
case AppLanguage.tr: {
|
||||||
i18n.loadAndActivate({locale, messages: messagesTr})
|
i18n.loadAndActivate({locale, messages: messagesTr})
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export async function dynamicActivate(locale: AppLanguage) {
|
|||||||
mod = await import(`./locales/pt-BR/messages`)
|
mod = await import(`./locales/pt-BR/messages`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case AppLanguage.ru: {
|
||||||
|
mod = await import(`./locales/ru/messages`)
|
||||||
|
break
|
||||||
|
}
|
||||||
case AppLanguage.tr: {
|
case AppLanguage.tr: {
|
||||||
mod = await import(`./locales/tr/messages`)
|
mod = await import(`./locales/tr/messages`)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export enum AppLanguage {
|
|||||||
ja = 'ja',
|
ja = 'ja',
|
||||||
ko = 'ko',
|
ko = 'ko',
|
||||||
pt_BR = 'pt-BR',
|
pt_BR = 'pt-BR',
|
||||||
|
ru = 'ru',
|
||||||
tr = 'tr',
|
tr = 'tr',
|
||||||
uk = 'uk',
|
uk = 'uk',
|
||||||
zh_CN = 'zh-CN',
|
zh_CN = 'zh-CN',
|
||||||
@@ -43,6 +44,7 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [
|
|||||||
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
{code2: AppLanguage.ja, name: '日本語 – Japanese'},
|
||||||
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
{code2: AppLanguage.ko, name: '한국어 – Korean'},
|
||||||
{code2: AppLanguage.pt_BR, name: 'Português (BR) – Portuguese (BR)'},
|
{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.tr, name: 'Türkçe – Turkish'},
|
||||||
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
{code2: AppLanguage.uk, name: 'Українська – Ukrainian'},
|
||||||
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
{code2: AppLanguage.zh_CN, name: '简体中文(中国)– Chinese (Simplified)'},
|
||||||
|
|||||||
+759
-507
File diff suppressed because it is too large
Load Diff
+634
-382
File diff suppressed because it is too large
Load Diff
+632
-380
File diff suppressed because it is too large
Load Diff
+633
-381
File diff suppressed because it is too large
Load Diff
+633
-381
File diff suppressed because it is too large
Load Diff
+636
-384
File diff suppressed because it is too large
Load Diff
+633
-381
File diff suppressed because it is too large
Load Diff
+632
-380
File diff suppressed because it is too large
Load Diff
+636
-384
File diff suppressed because it is too large
Load Diff
+1078
-829
File diff suppressed because it is too large
Load Diff
+586
-426
File diff suppressed because it is too large
Load Diff
+589
-429
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
+632
-380
File diff suppressed because it is too large
Load Diff
+632
-380
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ import {
|
|||||||
useSaveMessageDraft,
|
useSaveMessageDraft,
|
||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isIOS} from 'platform/detection'
|
import {isIOS} from 'platform/detection'
|
||||||
|
import {EmojiPickerPosition} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
@@ -41,6 +42,7 @@ export function MessageInput({
|
|||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import {
|
|||||||
} from '#/state/messages/message-drafts'
|
} from '#/state/messages/message-drafts'
|
||||||
import {isSafari, isTouchDevice} from 'lib/browser'
|
import {isSafari, isTouchDevice} from 'lib/browser'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
|
import {
|
||||||
|
Emoji,
|
||||||
|
EmojiPickerPosition,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button} from '#/components/Button'
|
||||||
import {useSharedInputStyles} from '#/components/forms/TextField'
|
import {useSharedInputStyles} from '#/components/forms/TextField'
|
||||||
|
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
|
||||||
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane'
|
||||||
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
import {useExtractEmbedFromFacets} from './MessageInputEmbed'
|
||||||
|
|
||||||
@@ -23,11 +30,13 @@ export function MessageInput({
|
|||||||
hasEmbed,
|
hasEmbed,
|
||||||
setEmbed,
|
setEmbed,
|
||||||
children,
|
children,
|
||||||
|
openEmojiPicker,
|
||||||
}: {
|
}: {
|
||||||
onSendMessage: (message: string) => void
|
onSendMessage: (message: string) => void
|
||||||
hasEmbed: boolean
|
hasEmbed: boolean
|
||||||
setEmbed: (embedUrl: string | undefined) => void
|
setEmbed: (embedUrl: string | undefined) => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
openEmojiPicker?: (pos: EmojiPickerPosition) => void
|
||||||
}) {
|
}) {
|
||||||
const {isTabletOrDesktop} = useWebMediaQueries()
|
const {isTabletOrDesktop} = useWebMediaQueries()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -40,6 +49,7 @@ export function MessageInput({
|
|||||||
const [isFocused, setIsFocused] = React.useState(false)
|
const [isFocused, setIsFocused] = React.useState(false)
|
||||||
const [isHovered, setIsHovered] = React.useState(false)
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
const [textAreaHeight, setTextAreaHeight] = React.useState(38)
|
||||||
|
const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const onSubmit = React.useCallback(() => {
|
const onSubmit = React.useCallback(() => {
|
||||||
if (!hasEmbed && message.trim() === '') {
|
if (!hasEmbed && message.trim() === '') {
|
||||||
@@ -94,6 +104,23 @@ export function MessageInput({
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const onEmojiInserted = React.useCallback(
|
||||||
|
(emoji: Emoji) => {
|
||||||
|
const position = textAreaRef.current?.selectionStart ?? 0
|
||||||
|
setMessage(
|
||||||
|
message =>
|
||||||
|
message.slice(0, position) + emoji.native + message.slice(position),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[setMessage],
|
||||||
|
)
|
||||||
|
React.useEffect(() => {
|
||||||
|
textInputWebEmitter.addListener('emoji-inserted', onEmojiInserted)
|
||||||
|
return () => {
|
||||||
|
textInputWebEmitter.removeListener('emoji-inserted', onEmojiInserted)
|
||||||
|
}
|
||||||
|
}, [onEmojiInserted])
|
||||||
|
|
||||||
useSaveMessageDraft(message)
|
useSaveMessageDraft(message)
|
||||||
useExtractEmbedFromFacets(message, setEmbed)
|
useExtractEmbedFromFacets(message, setEmbed)
|
||||||
|
|
||||||
@@ -106,7 +133,7 @@ export function MessageInput({
|
|||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
{
|
{
|
||||||
paddingRight: a.p_sm.padding - 2,
|
paddingRight: a.p_sm.padding - 2,
|
||||||
paddingLeft: a.p_md.padding - 2,
|
paddingLeft: a.p_sm.padding - 2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderRadius: 23,
|
borderRadius: 23,
|
||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
@@ -118,7 +145,44 @@ export function MessageInput({
|
|||||||
// @ts-expect-error web only
|
// @ts-expect-error web only
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}>
|
onMouseLeave={() => setIsHovered(false)}>
|
||||||
|
<Button
|
||||||
|
onPress={e => {
|
||||||
|
e.currentTarget.measure((_fx, _fy, _width, _height, px, py) => {
|
||||||
|
openEmojiPicker?.({top: py, left: px, right: px, bottom: py})
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
style={[
|
||||||
|
a.rounded_full,
|
||||||
|
a.overflow_hidden,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
marginTop: 5,
|
||||||
|
height: 30,
|
||||||
|
width: 30,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={_(msg`Open emoji picker`)}>
|
||||||
|
{state => (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.absolute,
|
||||||
|
a.inset_0,
|
||||||
|
a.align_center,
|
||||||
|
a.justify_center,
|
||||||
|
{
|
||||||
|
backgroundColor:
|
||||||
|
state.hovered || state.focused || state.pressed
|
||||||
|
? t.atoms.bg.backgroundColor
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
]}>
|
||||||
|
<EmojiSmile size="lg" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
|
ref={textAreaRef}
|
||||||
style={StyleSheet.flatten([
|
style={StyleSheet.flatten([
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
a.px_sm,
|
a.px_sm,
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ import {useAgent} from '#/state/session'
|
|||||||
import {clamp} from 'lib/numbers'
|
import {clamp} from 'lib/numbers'
|
||||||
import {ScrollProvider} from 'lib/ScrollContext'
|
import {ScrollProvider} from 'lib/ScrollContext'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {
|
||||||
|
EmojiPicker,
|
||||||
|
EmojiPickerState,
|
||||||
|
} from '#/view/com/composer/text-input/web/EmojiPicker.web'
|
||||||
import {List} from 'view/com/util/List'
|
import {List} from 'view/com/util/List'
|
||||||
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
import {ChatDisabled} from '#/screens/Messages/Conversation/ChatDisabled'
|
||||||
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
import {MessageInput} from '#/screens/Messages/Conversation/MessageInput'
|
||||||
@@ -97,6 +101,12 @@ export function MessagesList({
|
|||||||
startContentOffset: 0,
|
startContentOffset: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [emojiPickerState, setEmojiPickerState] =
|
||||||
|
React.useState<EmojiPickerState>({
|
||||||
|
isOpen: false,
|
||||||
|
pos: {top: 0, left: 0, right: 0, bottom: 0},
|
||||||
|
})
|
||||||
|
|
||||||
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
|
||||||
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
// are added to the list. For example, if the user is scrolled up to 1iew older messages, we don't want to scroll to
|
||||||
// the bottom.
|
// the bottom.
|
||||||
@@ -422,13 +432,22 @@ export function MessagesList({
|
|||||||
<MessageInput
|
<MessageInput
|
||||||
onSendMessage={onSendMessage}
|
onSendMessage={onSendMessage}
|
||||||
hasEmbed={!!embedUri}
|
hasEmbed={!!embedUri}
|
||||||
setEmbed={setEmbed}>
|
setEmbed={setEmbed}
|
||||||
|
openEmojiPicker={pos => setEmojiPickerState({isOpen: true, pos})}>
|
||||||
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
<MessageInputEmbed embedUri={embedUri} setEmbed={setEmbed} />
|
||||||
</MessageInput>
|
</MessageInput>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</KeyboardStickyView>
|
</KeyboardStickyView>
|
||||||
|
|
||||||
|
{isWeb && (
|
||||||
|
<EmojiPicker
|
||||||
|
pinToTop
|
||||||
|
state={emojiPickerState}
|
||||||
|
close={() => setEmojiPickerState(prev => ({...prev, isOpen: false}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
{newMessagesPill.show && <NewMessagesPill onPress={scrollToEndOnPress} />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,8 @@ import {useQuery} from '@tanstack/react-query'
|
|||||||
|
|
||||||
import {useAnalytics} from '#/lib/analytics/analytics'
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
import {logEvent} from '#/lib/statsig/statsig'
|
import {logEvent} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {capitalize} from '#/lib/strings/capitalize'
|
import {capitalize} from '#/lib/strings/capitalize'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useOnboardingDispatch} from '#/state/shell'
|
import {useOnboardingDispatch} from '#/state/shell'
|
||||||
import {
|
import {
|
||||||
@@ -29,23 +27,16 @@ import * as Toggle from '#/components/forms/Toggle'
|
|||||||
import {IconCircle} from '#/components/IconCircle'
|
import {IconCircle} from '#/components/IconCircle'
|
||||||
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
|
||||||
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
|
||||||
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
|
||||||
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
|
||||||
import {Loader} from '#/components/Loader'
|
import {Loader} from '#/components/Loader'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
const PROMPT_HEIGHT = isWeb ? 42 : 36
|
|
||||||
// matches the padding of the OnboardingControls.Portal
|
|
||||||
const PROMPT_OFFSET = isWeb ? a.pb_2xl.paddingBottom : a.pb_lg.paddingBottom
|
|
||||||
const MIN_INTERESTS = 3
|
|
||||||
|
|
||||||
export function StepInterests() {
|
export function StepInterests() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const gate = useGate()
|
|
||||||
const interestsDisplayNames = useInterestsDisplayNames()
|
const interestsDisplayNames = useInterestsDisplayNames()
|
||||||
|
|
||||||
const {state, dispatch} = React.useContext(Context)
|
const {state, dispatch} = React.useContext(Context)
|
||||||
@@ -143,12 +134,6 @@ export function StepInterests() {
|
|||||||
track('OnboardingV2:StepInterests:Start')
|
track('OnboardingV2:StepInterests:Start')
|
||||||
}, [track])
|
}, [track])
|
||||||
|
|
||||||
const isMinimumInterestsEnabled =
|
|
||||||
gate('onboarding_minimum_interests') && data?.interests.length !== 0
|
|
||||||
const meetsMinimumRequirement = isMinimumInterestsEnabled
|
|
||||||
? interests.length >= MIN_INTERESTS
|
|
||||||
: true
|
|
||||||
|
|
||||||
const title = isError ? (
|
const title = isError ? (
|
||||||
<Trans>Oh no! Something went wrong.</Trans>
|
<Trans>Oh no! Something went wrong.</Trans>
|
||||||
) : (
|
) : (
|
||||||
@@ -186,13 +171,8 @@ export function StepInterests() {
|
|||||||
|
|
||||||
<TitleText>{title}</TitleText>
|
<TitleText>{title}</TitleText>
|
||||||
<DescriptionText>{description}</DescriptionText>
|
<DescriptionText>{description}</DescriptionText>
|
||||||
{isMinimumInterestsEnabled && (
|
|
||||||
<DescriptionText style={[a.pt_sm]}>
|
|
||||||
<Trans>Choose 3 or more:</Trans>
|
|
||||||
</DescriptionText>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={[a.w_full, isMinimumInterestsEnabled ? a.pt_md : a.pt_2xl]}>
|
<View style={[a.w_full, a.pt_2xl]}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader size="xl" />
|
<Loader size="xl" />
|
||||||
) : isError || !data ? (
|
) : isError || !data ? (
|
||||||
@@ -268,7 +248,7 @@ export function StepInterests() {
|
|||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
disabled={saving || !data || !meetsMinimumRequirement}
|
disabled={saving || !data}
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
color="gradient_sky"
|
color="gradient_sky"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -283,53 +263,6 @@ export function StepInterests() {
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!meetsMinimumRequirement && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.align_center,
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
margin: 'auto',
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY:
|
|
||||||
-1 *
|
|
||||||
(PROMPT_OFFSET + PROMPT_HEIGHT + a.pb_lg.paddingBottom),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.flex_row,
|
|
||||||
a.align_center,
|
|
||||||
a.gap_sm,
|
|
||||||
a.rounded_full,
|
|
||||||
a.border,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
t.atoms.border_contrast_medium,
|
|
||||||
{
|
|
||||||
height: PROMPT_HEIGHT,
|
|
||||||
...t.atoms.shadow_sm,
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
},
|
|
||||||
isWeb
|
|
||||||
? [a.py_md, a.px_lg, a.pr_xl]
|
|
||||||
: [a.py_sm, a.px_md, a.pr_lg],
|
|
||||||
]}>
|
|
||||||
<CircleInfo />
|
|
||||||
<Text>
|
|
||||||
<Trans>
|
|
||||||
Choose at least {MIN_INTERESTS - interests.length} more
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</OnboardingControls.Portal>
|
</OnboardingControls.Portal>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isIOS} from '#/platform/detection'
|
import {isIOS} from '#/platform/detection'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
@@ -23,10 +21,9 @@ import {useRequireAuth, useSession} from '#/state/session'
|
|||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {useAnalytics} from 'lib/analytics/analytics'
|
||||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {useProfileShadow} from 'state/cache/profile-shadow'
|
import {useProfileShadow} from 'state/cache/profile-shadow'
|
||||||
import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
|
|
||||||
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
|
||||||
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
|
||||||
@@ -59,8 +56,6 @@ let ProfileHeaderStandard = ({
|
|||||||
}: Props): React.ReactNode => {
|
}: Props): React.ReactNode => {
|
||||||
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
|
||||||
useProfileShadow(profileUnshadowed)
|
useProfileShadow(profileUnshadowed)
|
||||||
const t = useTheme()
|
|
||||||
const gate = useGate()
|
|
||||||
const {currentAccount, hasSession} = useSession()
|
const {currentAccount, hasSession} = useSession()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {openModal} = useModalControls()
|
const {openModal} = useModalControls()
|
||||||
@@ -69,7 +64,6 @@ let ProfileHeaderStandard = ({
|
|||||||
() => moderateProfile(profile, moderationOpts),
|
() => moderateProfile(profile, moderationOpts),
|
||||||
[profile, moderationOpts],
|
[profile, moderationOpts],
|
||||||
)
|
)
|
||||||
const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
|
|
||||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
|
||||||
profile,
|
profile,
|
||||||
'ProfileHeader',
|
'ProfileHeader',
|
||||||
@@ -202,34 +196,7 @@ let ProfileHeaderStandard = ({
|
|||||||
)
|
)
|
||||||
) : !profile.viewer?.blockedBy ? (
|
) : !profile.viewer?.blockedBy ? (
|
||||||
<>
|
<>
|
||||||
{hasSession && (
|
{hasSession && <MessageProfileButton profile={profile} />}
|
||||||
<>
|
|
||||||
<MessageProfileButton profile={profile} />
|
|
||||||
{!gate('show_follow_suggestions_in_profile') && (
|
|
||||||
<Button
|
|
||||||
testID="suggestedFollowsBtn"
|
|
||||||
size="small"
|
|
||||||
color={showSuggestedFollows ? 'primary' : 'secondary'}
|
|
||||||
variant="solid"
|
|
||||||
shape="round"
|
|
||||||
onPress={() =>
|
|
||||||
setShowSuggestedFollows(!showSuggestedFollows)
|
|
||||||
}
|
|
||||||
label={_(msg`Show follows similar to ${profile.handle}`)}
|
|
||||||
style={{width: 36, height: 36}}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon="user-plus"
|
|
||||||
style={
|
|
||||||
showSuggestedFollows
|
|
||||||
? {color: t.palette.white}
|
|
||||||
: t.atoms.text
|
|
||||||
}
|
|
||||||
size={14}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
|
||||||
@@ -294,19 +261,6 @@ let ProfileHeaderStandard = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{showSuggestedFollows && (
|
|
||||||
<ProfileHeaderSuggestedFollows
|
|
||||||
actorDid={profile.did}
|
|
||||||
requestDismiss={() => {
|
|
||||||
if (showSuggestedFollows) {
|
|
||||||
setShowSuggestedFollows(false)
|
|
||||||
} else {
|
|
||||||
track('ProfileHeader:SuggestedFollowsOpened')
|
|
||||||
setShowSuggestedFollows(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
control={unblockPromptControl}
|
control={unblockPromptControl}
|
||||||
title={_(msg`Unblock Account?`)}
|
title={_(msg`Unblock Account?`)}
|
||||||
|
|||||||
+12
-12
@@ -99,8 +99,8 @@ export function useGetPosts() {
|
|||||||
|
|
||||||
export function usePostLikeMutationQueue(
|
export function usePostLikeMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:like']['logContext'] &
|
logContext: LogEvents['post:like:sampled']['logContext'] &
|
||||||
LogEvents['post:unlike']['logContext'],
|
LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
@@ -158,7 +158,7 @@ export function usePostLikeMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostLikeMutation(
|
function usePostLikeMutation(
|
||||||
logContext: LogEvents['post:like']['logContext'],
|
logContext: LogEvents['post:like:sampled']['logContext'],
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -175,7 +175,7 @@ function usePostLikeMutation(
|
|||||||
if (currentAccount) {
|
if (currentAccount) {
|
||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
logEvent('post:like', {
|
logEvent('post:like:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
doesPosterFollowLiker: postAuthor.viewer
|
doesPosterFollowLiker: postAuthor.viewer
|
||||||
? Boolean(postAuthor.viewer.followedBy)
|
? Boolean(postAuthor.viewer.followedBy)
|
||||||
@@ -200,12 +200,12 @@ function usePostLikeMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnlikeMutation(
|
function usePostUnlikeMutation(
|
||||||
logContext: LogEvents['post:unlike']['logContext'],
|
logContext: LogEvents['post:unlike:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
return useMutation<void, Error, {postUri: string; likeUri: string}>({
|
||||||
mutationFn: ({likeUri}) => {
|
mutationFn: ({likeUri}) => {
|
||||||
logEvent('post:unlike', {logContext})
|
logEvent('post:unlike:sampled', {logContext})
|
||||||
return agent.deleteLike(likeUri)
|
return agent.deleteLike(likeUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -216,8 +216,8 @@ function usePostUnlikeMutation(
|
|||||||
|
|
||||||
export function usePostRepostMutationQueue(
|
export function usePostRepostMutationQueue(
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>,
|
post: Shadow<AppBskyFeedDefs.PostView>,
|
||||||
logContext: LogEvents['post:repost']['logContext'] &
|
logContext: LogEvents['post:repost:sampled']['logContext'] &
|
||||||
LogEvents['post:unrepost']['logContext'],
|
LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const postUri = post.uri
|
const postUri = post.uri
|
||||||
@@ -273,7 +273,7 @@ export function usePostRepostMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostRepostMutation(
|
function usePostRepostMutation(
|
||||||
logContext: LogEvents['post:repost']['logContext'],
|
logContext: LogEvents['post:repost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<
|
return useMutation<
|
||||||
@@ -282,7 +282,7 @@ function usePostRepostMutation(
|
|||||||
{uri: string; cid: string} // the post's uri and cid
|
{uri: string; cid: string} // the post's uri and cid
|
||||||
>({
|
>({
|
||||||
mutationFn: post => {
|
mutationFn: post => {
|
||||||
logEvent('post:repost', {logContext})
|
logEvent('post:repost:sampled', {logContext})
|
||||||
return agent.repost(post.uri, post.cid)
|
return agent.repost(post.uri, post.cid)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
@@ -292,12 +292,12 @@ function usePostRepostMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usePostUnrepostMutation(
|
function usePostUnrepostMutation(
|
||||||
logContext: LogEvents['post:unrepost']['logContext'],
|
logContext: LogEvents['post:unrepost:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
return useMutation<void, Error, {postUri: string; repostUri: string}>({
|
||||||
mutationFn: ({repostUri}) => {
|
mutationFn: ({repostUri}) => {
|
||||||
logEvent('post:unrepost', {logContext})
|
logEvent('post:unrepost:sampled', {logContext})
|
||||||
return agent.deleteRepost(repostUri)
|
return agent.deleteRepost(repostUri)
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ export function useProfileUpdateMutation() {
|
|||||||
|
|
||||||
export function useProfileFollowMutationQueue(
|
export function useProfileFollowMutationQueue(
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
logContext: LogEvents['profile:follow']['logContext'] &
|
logContext: LogEvents['profile:follow:sampled']['logContext'] &
|
||||||
LogEvents['profile:unfollow']['logContext'],
|
LogEvents['profile:follow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -291,7 +291,7 @@ export function useProfileFollowMutationQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useProfileFollowMutation(
|
function useProfileFollowMutation(
|
||||||
logContext: LogEvents['profile:follow']['logContext'],
|
logContext: LogEvents['profile:follow:sampled']['logContext'],
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>,
|
||||||
) {
|
) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -306,7 +306,7 @@ function useProfileFollowMutation(
|
|||||||
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
ownProfile = findProfileQueryData(queryClient, currentAccount.did)
|
||||||
}
|
}
|
||||||
captureAction(ProgressGuideAction.Follow)
|
captureAction(ProgressGuideAction.Follow)
|
||||||
logEvent('profile:follow', {
|
logEvent('profile:follow:sampled', {
|
||||||
logContext,
|
logContext,
|
||||||
didBecomeMutual: profile.viewer
|
didBecomeMutual: profile.viewer
|
||||||
? Boolean(profile.viewer.followedBy)
|
? Boolean(profile.viewer.followedBy)
|
||||||
@@ -323,12 +323,12 @@ function useProfileFollowMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useProfileUnfollowMutation(
|
function useProfileUnfollowMutation(
|
||||||
logContext: LogEvents['profile:unfollow']['logContext'],
|
logContext: LogEvents['profile:unfollow:sampled']['logContext'],
|
||||||
) {
|
) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
return useMutation<void, Error, {did: string; followUri: string}>({
|
return useMutation<void, Error, {did: string; followUri: string}>({
|
||||||
mutationFn: async ({followUri}) => {
|
mutationFn: async ({followUri}) => {
|
||||||
logEvent('profile:unfollow', {logContext})
|
logEvent('profile:unfollow:sampled', {logContext})
|
||||||
track('Profile:Unfollow', {username: followUri})
|
track('Profile:Unfollow', {username: followUri})
|
||||||
return await agent.deleteFollow(followUri)
|
return await agent.deleteFollow(followUri)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useCompressVideoMutation({
|
|||||||
mutationKey: ['video', 'compress'],
|
mutationKey: ['video', 'compress'],
|
||||||
mutationFn: cancelable(
|
mutationFn: cancelable(
|
||||||
(asset: ImagePickerAsset) =>
|
(asset: ImagePickerAsset) =>
|
||||||
compressVideo(asset.uri, {
|
compressVideo(asset, {
|
||||||
onProgress: num => onProgress(trunc2dp(num)),
|
onProgress: num => onProgress(trunc2dp(num)),
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import {useMemo} from 'react'
|
import {useMemo} from 'react'
|
||||||
import {AtpAgent} from '@atproto/api'
|
import {AtpAgent} from '@atproto/api'
|
||||||
|
|
||||||
import {SupportedMimeTypes} from '#/lib/constants'
|
import {SupportedMimeTypes, VIDEO_SERVICE} from '#/lib/constants'
|
||||||
|
|
||||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
|
||||||
|
|
||||||
export const createVideoEndpointUrl = (
|
export const createVideoEndpointUrl = (
|
||||||
route: string,
|
route: string,
|
||||||
params?: Record<string, string>,
|
params?: Record<string, string>,
|
||||||
) => {
|
) => {
|
||||||
const url = new URL(`${UPLOAD_ENDPOINT}`)
|
const url = new URL(VIDEO_SERVICE)
|
||||||
url.pathname = route
|
url.pathname = route
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const key in params) {
|
for (const key in params) {
|
||||||
@@ -22,7 +20,7 @@ export const createVideoEndpointUrl = (
|
|||||||
export function useVideoAgent() {
|
export function useVideoAgent() {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return new AtpAgent({
|
return new AtpAgent({
|
||||||
service: UPLOAD_ENDPOINT,
|
service: VIDEO_SERVICE,
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
}
|
}
|
||||||
@@ -41,3 +39,18 @@ export function mimeToExt(mimeType: SupportedMimeTypes | (string & {})) {
|
|||||||
throw new Error(`Unsupported mime type: ${mimeType}`)
|
throw new Error(`Unsupported mime type: ${mimeType}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extToMime(ext: string) {
|
||||||
|
switch (ext) {
|
||||||
|
case 'mp4':
|
||||||
|
return 'video/mp4'
|
||||||
|
case 'webm':
|
||||||
|
return 'video/webm'
|
||||||
|
case 'mpeg':
|
||||||
|
return 'video/mpeg'
|
||||||
|
case 'mov':
|
||||||
|
return 'video/quicktime'
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported file extension: ${ext}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {useCallback} from 'react'
|
||||||
|
import {msg} from '@lingui/macro'
|
||||||
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {VIDEO_SERVICE_DID} from '#/lib/constants'
|
||||||
|
import {UploadLimitError} from '#/lib/media/video/errors'
|
||||||
|
import {getServiceAuthAudFromUrl} from '#/lib/strings/url-helpers'
|
||||||
|
import {useAgent} from '#/state/session'
|
||||||
|
import {useVideoAgent} from './util'
|
||||||
|
|
||||||
|
export function useServiceAuthToken({
|
||||||
|
aud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
}: {
|
||||||
|
aud?: string
|
||||||
|
lxm: string
|
||||||
|
exp?: number
|
||||||
|
}) {
|
||||||
|
const agent = useAgent()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const pdsAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
||||||
|
|
||||||
|
if (!pdsAud) {
|
||||||
|
throw new Error('Agent does not have a PDS URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth({
|
||||||
|
aud: aud ?? pdsAud,
|
||||||
|
lxm,
|
||||||
|
exp,
|
||||||
|
})
|
||||||
|
|
||||||
|
return serviceAuth.token
|
||||||
|
}, [agent, aud, lxm, exp])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVideoUploadLimits() {
|
||||||
|
const agent = useVideoAgent()
|
||||||
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'app.bsky.video.getUploadLimits',
|
||||||
|
aud: VIDEO_SERVICE_DID,
|
||||||
|
})
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
return useCallback(async () => {
|
||||||
|
const {data: limits} = await agent.app.bsky.video
|
||||||
|
.getUploadLimits(
|
||||||
|
{},
|
||||||
|
{headers: {Authorization: `Bearer ${await getToken()}`}},
|
||||||
|
)
|
||||||
|
.catch(err => {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
throw new UploadLimitError(err.message)
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!limits.canUpload) {
|
||||||
|
if (limits.message) {
|
||||||
|
throw new UploadLimitError(limits.message)
|
||||||
|
} else {
|
||||||
|
throw new UploadLimitError(
|
||||||
|
_(
|
||||||
|
msg`You have temporarily reached the limit for video uploads. Please try again later.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [agent, _, getToken])
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -24,38 +24,30 @@ export const useUploadVideoMutation = ({
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const uploadTask = createUploadTask(
|
const uploadTask = createUploadTask(
|
||||||
uri,
|
uri,
|
||||||
video.uri,
|
video.uri,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': video.mimeType,
|
'content-type': video.mimeType,
|
||||||
Authorization: `Bearer ${serviceAuth.token}`,
|
Authorization: `Bearer ${await getToken()}`,
|
||||||
},
|
},
|
||||||
httpMethod: 'POST',
|
httpMethod: 'POST',
|
||||||
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
uploadType: FileSystemUploadType.BINARY_CONTENT,
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {cancelable} from '#/lib/async/cancelable'
|
|||||||
import {ServerError} from '#/lib/media/video/errors'
|
import {ServerError} from '#/lib/media/video/errors'
|
||||||
import {CompressedVideo} from '#/lib/media/video/types'
|
import {CompressedVideo} from '#/lib/media/video/types'
|
||||||
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
import {createVideoEndpointUrl, mimeToExt} from '#/state/queries/video/util'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
import {useServiceAuthToken, useVideoUploadLimits} from './video-upload.shared'
|
||||||
|
|
||||||
export const useUploadVideoMutation = ({
|
export const useUploadVideoMutation = ({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -23,37 +23,30 @@ export const useUploadVideoMutation = ({
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => {
|
}) => {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const agent = useAgent()
|
const getToken = useServiceAuthToken({
|
||||||
|
lxm: 'com.atproto.repo.uploadBlob',
|
||||||
|
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
||||||
|
})
|
||||||
|
const checkLimits = useVideoUploadLimits()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ['video', 'upload'],
|
mutationKey: ['video', 'upload'],
|
||||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||||
|
await checkLimits()
|
||||||
|
|
||||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||||
did: currentAccount!.did,
|
did: currentAccount!.did,
|
||||||
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
name: `${nanoid(12)}.${mimeToExt(video.mimeType)}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
|
|
||||||
|
|
||||||
if (!serviceAuthAud) {
|
|
||||||
throw new Error('Agent does not have a PDS URL')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
|
||||||
{
|
|
||||||
aud: serviceAuthAud,
|
|
||||||
lxm: 'com.atproto.repo.uploadBlob',
|
|
||||||
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
let bytes = video.bytes
|
let bytes = video.bytes
|
||||||
|
|
||||||
if (!bytes) {
|
if (!bytes) {
|
||||||
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = await getToken()
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||||
(resolve, reject) => {
|
(resolve, reject) => {
|
||||||
@@ -76,7 +69,7 @@ export const useUploadVideoMutation = ({
|
|||||||
}
|
}
|
||||||
xhr.open('POST', uri)
|
xhr.open('POST', uri)
|
||||||
xhr.setRequestHeader('Content-Type', video.mimeType)
|
xhr.setRequestHeader('Content-Type', video.mimeType)
|
||||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||||
xhr.send(bytes)
|
xhr.send(bytes)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import {msg} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {AbortError} from '#/lib/async/cancelable'
|
||||||
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
import {SUPPORTED_MIME_TYPES, SupportedMimeTypes} from '#/lib/constants'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {ServerError, VideoTooLargeError} from 'lib/media/video/errors'
|
import {
|
||||||
|
ServerError,
|
||||||
|
UploadLimitError,
|
||||||
|
VideoTooLargeError,
|
||||||
|
} from 'lib/media/video/errors'
|
||||||
import {CompressedVideo} from 'lib/media/video/types'
|
import {CompressedVideo} from 'lib/media/video/types'
|
||||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||||
import {useVideoAgent} from 'state/queries/video/util'
|
import {useVideoAgent} from 'state/queries/video/util'
|
||||||
@@ -39,6 +44,8 @@ export interface State {
|
|||||||
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
|
pendingPublish?: {blobRef: BlobRef; mutableProcessed: boolean}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VideoUploadDispatch = (action: Action) => void
|
||||||
|
|
||||||
function reducer(queryClient: QueryClient) {
|
function reducer(queryClient: QueryClient) {
|
||||||
return (state: State, action: Action): State => {
|
return (state: State, action: Action): State => {
|
||||||
let updatedState = state
|
let updatedState = state
|
||||||
@@ -144,11 +151,42 @@ export function useUploadVideo({
|
|||||||
setJobId(response.jobId)
|
setJobId(response.jobId)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
logger.error('Error uploading video', {safeMessage: e})
|
if (e instanceof AbortError) {
|
||||||
if (e instanceof ServerError) {
|
return
|
||||||
|
} else if (e instanceof ServerError || e instanceof UploadLimitError) {
|
||||||
|
let message
|
||||||
|
// https://github.com/bluesky-social/tango/blob/lumi/lumi/worker/permissions.go#L77
|
||||||
|
switch (e.message) {
|
||||||
|
case 'User is not allowed to upload videos':
|
||||||
|
message = _(msg`You are not allowed to upload videos.`)
|
||||||
|
break
|
||||||
|
case 'Uploading is disabled at the moment':
|
||||||
|
message = _(
|
||||||
|
msg`Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case "Failed to get user's upload stats":
|
||||||
|
message = _(
|
||||||
|
msg`We were unable to determine if you are allowed to upload videos. Please try again.`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload bytes limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many bytes)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'User has exceeded daily upload videos limit':
|
||||||
|
message = _(
|
||||||
|
msg`You've reached your daily limit for video uploads (too many videos)`,
|
||||||
|
)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
message = e.message
|
||||||
|
break
|
||||||
|
}
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: e.message,
|
error: message,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
@@ -176,8 +214,9 @@ export function useUploadVideo({
|
|||||||
onVideoCompressed(video)
|
onVideoCompressed(video)
|
||||||
},
|
},
|
||||||
onError: e => {
|
onError: e => {
|
||||||
logger.error('Error uploading video', {safeMessage: e})
|
if (e instanceof AbortError) {
|
||||||
if (e instanceof VideoTooLargeError) {
|
return
|
||||||
|
} else if (e instanceof VideoTooLargeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SetError',
|
type: 'SetError',
|
||||||
error: _(msg`The selected video is larger than 100MB.`),
|
error: _(msg`The selected video is larger than 100MB.`),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export interface ComposerOpts {
|
|||||||
quote?: ComposerOptsQuote
|
quote?: ComposerOptsQuote
|
||||||
quoteCount?: number
|
quoteCount?: number
|
||||||
mention?: string // handle of user to mention
|
mention?: string // handle of user to mention
|
||||||
openPicker?: (pos: DOMRect | undefined) => void
|
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||||
text?: string
|
text?: string
|
||||||
imageUris?: {uri: string; width: number; height: number}[]
|
imageUris?: {uri: string; width: number; height: number}[]
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-118
@@ -25,6 +25,7 @@ import Animated, {
|
|||||||
FadeOut,
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
LayoutAnimationConfig,
|
LayoutAnimationConfig,
|
||||||
|
LinearTransition,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useDerivedValue,
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -45,18 +46,31 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
|
import * as apilib from '#/lib/api/index'
|
||||||
import {until} from '#/lib/async/until'
|
import {until} from '#/lib/async/until'
|
||||||
|
import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
|
||||||
import {
|
import {
|
||||||
createGIFDescription,
|
createGIFDescription,
|
||||||
parseAltFromGIFDescription,
|
parseAltFromGIFDescription,
|
||||||
} from '#/lib/gif-alt-text'
|
} from '#/lib/gif-alt-text'
|
||||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||||
|
import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||||
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
|
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||||
import {LikelyType} from '#/lib/link-meta/link-meta'
|
import {LikelyType} from '#/lib/link-meta/link-meta'
|
||||||
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
import {logEvent, useGate} from '#/lib/statsig/statsig'
|
||||||
|
import {cleanError} from '#/lib/strings/errors'
|
||||||
|
import {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||||
|
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||||
|
import {colors, s} from '#/lib/styles'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
|
||||||
|
import {useDialogStateControlContext} from '#/state/dialogs'
|
||||||
import {emitPostCreated} from '#/state/events'
|
import {emitPostCreated} from '#/state/events'
|
||||||
import {useModalControls} from '#/state/modals'
|
import {useModalControls} from '#/state/modals'
|
||||||
import {useModals} from '#/state/modals'
|
import {useModals} from '#/state/modals'
|
||||||
|
import {GalleryModel} from '#/state/models/media/gallery'
|
||||||
import {useRequireAltTextEnabled} from '#/state/preferences'
|
import {useRequireAltTextEnabled} from '#/state/preferences'
|
||||||
import {
|
import {
|
||||||
toPostLanguages,
|
toPostLanguages,
|
||||||
@@ -68,25 +82,38 @@ import {useProfileQuery} from '#/state/queries/profile'
|
|||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
|
||||||
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
|
||||||
import {useUploadVideo} from '#/state/queries/video/video'
|
import {
|
||||||
|
State as VideoUploadState,
|
||||||
|
useUploadVideo,
|
||||||
|
VideoUploadDispatch,
|
||||||
|
} from '#/state/queries/video/video'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
import {ComposerOpts} from '#/state/shell/composer'
|
||||||
import * as apilib from 'lib/api/index'
|
import {CharProgress} from '#/view/com/composer/char-progress/CharProgress'
|
||||||
import {MAX_GRAPHEME_LENGTH} from 'lib/constants'
|
import {ComposerReplyTo} from '#/view/com/composer/ComposerReplyTo'
|
||||||
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
|
import {ExternalEmbed} from '#/view/com/composer/ExternalEmbed'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {GifAltText} from '#/view/com/composer/GifAltText'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
||||||
import {cleanError} from 'lib/strings/errors'
|
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
||||||
import {insertMentionAt} from 'lib/strings/mention-manip'
|
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||||
import {shortenLinks} from 'lib/strings/rich-text-manip'
|
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
||||||
import {colors, s} from 'lib/styles'
|
import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
|
||||||
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
// TODO: Prevent naming components that coincide with RN primitives
|
||||||
import {State as VideoUploadState} from 'state/queries/video/video'
|
// due to linting false positives
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {TextInput, TextInputRef} from '#/view/com/composer/text-input/TextInput'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
|
||||||
|
import {useExternalLinkFetch} from '#/view/com/composer/useExternalLinkFetch'
|
||||||
|
import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
|
||||||
|
import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
|
||||||
|
import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
|
||||||
|
import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
|
||||||
|
import {QuoteEmbed, QuoteX} from '#/view/com/util/post-embeds/QuoteEmbed'
|
||||||
|
import {Text} from '#/view/com/util/text/Text'
|
||||||
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||||
import {atoms as a, native, useTheme} from '#/alf'
|
import {atoms as a, native, useTheme} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
|
||||||
@@ -94,29 +121,6 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
|
|||||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||||
import * as Prompt from '#/components/Prompt'
|
import * as Prompt from '#/components/Prompt'
|
||||||
import {Text as NewText} from '#/components/Typography'
|
import {Text as NewText} from '#/components/Typography'
|
||||||
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
|
|
||||||
import {Text} from '../util/text/Text'
|
|
||||||
import * as Toast from '../util/Toast'
|
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
|
||||||
import {CharProgress} from './char-progress/CharProgress'
|
|
||||||
import {ExternalEmbed} from './ExternalEmbed'
|
|
||||||
import {GifAltText} from './GifAltText'
|
|
||||||
import {LabelsBtn} from './labels/LabelsBtn'
|
|
||||||
import {Gallery} from './photos/Gallery'
|
|
||||||
import {OpenCameraBtn} from './photos/OpenCameraBtn'
|
|
||||||
import {SelectGifBtn} from './photos/SelectGifBtn'
|
|
||||||
import {SelectPhotoBtn} from './photos/SelectPhotoBtn'
|
|
||||||
import {SelectLangBtn} from './select-language/SelectLangBtn'
|
|
||||||
import {SuggestedLanguage} from './select-language/SuggestedLanguage'
|
|
||||||
// TODO: Prevent naming components that coincide with RN primitives
|
|
||||||
// due to linting false positives
|
|
||||||
import {TextInput, TextInputRef} from './text-input/TextInput'
|
|
||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
|
||||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
|
||||||
import {SubtitleDialogBtn} from './videos/SubtitleDialog'
|
|
||||||
import {VideoPreview} from './videos/VideoPreview'
|
|
||||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
@@ -129,7 +133,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
quote: initQuote,
|
quote: initQuote,
|
||||||
quoteCount,
|
quoteCount,
|
||||||
mention: initMention,
|
mention: initMention,
|
||||||
openPicker,
|
openEmojiPicker,
|
||||||
text: initText,
|
text: initText,
|
||||||
imageUris: initImageUris,
|
imageUris: initImageUris,
|
||||||
cancelRef,
|
cancelRef,
|
||||||
@@ -196,6 +200,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
const hasVideo = Boolean(videoUploadState.asset || videoUploadState.video)
|
||||||
|
|
||||||
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
@@ -516,8 +521,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openEmojiPicker?.(textInput.current?.getCursorPosition())
|
||||||
}, [openPicker])
|
}, [openEmojiPicker])
|
||||||
|
|
||||||
const focusTextInput = useCallback(() => {
|
const focusTextInput = useCallback(() => {
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
@@ -578,7 +583,9 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
keyboardVerticalOffset={keyboardVerticalOffset}
|
keyboardVerticalOffset={keyboardVerticalOffset}
|
||||||
style={a.flex_1}>
|
style={a.flex_1}>
|
||||||
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
<View style={[a.flex_1, viewStyles]} aria-modal accessibilityViewIsModal>
|
||||||
<Animated.View style={topBarAnimatedStyle}>
|
<Animated.View
|
||||||
|
style={topBarAnimatedStyle}
|
||||||
|
layout={native(LinearTransition)}>
|
||||||
<View style={styles.topbarInner}>
|
<View style={styles.topbarInner}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Cancel`)}
|
label={_(msg`Cancel`)}
|
||||||
@@ -662,48 +669,15 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{(error !== '' || videoUploadState.error) && (
|
<ErrorBanner
|
||||||
<View style={[a.px_lg, a.pb_sm]}>
|
error={error}
|
||||||
<View
|
videoUploadState={videoUploadState}
|
||||||
style={[
|
clearError={() => setError('')}
|
||||||
a.px_md,
|
videoUploadDispatch={videoUploadDispatch}
|
||||||
a.py_sm,
|
/>
|
||||||
a.rounded_sm,
|
|
||||||
a.flex_row,
|
|
||||||
a.gap_sm,
|
|
||||||
t.atoms.bg_contrast_25,
|
|
||||||
{
|
|
||||||
paddingRight: 48,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<CircleInfo fill={t.palette.negative_400} />
|
|
||||||
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
|
||||||
{error || videoUploadState.error}
|
|
||||||
</NewText>
|
|
||||||
<Button
|
|
||||||
label={_(msg`Dismiss error`)}
|
|
||||||
size="tiny"
|
|
||||||
color="secondary"
|
|
||||||
variant="ghost"
|
|
||||||
shape="round"
|
|
||||||
style={[
|
|
||||||
a.absolute,
|
|
||||||
{
|
|
||||||
top: a.py_sm.paddingTop,
|
|
||||||
right: a.px_md.paddingRight,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
if (error) setError('')
|
|
||||||
else videoUploadDispatch({type: 'Reset'})
|
|
||||||
}}>
|
|
||||||
<ButtonIcon icon={X} />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
|
layout={native(LinearTransition)}
|
||||||
onScroll={scrollHandler}
|
onScroll={scrollHandler}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
keyboardShouldPersistTaps="always"
|
keyboardShouldPersistTaps="always"
|
||||||
@@ -757,8 +731,37 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<LayoutAnimationConfig skipExiting>
|
||||||
<View style={[a.mt_md]}>
|
{hasVideo && (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.w_full, a.mt_lg]}
|
||||||
|
entering={native(ZoomIn)}
|
||||||
|
exiting={native(ZoomOut)}>
|
||||||
|
{videoUploadState.asset &&
|
||||||
|
(videoUploadState.status === 'compressing' ? (
|
||||||
|
<VideoTranscodeProgress
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
progress={videoUploadState.progress}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : videoUploadState.video ? (
|
||||||
|
<VideoPreview
|
||||||
|
asset={videoUploadState.asset}
|
||||||
|
video={videoUploadState.video}
|
||||||
|
setDimensions={updateVideoDimensions}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
|
) : null)}
|
||||||
|
<SubtitleDialogBtn
|
||||||
|
defaultAltText={videoAltText}
|
||||||
|
saveAltText={setVideoAltText}
|
||||||
|
captions={captions}
|
||||||
|
setCaptions={setCaptions}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</LayoutAnimationConfig>
|
||||||
|
<View style={!hasVideo ? [a.mt_md] : []}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={{pointerEvents: 'none'}}>
|
||||||
@@ -769,36 +772,6 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : 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
|
|
||||||
altText={videoAltText}
|
|
||||||
setAltText={setVideoAltText}
|
|
||||||
captions={captions}
|
|
||||||
setCaptions={setCaptions}
|
|
||||||
/>
|
|
||||||
</Animated.View>
|
|
||||||
)}
|
|
||||||
</LayoutAnimationConfig>
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
@@ -1083,6 +1056,80 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function ErrorBanner({
|
||||||
|
error: standardError,
|
||||||
|
videoUploadState,
|
||||||
|
clearError,
|
||||||
|
videoUploadDispatch,
|
||||||
|
}: {
|
||||||
|
error: string
|
||||||
|
videoUploadState: VideoUploadState
|
||||||
|
clearError: () => void
|
||||||
|
videoUploadDispatch: VideoUploadDispatch
|
||||||
|
}) {
|
||||||
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
|
const videoError =
|
||||||
|
videoUploadState.status !== 'idle' ? videoUploadState.error : undefined
|
||||||
|
const error = standardError || videoError
|
||||||
|
|
||||||
|
const onClearError = () => {
|
||||||
|
if (standardError) {
|
||||||
|
clearError()
|
||||||
|
} else {
|
||||||
|
videoUploadDispatch({type: 'Reset'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!error) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[a.px_lg, a.pb_sm]}
|
||||||
|
entering={FadeIn}
|
||||||
|
exiting={FadeOut}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.px_md,
|
||||||
|
a.py_sm,
|
||||||
|
a.gap_xs,
|
||||||
|
a.rounded_sm,
|
||||||
|
t.atoms.bg_contrast_25,
|
||||||
|
]}>
|
||||||
|
<View style={[a.relative, a.flex_row, a.gap_sm, {paddingRight: 48}]}>
|
||||||
|
<CircleInfo fill={t.palette.negative_400} />
|
||||||
|
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
|
||||||
|
{error}
|
||||||
|
</NewText>
|
||||||
|
<Button
|
||||||
|
label={_(msg`Dismiss error`)}
|
||||||
|
size="tiny"
|
||||||
|
color="secondary"
|
||||||
|
variant="ghost"
|
||||||
|
shape="round"
|
||||||
|
style={[a.absolute, {top: 0, right: 0}]}
|
||||||
|
onPress={onClearError}>
|
||||||
|
<ButtonIcon icon={X} />
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{videoError && videoUploadState.jobStatus?.jobId && (
|
||||||
|
<NewText
|
||||||
|
style={[
|
||||||
|
{paddingLeft: 28},
|
||||||
|
a.text_xs,
|
||||||
|
a.font_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_low,
|
||||||
|
]}>
|
||||||
|
<Trans>Job ID: {videoUploadState.jobStatus.jobId}</Trans>
|
||||||
|
</NewText>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ToolbarWrapper({
|
function ToolbarWrapper({
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
@@ -1107,10 +1154,12 @@ function VideoUploadToolbar({state}: {state: VideoUploadState}) {
|
|||||||
const progress = state.jobStatus?.progress
|
const progress = state.jobStatus?.progress
|
||||||
? state.jobStatus.progress / 100
|
? state.jobStatus.progress / 100
|
||||||
: state.progress
|
: 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(() => {
|
const rotate = useDerivedValue(() => {
|
||||||
if (progress === 0 || progress >= 0.99) {
|
if (shouldRotate) {
|
||||||
return withRepeat(
|
return withRepeat(
|
||||||
withTiming(360, {
|
withTiming(360, {
|
||||||
duration: 2500,
|
duration: 2500,
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {Placeholder} from '@tiptap/extension-placeholder'
|
|||||||
import {Text as TiptapText} from '@tiptap/extension-text'
|
import {Text as TiptapText} from '@tiptap/extension-text'
|
||||||
import {generateJSON} from '@tiptap/html'
|
import {generateJSON} from '@tiptap/html'
|
||||||
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
|
||||||
import EventEmitter from 'eventemitter3'
|
|
||||||
|
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
|
||||||
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
|
||||||
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
import {blobToDataUri, isUriImage} from 'lib/media/util'
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {
|
import {
|
||||||
LinkFacetMatch,
|
LinkFacetMatch,
|
||||||
suggestLinkCardUri,
|
suggestLinkCardUri,
|
||||||
@@ -46,8 +46,6 @@ interface TextInputProps {
|
|||||||
onError: (err: string) => void
|
onError: (err: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const textInputWebEmitter = new EventEmitter()
|
|
||||||
|
|
||||||
export const TextInput = React.forwardRef(function TextInputImpl(
|
export const TextInput = React.forwardRef(function TextInputImpl(
|
||||||
{
|
{
|
||||||
richtext,
|
richtext,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
|
export const textInputWebEmitter = new EventEmitter()
|
||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import Picker from '@emoji-mart/react'
|
import Picker from '@emoji-mart/react'
|
||||||
|
|
||||||
|
import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {textInputWebEmitter} from '../TextInput.web'
|
|
||||||
|
|
||||||
const HEIGHT_OFFSET = 40
|
const HEIGHT_OFFSET = 40
|
||||||
const WIDTH_OFFSET = 100
|
const WIDTH_OFFSET = 100
|
||||||
@@ -26,22 +26,41 @@ export type Emoji = {
|
|||||||
unified: string
|
unified: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EmojiPickerPosition {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
right: number
|
||||||
|
bottom: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface EmojiPickerState {
|
export interface EmojiPickerState {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
pos: {top: number; left: number; right: number; bottom: number}
|
pos: EmojiPickerPosition
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
state: EmojiPickerState
|
state: EmojiPickerState
|
||||||
close: () => void
|
close: () => void
|
||||||
|
/**
|
||||||
|
* If `true`, overrides position and ensures picker is pinned to the top of
|
||||||
|
* the target element.
|
||||||
|
*/
|
||||||
|
pinToTop?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmojiPicker({state, close}: IProps) {
|
export function EmojiPicker({state, close, pinToTop}: IProps) {
|
||||||
const {height, width} = useWindowDimensions()
|
const {height, width} = useWindowDimensions()
|
||||||
|
|
||||||
const isShiftDown = React.useRef(false)
|
const isShiftDown = React.useRef(false)
|
||||||
|
|
||||||
const position = React.useMemo(() => {
|
const position = React.useMemo(() => {
|
||||||
|
if (pinToTop) {
|
||||||
|
return {
|
||||||
|
top: state.pos.top - PICKER_HEIGHT + HEIGHT_OFFSET - 10,
|
||||||
|
left: state.pos.left,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
const fitsBelow = state.pos.top + PICKER_HEIGHT < height
|
||||||
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
const fitsAbove = PICKER_HEIGHT < state.pos.top
|
||||||
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
const placeOnLeft = PICKER_WIDTH < state.pos.left
|
||||||
@@ -64,7 +83,7 @@ export function EmojiPicker({state, close}: IProps) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [state.pos, height, width])
|
}, [state.pos, height, width, pinToTop])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!state.isOpen) return
|
if (!state.isOpen) return
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useCallback} from 'react'
|
import React, {useCallback, useState} from 'react'
|
||||||
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import RNPickerSelect from 'react-native-picker-select'
|
import RNPickerSelect from 'react-native-picker-select'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
@@ -7,7 +7,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
import {MAX_ALT_TEXT} from '#/lib/constants'
|
import {MAX_ALT_TEXT} from '#/lib/constants'
|
||||||
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
|
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
|
||||||
import {LANGUAGES} from '#/locale/languages'
|
import {LANGUAGES} from '#/locale/languages'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isAndroid, isWeb} from '#/platform/detection'
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {atoms as a, useTheme, web} from '#/alf'
|
import {atoms as a, useTheme, web} from '#/alf'
|
||||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||||
@@ -21,9 +21,9 @@ import {Text} from '#/components/Typography'
|
|||||||
import {SubtitleFilePicker} from './SubtitleFilePicker'
|
import {SubtitleFilePicker} from './SubtitleFilePicker'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
altText: string
|
defaultAltText: string
|
||||||
captions: {lang: string; file: File}[]
|
captions: {lang: string; file: File}[]
|
||||||
setAltText: (altText: string) => void
|
saveAltText: (altText: string) => void
|
||||||
setCaptions: React.Dispatch<
|
setCaptions: React.Dispatch<
|
||||||
React.SetStateAction<{lang: string; file: File}[]>
|
React.SetStateAction<{lang: string; file: File}[]>
|
||||||
>
|
>
|
||||||
@@ -54,7 +54,9 @@ export function SubtitleDialogBtn(props: Props) {
|
|||||||
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
|
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
|
||||||
</ButtonText>
|
</ButtonText>
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog.Outer control={control}>
|
<Dialog.Outer
|
||||||
|
control={control}
|
||||||
|
nativeOptions={isAndroid ? {sheet: {snapPoints: ['60%']}} : {}}>
|
||||||
<Dialog.Handle />
|
<Dialog.Handle />
|
||||||
<SubtitleDialogInner {...props} />
|
<SubtitleDialogInner {...props} />
|
||||||
</Dialog.Outer>
|
</Dialog.Outer>
|
||||||
@@ -63,8 +65,8 @@ export function SubtitleDialogBtn(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SubtitleDialogInner({
|
function SubtitleDialogInner({
|
||||||
altText,
|
defaultAltText,
|
||||||
setAltText,
|
saveAltText,
|
||||||
captions,
|
captions,
|
||||||
setCaptions,
|
setCaptions,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -74,6 +76,8 @@ function SubtitleDialogInner({
|
|||||||
const enforceLen = useEnforceMaxGraphemeCount()
|
const enforceLen = useEnforceMaxGraphemeCount()
|
||||||
const {primaryLanguage} = useLanguagePrefs()
|
const {primaryLanguage} = useLanguagePrefs()
|
||||||
|
|
||||||
|
const [altText, setAltText] = useState(defaultAltText)
|
||||||
|
|
||||||
const handleSelectFile = useCallback(
|
const handleSelectFile = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
setCaptions(subs => [
|
setCaptions(subs => [
|
||||||
@@ -105,6 +109,7 @@ function SubtitleDialogInner({
|
|||||||
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
|
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
|
||||||
maxLength={MAX_ALT_TEXT * 10}
|
maxLength={MAX_ALT_TEXT * 10}
|
||||||
multiline
|
multiline
|
||||||
|
style={{maxHeight: 300}}
|
||||||
numberOfLines={3}
|
numberOfLines={3}
|
||||||
onKeyPress={({nativeEvent}) => {
|
onKeyPress={({nativeEvent}) => {
|
||||||
if (nativeEvent.key === 'Escape') {
|
if (nativeEvent.key === 'Escape') {
|
||||||
@@ -147,22 +152,26 @@ function SubtitleDialogInner({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
{subtitleMissingLanguage && (
|
||||||
|
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans>
|
||||||
|
Ensure you have selected a language for each subtitle file.
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{subtitleMissingLanguage && (
|
|
||||||
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
|
|
||||||
Ensure you have selected a language for each subtitle file.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View style={web([a.flex_row, a.justify_end])}>
|
<View style={web([a.flex_row, a.justify_end])}>
|
||||||
<Button
|
<Button
|
||||||
label={_(msg`Done`)}
|
label={_(msg`Done`)}
|
||||||
size={isWeb ? 'small' : 'medium'}
|
size={isWeb ? 'small' : 'medium'}
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="solid"
|
variant="solid"
|
||||||
onPress={() => control.close()}
|
onPress={() => {
|
||||||
|
saveAltText(altText)
|
||||||
|
control.close()
|
||||||
|
}}
|
||||||
style={a.mt_lg}>
|
style={a.mt_lg}>
|
||||||
<ButtonText>
|
<ButtonText>
|
||||||
<Trans>Done</Trans>
|
<Trans>Done</Trans>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function VideoPreview({
|
|||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
{autoplayDisabled && (
|
{autoplayDisabled && (
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
<PlayButtonIcon size={48} />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function VideoPreview({
|
|||||||
ref.current.addEventListener(
|
ref.current.addEventListener(
|
||||||
'error',
|
'error',
|
||||||
() => {
|
() => {
|
||||||
Toast.show(_(msg`Could not process your video`))
|
Toast.show(_(msg`Could not process your video`), 'xmark')
|
||||||
clear()
|
clear()
|
||||||
},
|
},
|
||||||
{signal},
|
{signal},
|
||||||
@@ -83,7 +83,7 @@ export function VideoPreview({
|
|||||||
/>
|
/>
|
||||||
{autoplayDisabled && (
|
{autoplayDisabled && (
|
||||||
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
<View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||||
<PlayButtonIcon size={48} />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ export function Component({
|
|||||||
accessibilityLabel={_(msg`Confirmation code`)}
|
accessibilityLabel={_(msg`Confirmation code`)}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoComplete="off"
|
autoComplete="one-time-code"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|||||||
@@ -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 {StyleSheet, View} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyActorDefs,
|
||||||
@@ -21,7 +21,6 @@ import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
|
|||||||
import {MAX_POST_LINES} from '#/lib/constants'
|
import {MAX_POST_LINES} from '#/lib/constants'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {makeProfileLink} from '#/lib/routes/links'
|
import {makeProfileLink} from '#/lib/routes/links'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {countLines} from '#/lib/strings/helpers'
|
import {countLines} from '#/lib/strings/helpers'
|
||||||
@@ -47,7 +46,6 @@ import {AppModerationCause} from '#/components/Pills'
|
|||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||||
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
|
||||||
import {AviFollowButton} from './AviFollowButton'
|
import {AviFollowButton} from './AviFollowButton'
|
||||||
|
|
||||||
interface FeedItemProps {
|
interface FeedItemProps {
|
||||||
@@ -386,7 +384,6 @@ let FeedItemInner = ({
|
|||||||
post={post}
|
post={post}
|
||||||
threadgateRecord={threadgateRecord}
|
threadgateRecord={threadgateRecord}
|
||||||
/>
|
/>
|
||||||
<VideoDebug />
|
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
post={post}
|
post={post}
|
||||||
record={record}
|
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({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
paddingLeft: 10,
|
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,6 +7,7 @@ import {usePalette} from '#/lib/hooks/usePalette'
|
|||||||
import {useScrollHandlers} from '#/lib/ScrollContext'
|
import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||||
import {useDedupe} from 'lib/hooks/useDedupe'
|
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||||
import {addStyle} from 'lib/styles'
|
import {addStyle} from 'lib/styles'
|
||||||
|
import {isIOS} from 'platform/detection'
|
||||||
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||||
import {FlatList_INTERNAL} from './Views'
|
import {FlatList_INTERNAL} from './Views'
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ function ListImpl<ItemT>(
|
|||||||
) {
|
) {
|
||||||
const isScrolledDown = useSharedValue(false)
|
const isScrolledDown = useSharedValue(false)
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const dedupe = useDedupe()
|
const dedupe = useDedupe(400)
|
||||||
|
|
||||||
function handleScrolledDownChange(didScrollDown: boolean) {
|
function handleScrolledDownChange(didScrollDown: boolean) {
|
||||||
onScrolledDownChange?.(didScrollDown)
|
onScrolledDownChange?.(didScrollDown)
|
||||||
@@ -68,6 +69,7 @@ function ListImpl<ItemT>(
|
|||||||
onBeginDragFromContext?.(e, ctx)
|
onBeginDragFromContext?.(e, ctx)
|
||||||
},
|
},
|
||||||
onEndDrag(e, ctx) {
|
onEndDrag(e, ctx) {
|
||||||
|
runOnJS(updateActiveViewAsync)()
|
||||||
onEndDragFromContext?.(e, ctx)
|
onEndDragFromContext?.(e, ctx)
|
||||||
},
|
},
|
||||||
onScroll(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
|
// Note: adding onMomentumBegin here makes simulator scroll
|
||||||
// lag on Android. So either don't add it, or figure out why.
|
// lag on Android. So either don't add it, or figure out why.
|
||||||
onMomentumEnd(e, ctx) {
|
onMomentumEnd(e, ctx) {
|
||||||
|
runOnJS(updateActiveViewAsync)()
|
||||||
onMomentumEndFromContext?.(e, ctx)
|
onMomentumEndFromContext?.(e, ctx)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import EventEmitter from 'eventemitter3'
|
import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
import {ScrollProvider} from '#/lib/ScrollContext'
|
import {ScrollProvider} from '#/lib/ScrollContext'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {useMinimalShellMode} from '#/state/shell'
|
import {useMinimalShellMode} from '#/state/shell'
|
||||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||||
import {isNative, isWeb} from 'platform/detection'
|
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}) {
|
export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||||
const {headerHeight} = useShellLayout()
|
const {headerHeight} = useShellLayout()
|
||||||
const {headerMode, footerMode} = useMinimalShellMode()
|
const {headerMode} = useMinimalShellMode()
|
||||||
const startDragOffset = useSharedValue<number | null>(null)
|
const startDragOffset = useSharedValue<number | null>(null)
|
||||||
const startMode = useSharedValue<number | null>(null)
|
const startMode = useSharedValue<number | null>(null)
|
||||||
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
const didJustRestoreScroll = useSharedValue<boolean>(false)
|
||||||
const gate = useGate()
|
|
||||||
const isFixedBottomBar = gate('fixed_bottom_bar')
|
|
||||||
|
|
||||||
const setMode = React.useCallback(
|
const setMode = React.useCallback(
|
||||||
(v: boolean) => {
|
(v: boolean) => {
|
||||||
@@ -37,14 +34,8 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
headerMode.value = withSpring(v ? 1 : 0, {
|
headerMode.value = withSpring(v ? 1 : 0, {
|
||||||
overshootClamping: true,
|
overshootClamping: true,
|
||||||
})
|
})
|
||||||
if (!isFixedBottomBar) {
|
|
||||||
cancelAnimation(footerMode)
|
|
||||||
footerMode.value = withSpring(v ? 1 : 0, {
|
|
||||||
overshootClamping: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[headerMode, footerMode, isFixedBottomBar],
|
[headerMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -147,10 +138,6 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
// Cancel any any existing animation
|
// Cancel any any existing animation
|
||||||
cancelAnimation(headerMode)
|
cancelAnimation(headerMode)
|
||||||
headerMode.value = newValue
|
headerMode.value = newValue
|
||||||
if (!isFixedBottomBar) {
|
|
||||||
cancelAnimation(footerMode)
|
|
||||||
footerMode.value = newValue
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (didJustRestoreScroll.value) {
|
if (didJustRestoreScroll.value) {
|
||||||
@@ -173,12 +160,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
|||||||
[
|
[
|
||||||
headerHeight,
|
headerHeight,
|
||||||
headerMode,
|
headerMode,
|
||||||
footerMode,
|
|
||||||
setMode,
|
setMode,
|
||||||
startDragOffset,
|
startDragOffset,
|
||||||
startMode,
|
startMode,
|
||||||
didJustRestoreScroll,
|
didJustRestoreScroll,
|
||||||
isFixedBottomBar,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ function Toast({
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
t.atoms.bg,
|
t.atoms.bg,
|
||||||
a.shadow_lg,
|
a.shadow_lg,
|
||||||
a.rounded_sm,
|
|
||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
a.rounded_sm,
|
a.rounded_sm,
|
||||||
a.px_md,
|
a.px_md,
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ const styles = StyleSheet.create({
|
|||||||
// @ts-ignore web-only
|
// @ts-ignore web-only
|
||||||
position: isWeb ? 'fixed' : 'absolute',
|
position: isWeb ? 'fixed' : 'absolute',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
inner: {
|
inner: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import * as imageSizes from '#/lib/media/image-sizes'
|
import * as imageSizes from '#/lib/media/image-sizes'
|
||||||
import {Dimensions} from '#/lib/media/types'
|
import {Dimensions} from '#/lib/media/types'
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||||
import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
|
import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
|
||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
@@ -65,14 +66,18 @@ export function ConstrainedImage({
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {gtMobile} = useBreakpoints()
|
||||||
/**
|
/**
|
||||||
* Computed as a % value to apply as `paddingTop`
|
* Computed as a % value to apply as `paddingTop`
|
||||||
*/
|
*/
|
||||||
const outerAspectRatio = React.useMemo<DimensionValue>(() => {
|
const outerAspectRatio = React.useMemo<DimensionValue>(() => {
|
||||||
// capped to square or shorter
|
// capped to square or shorter
|
||||||
const ratio = Math.min(1 / aspectRatio, 1)
|
const ratio =
|
||||||
|
isNative || !gtMobile
|
||||||
|
? Math.min(1 / aspectRatio, 1.5)
|
||||||
|
: Math.min(1 / aspectRatio, 1)
|
||||||
return `${ratio * 100}%`
|
return `${ratio * 100}%`
|
||||||
}, [aspectRatio])
|
}, [aspectRatio, gtMobile])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.w_full]}>
|
<View style={[a.w_full]}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
||||||
|
|
||||||
import {isNative} from '#/platform/detection'
|
import {isAndroid, isNative} from '#/platform/detection'
|
||||||
|
|
||||||
const Context = React.createContext<{
|
const Context = React.createContext<{
|
||||||
activeSource: string
|
activeSource: string
|
||||||
activeViewId: string | undefined
|
activeViewId: string | undefined
|
||||||
setActiveSource: (src: string, viewId: string) => void
|
setActiveSource: (src: string | null, viewId: string | null) => void
|
||||||
player: VideoPlayer
|
player: VideoPlayer
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
@@ -21,12 +21,24 @@ export function Provider({children}: {children: React.ReactNode}) {
|
|||||||
const player = useVideoPlayer(activeSource, p => {
|
const player = useVideoPlayer(activeSource, p => {
|
||||||
p.muted = true
|
p.muted = true
|
||||||
p.loop = true
|
p.loop = true
|
||||||
|
// We want to immediately call `play` so we get the loading state
|
||||||
p.play()
|
p.play()
|
||||||
})
|
})
|
||||||
|
|
||||||
const setActiveSourceOuter = (src: string, viewId: string) => {
|
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
|
||||||
setActiveSource(src)
|
// HACK
|
||||||
setActiveViewId(viewId)
|
// 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 (
|
return (
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
|
|||||||
import {makeProfileLink} from 'lib/routes/links'
|
import {makeProfileLink} from 'lib/routes/links'
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
import {precacheProfile} from 'state/queries/profile'
|
||||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||||
import {atoms as a, useBreakpoints} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||||
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
|
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
|
||||||
@@ -41,7 +41,7 @@ import {Link} from '../Link'
|
|||||||
import {PostMeta} from '../PostMeta'
|
import {PostMeta} from '../PostMeta'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {PostEmbeds} from '.'
|
import {PostEmbeds} from '.'
|
||||||
import {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
import {QuoteEmbedViewContext} from './types'
|
||||||
|
|
||||||
export function MaybeQuoteEmbed({
|
export function MaybeQuoteEmbed({
|
||||||
embed,
|
embed,
|
||||||
@@ -161,7 +161,6 @@ export function QuoteEmbed({
|
|||||||
onOpen,
|
onOpen,
|
||||||
style,
|
style,
|
||||||
allowNestedQuotes,
|
allowNestedQuotes,
|
||||||
viewContext,
|
|
||||||
}: {
|
}: {
|
||||||
quote: ComposerOptsQuote
|
quote: ComposerOptsQuote
|
||||||
moderation?: ModerationDecision
|
moderation?: ModerationDecision
|
||||||
@@ -175,7 +174,6 @@ export function QuoteEmbed({
|
|||||||
const itemUrip = new AtUri(quote.uri)
|
const itemUrip = new AtUri(quote.uri)
|
||||||
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
const itemHref = makeProfileLink(quote.author, 'post', itemUrip.rkey)
|
||||||
const itemTitle = `Post by ${quote.author.handle}`
|
const itemTitle = `Post by ${quote.author.handle}`
|
||||||
const {gtMobile} = useBreakpoints()
|
|
||||||
|
|
||||||
const richText = React.useMemo(
|
const richText = React.useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -207,7 +205,6 @@ export function QuoteEmbed({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [quote.embeds, allowNestedQuotes])
|
}, [quote.embeds, allowNestedQuotes])
|
||||||
const isImagesEmbed = AppBskyEmbedImages.isView(embed)
|
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
const onBeforePress = React.useCallback(() => {
|
||||||
precacheProfile(queryClient, quote.author)
|
precacheProfile(queryClient, quote.author)
|
||||||
@@ -237,43 +234,15 @@ export function QuoteEmbed({
|
|||||||
{moderation ? (
|
{moderation ? (
|
||||||
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
<PostAlerts modui={moderation.ui('contentView')} style={[a.py_xs]} />
|
||||||
) : null}
|
) : null}
|
||||||
|
{richText ? (
|
||||||
{viewContext === QuoteEmbedViewContext.FeedEmbedRecordWithMedia &&
|
<RichText
|
||||||
isImagesEmbed ? (
|
value={richText}
|
||||||
<View style={[a.flex_row, a.gap_md]}>
|
style={a.text_md}
|
||||||
{embed && (
|
numberOfLines={20}
|
||||||
<View style={[{width: gtMobile ? 100 : 80}]}>
|
disableLinks
|
||||||
<PostEmbeds
|
/>
|
||||||
embed={embed}
|
) : null}
|
||||||
moderation={moderation}
|
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
|
||||||
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{richText ? (
|
|
||||||
<View style={[a.flex_1, a.pt_xs]}>
|
|
||||||
<RichText
|
|
||||||
value={richText}
|
|
||||||
style={a.text_md}
|
|
||||||
numberOfLines={20}
|
|
||||||
disableLinks
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{richText ? (
|
|
||||||
<RichText
|
|
||||||
value={richText}
|
|
||||||
style={a.text_md}
|
|
||||||
numberOfLines={20}
|
|
||||||
disableLinks
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{embed && <PostEmbeds embed={embed} moderation={moderation} />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
</ContentHider>
|
</ContentHider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import React, {useCallback, useEffect, useId, useState} from 'react'
|
import React, {useCallback, useEffect, useId, useState} from 'react'
|
||||||
import {View} from 'react-native'
|
import {View} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {ImageBackground} from 'expo-image'
|
||||||
import {PlayerError, VideoPlayerStatus} from 'expo-video'
|
import {PlayerError, VideoPlayerStatus} from 'expo-video'
|
||||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||||
import {msg, Trans} from '@lingui/macro'
|
import {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {clamp} from '#/lib/numbers'
|
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 {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {Button} from '#/components/Button'
|
import {Button} from '#/components/Button'
|
||||||
@@ -23,8 +23,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function VideoEmbed({embed}: Props) {
|
export function VideoEmbed({embed}: Props) {
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
const [key, setKey] = useState(0)
|
const [key, setKey] = useState(0)
|
||||||
|
|
||||||
const renderError = useCallback(
|
const renderError = useCallback(
|
||||||
@@ -41,10 +39,6 @@ export function VideoEmbed({embed}: Props) {
|
|||||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!gate('video_view_on_posts')) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -69,18 +63,20 @@ function InnerWrapper({embed}: Props) {
|
|||||||
const viewId = useId()
|
const viewId = useId()
|
||||||
|
|
||||||
const [playerStatus, setPlayerStatus] = useState<
|
const [playerStatus, setPlayerStatus] = useState<
|
||||||
VideoPlayerStatus | 'switching'
|
VideoPlayerStatus | 'paused'
|
||||||
>('loading')
|
>('paused')
|
||||||
const [isMuted, setIsMuted] = useState(player.muted)
|
const [isMuted, setIsMuted] = useState(player.muted)
|
||||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||||
|
const disableAutoplay = useAutoplayDisabled()
|
||||||
const isActive = embed.playlist === activeSource && activeViewId === viewId
|
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 =
|
const isLoading =
|
||||||
isActive &&
|
isActive &&
|
||||||
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
||||||
playerStatus === 'loading')
|
playerStatus === 'loading')
|
||||||
const isSwitching = playerStatus === 'switching'
|
// This happens whenever the visibility view decides that another video should start playing
|
||||||
const showOverlay = !isActive || isLoading || isSwitching
|
const showOverlay = !isActive || isLoading || playerStatus === 'paused'
|
||||||
|
|
||||||
// send error up to error boundary
|
// send error up to error boundary
|
||||||
const [error, setError] = useState<Error | PlayerError | null>(null)
|
const [error, setError] = useState<Error | PlayerError | null>(null)
|
||||||
@@ -102,11 +98,14 @@ function InnerWrapper({embed}: Props) {
|
|||||||
)
|
)
|
||||||
const statusSub = player.addListener(
|
const statusSub = player.addListener(
|
||||||
'statusChange',
|
'statusChange',
|
||||||
(status, _oldStatus, playerError) => {
|
(status, oldStatus, playerError) => {
|
||||||
setPlayerStatus(status)
|
setPlayerStatus(status)
|
||||||
if (status === 'error') {
|
if (status === 'error') {
|
||||||
setError(playerError ?? new Error('Unknown player error'))
|
setError(playerError ?? new Error('Unknown player error'))
|
||||||
}
|
}
|
||||||
|
if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return () => {
|
return () => {
|
||||||
@@ -115,35 +114,47 @@ function InnerWrapper({embed}: Props) {
|
|||||||
statusSub.remove()
|
statusSub.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [player, isActive])
|
}, [player, isActive, disableAutoplay])
|
||||||
|
|
||||||
useEffect(() => {
|
// The source might already be active (for example, if you are scrolling a list of quotes and its all the same
|
||||||
if (!isActive && playerStatus !== 'loading') {
|
// video). In those cases, just start playing. Otherwise, setting the active source will result in the video
|
||||||
setPlayerStatus('loading')
|
// start playback immediately
|
||||||
}
|
const startPlaying = (ignoreAutoplayPreference: boolean) => {
|
||||||
}, [isActive, playerStatus])
|
if (disableAutoplay && !ignoreAutoplayPreference) {
|
||||||
|
|
||||||
const onChangeStatus = (isVisible: boolean) => {
|
|
||||||
if (isFullscreen) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVisible) {
|
if (isActive) {
|
||||||
setActiveSource(embed.playlist, viewId)
|
player.play()
|
||||||
if (!player.playing) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
setPlayerStatus('switching')
|
setActiveSource(embed.playlist, viewId)
|
||||||
player.muted = true
|
}
|
||||||
if (player.playing) {
|
}
|
||||||
player.pause()
|
|
||||||
|
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 (
|
return (
|
||||||
<VisibilityView enabled={true} onChangeStatus={onChangeStatus}>
|
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}>
|
||||||
{isActive ? (
|
{isActive ? (
|
||||||
<VideoEmbedInnerNative
|
<VideoEmbedInnerNative
|
||||||
embed={embed}
|
embed={embed}
|
||||||
@@ -153,29 +164,26 @@ function InnerWrapper({embed}: Props) {
|
|||||||
setIsFullscreen={setIsFullscreen}
|
setIsFullscreen={setIsFullscreen}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<View
|
<ImageBackground
|
||||||
|
source={{uri: embed.thumbnail}}
|
||||||
|
accessibilityIgnoresInvertColors
|
||||||
style={[
|
style={[
|
||||||
{
|
{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 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',
|
display: showOverlay ? 'flex' : 'none',
|
||||||
},
|
},
|
||||||
]}>
|
]}
|
||||||
<Image
|
cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android
|
||||||
source={{uri: embed.thumbnail}}
|
>
|
||||||
alt={embed.alt}
|
|
||||||
style={a.flex_1}
|
|
||||||
contentFit="cover"
|
|
||||||
accessibilityIgnoresInvertColors
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
style={[a.absolute, a.inset_0]}
|
style={[a.flex_1, a.align_center, a.justify_center]}
|
||||||
onPress={() => {
|
onPress={() => startPlaying(true)}
|
||||||
setActiveSource(embed.playlist, viewId)
|
|
||||||
}}
|
|
||||||
label={_(msg`Play video`)}
|
label={_(msg`Play video`)}
|
||||||
color="secondary">
|
color="secondary">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -183,8 +191,8 @@ function InnerWrapper({embed}: Props) {
|
|||||||
style={[
|
style={[
|
||||||
a.rounded_full,
|
a.rounded_full,
|
||||||
a.p_xs,
|
a.p_xs,
|
||||||
a.absolute,
|
a.align_center,
|
||||||
{top: 'auto', left: 'auto'},
|
a.justify_center,
|
||||||
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||||
]}>
|
]}>
|
||||||
<Loader size="2xl" style={{color: 'white'}} />
|
<Loader size="2xl" style={{color: 'white'}} />
|
||||||
@@ -193,7 +201,7 @@ function InnerWrapper({embed}: Props) {
|
|||||||
<PlayButtonIcon />
|
<PlayButtonIcon />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</ImageBackground>
|
||||||
</VisibilityView>
|
</VisibilityView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
|||||||
|
|
||||||
import {HITSLOP_30} from '#/lib/constants'
|
import {HITSLOP_30} from '#/lib/constants'
|
||||||
import {clamp} from '#/lib/numbers'
|
import {clamp} from '#/lib/numbers'
|
||||||
|
import {isAndroid} from 'platform/detection'
|
||||||
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||||
@@ -61,15 +62,15 @@ export function VideoEmbedInnerNative({
|
|||||||
PlatformInfo.setAudioActive(true)
|
PlatformInfo.setAudioActive(true)
|
||||||
player.muted = false
|
player.muted = false
|
||||||
setIsFullscreen(true)
|
setIsFullscreen(true)
|
||||||
|
if (isAndroid) {
|
||||||
|
player.play()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onFullscreenExit={() => {
|
onFullscreenExit={() => {
|
||||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||||
PlatformInfo.setAudioActive(false)
|
PlatformInfo.setAudioActive(false)
|
||||||
player.muted = true
|
player.muted = true
|
||||||
player.playbackRate = 1
|
player.playbackRate = 1
|
||||||
if (!player.playing) {
|
|
||||||
player.play()
|
|
||||||
}
|
|
||||||
setIsFullscreen(false)
|
setIsFullscreen(false)
|
||||||
}}
|
}}
|
||||||
accessibilityLabel={
|
accessibilityLabel={
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import React from 'react'
|
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 {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
import {useSetTitle} from '#/lib/hooks/useSetTitle'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
|
||||||
import {emitSoftReset} from '#/state/events'
|
import {emitSoftReset} from '#/state/events'
|
||||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||||
import {FeedParams} from '#/state/queries/post-feed'
|
import {FeedParams} from '#/state/queries/post-feed'
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {
|
import {useSetDrawerSwipeDisabled, useSetMinimalShellMode} from '#/state/shell'
|
||||||
useMinimalShellMode,
|
|
||||||
useSetDrawerSwipeDisabled,
|
|
||||||
useSetMinimalShellMode,
|
|
||||||
} from '#/state/shell'
|
|
||||||
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
import {useSelectedFeed, useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||||
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
import {useOTAUpdates} from 'lib/hooks/useOTAUpdates'
|
||||||
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
|
||||||
@@ -87,7 +81,6 @@ function HomeScreenReady({
|
|||||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||||
const selectedFeed = allFeeds[selectedIndex]
|
const selectedFeed = allFeeds[selectedIndex]
|
||||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||||
const gate = useGate()
|
|
||||||
|
|
||||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||||
useOTAUpdates()
|
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(
|
const onPageSelected = React.useCallback(
|
||||||
(index: number) => {
|
(index: number) => {
|
||||||
setMinimalShellMode(false)
|
setMinimalShellMode(false)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function Composer({}: {winHeight: number}) {
|
|||||||
quoteCount={state?.quoteCount}
|
quoteCount={state?.quoteCount}
|
||||||
onPost={state.onPost}
|
onPost={state.onPost}
|
||||||
mention={state.mention}
|
mention={state.mention}
|
||||||
openPicker={onOpenPicker}
|
openEmojiPicker={onOpenPicker}
|
||||||
text={state.text}
|
text={state.text}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
/>
|
/>
|
||||||
<Btn
|
<Btn
|
||||||
testID="bottomBarSearchBtn"
|
|
||||||
icon={
|
icon={
|
||||||
isAtSearch ? (
|
isAtSearch ? (
|
||||||
<MagnifyingGlassFilled
|
<MagnifyingGlassFilled
|
||||||
@@ -169,6 +168,7 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MagnifyingGlass
|
<MagnifyingGlass
|
||||||
|
testID="bottomBarSearchBtn"
|
||||||
width={iconWidth + 2}
|
width={iconWidth + 2}
|
||||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Animated from 'react-native-reanimated'
|
|||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||||
import * as NavigationBar from 'expo-navigation-bar'
|
import * as NavigationBar from 'expo-navigation-bar'
|
||||||
import {StatusBar} from 'expo-status-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 {useSession} from '#/state/session'
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
useSetDrawerOpen,
|
useSetDrawerOpen,
|
||||||
} from '#/state/shell'
|
} from '#/state/shell'
|
||||||
import {useCloseAnyActiveElement} from '#/state/util'
|
import {useCloseAnyActiveElement} from '#/state/util'
|
||||||
|
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||||
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
|
import {useNotificationsHandler} from 'lib/hooks/useNotificationHandler'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useNotificationsRegistration} from 'lib/notifications/notifications'
|
import {useNotificationsRegistration} from 'lib/notifications/notifications'
|
||||||
@@ -33,6 +34,7 @@ import {ErrorBoundary} from 'view/com/util/ErrorBoundary'
|
|||||||
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
|
||||||
import {SigninDialog} from '#/components/dialogs/Signin'
|
import {SigninDialog} from '#/components/dialogs/Signin'
|
||||||
import {Outlet as PortalOutlet} from '#/components/Portal'
|
import {Outlet as PortalOutlet} from '#/components/Portal'
|
||||||
|
import {updateActiveViewAsync} from '../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||||
import {RoutesContainer, TabsNavigator} from '../../Navigation'
|
import {RoutesContainer, TabsNavigator} from '../../Navigation'
|
||||||
import {Composer} from './Composer'
|
import {Composer} from './Composer'
|
||||||
import {DrawerContent} from './Drawer'
|
import {DrawerContent} from './Drawer'
|
||||||
@@ -76,6 +78,27 @@ function ShellInner() {
|
|||||||
}
|
}
|
||||||
}, [closeAnyActiveElement])
|
}, [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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|||||||
@@ -12414,9 +12414,9 @@ expo-updates@~0.25.14:
|
|||||||
ignore "^5.3.1"
|
ignore "^5.3.1"
|
||||||
resolve-from "^5.0.0"
|
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"
|
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:
|
expo-web-browser@~13.0.3:
|
||||||
version "13.0.3"
|
version "13.0.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user