Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
|
||||
- tapOn: "Delete List"
|
||||
- tapOn:
|
||||
id: "confirmBtn"
|
||||
- assertVisible:
|
||||
id: "listsEmpty"
|
||||
- assertVisible: "This list is empty!"
|
||||
|
||||
- tapOn:
|
||||
label: "Create a new curatelist"
|
||||
@@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
|
||||
- assertNotVisible:
|
||||
id: "userAddRemoveListsModal"
|
||||
|
||||
- tapOn:
|
||||
label: "Shows the curatelist on my profile"
|
||||
id: "bottomBarProfileBtn"
|
||||
- swipe:
|
||||
from:
|
||||
id: "profilePager-selector"
|
||||
direction: LEFT
|
||||
- tapOn:
|
||||
id: "profilePager-selector-6"
|
||||
- tapOn: "Good Ppl"
|
||||
|
||||
- tapOn:
|
||||
label: "Adds and removes users on curatelists from the profile"
|
||||
id: "bottomBarSearchBtn"
|
||||
|
||||
@@ -21,14 +21,12 @@ appId: xyz.blueskyweb.app
|
||||
id: "likeBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-bob.test"
|
||||
- assertVisible:
|
||||
id: "likeCount-expanded"
|
||||
- assertVisible: "1 like"
|
||||
- tapOn:
|
||||
id: "likeBtn"
|
||||
childOf:
|
||||
id: "postThreadItem-by-bob.test"
|
||||
- assertNotVisible:
|
||||
id: "likeCount-expanded"
|
||||
- assertNotVisible: "1 like"
|
||||
|
||||
# Can like a reply post
|
||||
- tapOn:
|
||||
|
||||
@@ -211,7 +211,6 @@ module.exports = function (config) {
|
||||
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
|
||||
},
|
||||
],
|
||||
'expo-video',
|
||||
'react-native-compressor',
|
||||
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
|
||||
'./plugins/withAndroidManifestPlugin.js',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 378 B |
@@ -258,6 +258,51 @@
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
width: 16px;
|
||||
vertical-align: bottom;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
</style>
|
||||
{% include "scripts.html" %}
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bsky.app",
|
||||
"version": "1.91.0",
|
||||
"version": "1.91.2",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -68,6 +68,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.1.1",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.1.1",
|
||||
"@fortawesome/react-native-fontawesome": "^0.3.2",
|
||||
"@haileyok/bluesky-video": "0.1.10",
|
||||
"@lingui/react": "^4.5.0",
|
||||
"@mattermost/react-native-paste-input": "^0.7.1",
|
||||
"@miblanchard/react-native-slider": "^2.3.1",
|
||||
@@ -84,7 +85,7 @@
|
||||
"@segment/analytics-react": "^1.0.0-rc1",
|
||||
"@segment/analytics-react-native": "^2.10.1",
|
||||
"@segment/sovran-react-native": "^0.4.5",
|
||||
"@sentry/react-native": "5.5.0",
|
||||
"@sentry/react-native": "5.32.0",
|
||||
"@tamagui/focus-scope": "^1.84.1",
|
||||
"@tanstack/query-async-storage-persister": "^5.25.0",
|
||||
"@tanstack/react-query": "^5.8.1",
|
||||
@@ -139,7 +140,6 @@
|
||||
"expo-system-ui": "~3.0.4",
|
||||
"expo-task-manager": "~11.8.1",
|
||||
"expo-updates": "~0.25.14",
|
||||
"expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz",
|
||||
"expo-web-browser": "~13.0.3",
|
||||
"fast-text-encoding": "^1.0.6",
|
||||
"history": "^5.3.0",
|
||||
@@ -177,7 +177,7 @@
|
||||
"react-native-fs": "^2.20.0",
|
||||
"react-native-gesture-handler": "~2.16.2",
|
||||
"react-native-get-random-values": "~1.11.0",
|
||||
"react-native-image-crop-picker": "0.40.3",
|
||||
"react-native-image-crop-picker": "0.41.2",
|
||||
"react-native-ios-context-menu": "^1.15.3",
|
||||
"react-native-keyboard-controller": "^1.12.1",
|
||||
"react-native-mmkv": "^2.12.2",
|
||||
@@ -190,7 +190,7 @@
|
||||
"react-native-safe-area-context": "4.10.1",
|
||||
"react-native-screens": "~3.31.1",
|
||||
"react-native-svg": "^15.3.0",
|
||||
"react-native-uitextview": "^1.1.7",
|
||||
"react-native-uitextview": "^1.3.0",
|
||||
"react-native-url-polyfill": "^1.3.0",
|
||||
"react-native-uuid": "^2.0.2",
|
||||
"react-native-view-shot": "^3.8.0",
|
||||
|
||||
+7
-8
@@ -52,17 +52,17 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
@@ -109,8 +109,8 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||
<ActiveVideoProvider>
|
||||
<RootSiblingParent>
|
||||
<RootSiblingParent>
|
||||
<VideoVolumeProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
@@ -131,6 +131,7 @@ function InnerApp() {
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
@@ -145,8 +146,8 @@ function InnerApp() {
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
</RootSiblingParent>
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
</Splash>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
@@ -157,8 +158,6 @@ function App() {
|
||||
const [isReady, setReady] = useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
initPersistedState().then(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
|
||||
+43
-35
@@ -35,17 +35,20 @@ import {
|
||||
} from '#/state/session'
|
||||
import {readLastActiveAccount} from '#/state/session/util'
|
||||
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
|
||||
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
||||
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {NuxDialogs} from '#/components/dialogs/nuxs'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
@@ -60,6 +63,8 @@ function InnerApp() {
|
||||
useIntentHandler()
|
||||
const hasCheckedReferrer = useStarterPackEntry()
|
||||
|
||||
useComposerKeyboardShortcut()
|
||||
|
||||
// init
|
||||
useEffect(() => {
|
||||
async function onLaunch(account?: SessionAccount) {
|
||||
@@ -94,41 +99,44 @@ function InnerApp() {
|
||||
<Alf theme={theme}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<RootSiblingParent>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</ActiveVideoProvider>
|
||||
<VideoVolumeProvider>
|
||||
<ActiveVideoProvider>
|
||||
<React.Fragment
|
||||
// Resets the entire tree below when it changes:
|
||||
key={currentAccount?.did}>
|
||||
<QueryProvider currentDid={currentAccount?.did}>
|
||||
<StatsigProvider>
|
||||
<MessagesProvider>
|
||||
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
|
||||
<LabelDefsProvider>
|
||||
<ModerationOptsProvider>
|
||||
<LoggedOutViewProvider>
|
||||
<SelectedFeedProvider>
|
||||
<HiddenRepliesProvider>
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
<NuxDialogs />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
</HiddenRepliesProvider>
|
||||
</SelectedFeedProvider>
|
||||
</LoggedOutViewProvider>
|
||||
</ModerationOptsProvider>
|
||||
</LabelDefsProvider>
|
||||
</MessagesProvider>
|
||||
</StatsigProvider>
|
||||
</QueryProvider>
|
||||
</React.Fragment>
|
||||
<ToastContainer />
|
||||
</ActiveVideoProvider>
|
||||
</VideoVolumeProvider>
|
||||
</RootSiblingParent>
|
||||
</ThemeProvider>
|
||||
</Alf>
|
||||
|
||||
+15
-3
@@ -18,9 +18,17 @@ export * from '#/alf/util/themeSelector'
|
||||
export const Context = React.createContext<{
|
||||
themeName: ThemeName
|
||||
theme: Theme
|
||||
themes: ReturnType<typeof createThemes>
|
||||
}>({
|
||||
themeName: 'light',
|
||||
theme: defaultTheme,
|
||||
themes: createThemes({
|
||||
hues: {
|
||||
primary: BLUE_HUE,
|
||||
negative: RED_HUE,
|
||||
positive: GREEN_HUE,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
export function ThemeProvider({
|
||||
@@ -42,18 +50,22 @@ export function ThemeProvider({
|
||||
<Context.Provider
|
||||
value={React.useMemo(
|
||||
() => ({
|
||||
themes,
|
||||
themeName: themeName,
|
||||
theme: theme,
|
||||
}),
|
||||
[theme, themeName],
|
||||
[theme, themeName, themes],
|
||||
)}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return React.useContext(Context).theme
|
||||
export function useTheme(theme?: ThemeName) {
|
||||
const ctx = React.useContext(Context)
|
||||
return React.useMemo(() => {
|
||||
return theme ? ctx.themes[theme] : ctx.theme
|
||||
}, [theme, ctx])
|
||||
}
|
||||
|
||||
export function useBreakpoints() {
|
||||
|
||||
@@ -256,7 +256,7 @@ export const ScrollableInner = React.forwardRef<
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
flatten(style),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={a.pb_4xl}
|
||||
ref={ref}>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
|
||||
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||
|
||||
export function Fill({
|
||||
children,
|
||||
style,
|
||||
}: {children?: React.ReactNode} & ViewStyleProp) {
|
||||
return <View style={[a.absolute, a.inset_0, style]}>{children}</View>
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function LikedByList({uri}: {uri: string}) {
|
||||
isLoading={isUriLoading || isLikedByLoading}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyTitle={_(msg`No likes yet`)}
|
||||
emptyMessage={_(
|
||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
|
||||
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
|
||||
/**
|
||||
* Applies and thin border within a bounding box. Used to contrast media from
|
||||
* bg of the container.
|
||||
*/
|
||||
export function MediaInsetBorder({
|
||||
children,
|
||||
style,
|
||||
opaque,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
/**
|
||||
* Used where this border needs to match adjacent borders, such as in
|
||||
* external link previews
|
||||
*/
|
||||
opaque?: boolean
|
||||
} & ViewStyleProp) {
|
||||
const t = useTheme()
|
||||
const isLight = t.name === 'light'
|
||||
return (
|
||||
<Fill
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
a.border,
|
||||
opaque
|
||||
? [t.atoms.border_contrast_low]
|
||||
: [
|
||||
isLight
|
||||
? t.atoms.border_contrast_low
|
||||
: t.atoms.border_contrast_high,
|
||||
{opacity: 0.6},
|
||||
],
|
||||
{
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
{children}
|
||||
</Fill>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {Trans} from '@lingui/macro'
|
||||
|
||||
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
@@ -104,6 +105,7 @@ export function ImageItem({
|
||||
accessibilityHint={alt}
|
||||
accessibilityLabel=""
|
||||
/>
|
||||
<MediaInsetBorder style={[a.rounded_xs]} />
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -59,7 +59,9 @@ export function Outer({
|
||||
export function TitleText({children}: React.PropsWithChildren<{}>) {
|
||||
const {titleId} = React.useContext(Context)
|
||||
return (
|
||||
<Text nativeID={titleId} style={[a.text_2xl, a.font_bold, a.pb_sm]}>
|
||||
<Text
|
||||
nativeID={titleId}
|
||||
style={[a.text_2xl, a.font_bold, a.pb_sm, a.leading_snug]}>
|
||||
{children}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ interface ProfilesListProps {
|
||||
|
||||
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
|
||||
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
|
||||
const feed: FeedDescriptor = `list|${listUri}|as_following`
|
||||
const feed: FeedDescriptor = `list|${listUri}`
|
||||
const {_} = useLingui()
|
||||
|
||||
const onScrollToTop = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Svg, {Circle, Path} from 'react-native-svg'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {Nux, useUpsertNuxMutation} from '#/state/queries/nuxs'
|
||||
import {atoms as a, ViewStyleProp} from '#/alf'
|
||||
import {Button, ButtonProps} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {InlineLinkText} from '#/components/Link'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {TenMillion} from './'
|
||||
|
||||
export function Trigger({children}: {children: ButtonProps['children']}) {
|
||||
const {_} = useLingui()
|
||||
const {mutate: upsertNux} = useUpsertNuxMutation()
|
||||
const [show, setShow] = React.useState(false)
|
||||
const [fallback, setFallback] = React.useState(false)
|
||||
const control = Prompt.usePromptControl()
|
||||
|
||||
const handleOnPress = () => {
|
||||
if (!fallback) {
|
||||
setShow(true)
|
||||
upsertNux({
|
||||
id: Nux.TenMillionDialog,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
})
|
||||
} else {
|
||||
control.open()
|
||||
}
|
||||
}
|
||||
|
||||
const onHandleFallback = () => {
|
||||
setFallback(true)
|
||||
control.open()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
label={_(msg`Bluesky is celebrating 10 million users!`)}
|
||||
onPress={handleOnPress}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
{show && !fallback && (
|
||||
<TenMillion
|
||||
showTimeout={0}
|
||||
onClose={() => setShow(false)}
|
||||
onFallback={onHandleFallback}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Prompt.Outer control={control}>
|
||||
<View style={{maxWidth: 300}}>
|
||||
<Prompt.TitleText>
|
||||
<Trans>Bluesky is celebrating 10 million users!</Trans>
|
||||
</Prompt.TitleText>
|
||||
</View>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
Together, we're rebuilding the social internet. We're glad you're
|
||||
here.
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Prompt.DescriptionText>
|
||||
<Trans>
|
||||
To learn more,{' '}
|
||||
<InlineLinkText
|
||||
label={_(msg`View our post`)}
|
||||
to="/profile/bsky.app/post/3l47prg3wgy23"
|
||||
onPress={() => {
|
||||
control.close()
|
||||
}}
|
||||
style={[a.text_md, a.leading_snug]}>
|
||||
<Trans>check out our post.</Trans>
|
||||
</InlineLinkText>
|
||||
</Trans>
|
||||
</Prompt.DescriptionText>
|
||||
<Dialog.Close />
|
||||
</Prompt.Outer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function Icon({width, style}: {width: number} & ViewStyleProp) {
|
||||
return (
|
||||
<Svg width={width} height={width} viewBox="0 0 36 36" style={style}>
|
||||
<Path
|
||||
fill="#dd2e44"
|
||||
d="M11.626 7.488a1.4 1.4 0 0 0-.268.395l-.008-.008L.134 33.141l.011.011c-.208.403.14 1.223.853 1.937c.713.713 1.533 1.061 1.936.853l.01.01L28.21 24.735l-.008-.009c.147-.07.282-.155.395-.269c1.562-1.562-.971-6.627-5.656-11.313c-4.687-4.686-9.752-7.218-11.315-5.656"
|
||||
/>
|
||||
<Path
|
||||
fill="#ea596e"
|
||||
d="M13 12L.416 32.506l-.282.635l.011.011c-.208.403.14 1.223.853 1.937c.232.232.473.408.709.557L17 17z"
|
||||
/>
|
||||
<Path
|
||||
fill="#a0041e"
|
||||
d="M23.012 13.066c4.67 4.672 7.263 9.652 5.789 11.124c-1.473 1.474-6.453-1.118-11.126-5.788c-4.671-4.672-7.263-9.654-5.79-11.127c1.474-1.473 6.454 1.119 11.127 5.791"
|
||||
/>
|
||||
<Path
|
||||
fill="#aa8dd8"
|
||||
d="M18.59 13.609a1 1 0 0 1-.734.215c-.868-.094-1.598-.396-2.109-.873c-.541-.505-.808-1.183-.735-1.862c.128-1.192 1.324-2.286 3.363-2.066c.793.085 1.147-.17 1.159-.292c.014-.121-.277-.446-1.07-.532c-.868-.094-1.598-.396-2.11-.873c-.541-.505-.809-1.183-.735-1.862c.13-1.192 1.325-2.286 3.362-2.065c.578.062.883-.057 1.012-.134c.103-.063.144-.123.148-.158c.012-.121-.275-.446-1.07-.532a1 1 0 0 1-.886-1.102a.997.997 0 0 1 1.101-.886c2.037.219 2.973 1.542 2.844 2.735c-.13 1.194-1.325 2.286-3.364 2.067c-.578-.063-.88.057-1.01.134c-.103.062-.145.123-.149.157c-.013.122.276.446 1.071.532c2.037.22 2.973 1.542 2.844 2.735s-1.324 2.286-3.362 2.065c-.578-.062-.882.058-1.012.134c-.104.064-.144.124-.148.158c-.013.121.276.446 1.07.532a1 1 0 0 1 .52 1.773"
|
||||
/>
|
||||
<Path
|
||||
fill="#77b255"
|
||||
d="M30.661 22.857c1.973-.557 3.334.323 3.658 1.478c.324 1.154-.378 2.615-2.35 3.17c-.77.216-1.001.584-.97.701c.034.118.425.312 1.193.095c1.972-.555 3.333.325 3.657 1.479c.326 1.155-.378 2.614-2.351 3.17c-.769.216-1.001.585-.967.702s.423.311 1.192.095a1 1 0 1 1 .54 1.925c-1.971.555-3.333-.323-3.659-1.479c-.324-1.154.379-2.613 2.353-3.169c.77-.217 1.001-.584.967-.702c-.032-.117-.422-.312-1.19-.096c-1.974.556-3.334-.322-3.659-1.479c-.325-1.154.378-2.613 2.351-3.17c.768-.215.999-.585.967-.701c-.034-.118-.423-.312-1.192-.096a1 1 0 1 1-.54-1.923"
|
||||
/>
|
||||
<Path
|
||||
fill="#aa8dd8"
|
||||
d="M23.001 20.16a1.001 1.001 0 0 1-.626-1.781c.218-.175 5.418-4.259 12.767-3.208a1 1 0 1 1-.283 1.979c-6.493-.922-11.187 2.754-11.233 2.791a1 1 0 0 1-.625.219"
|
||||
/>
|
||||
<Path
|
||||
fill="#77b255"
|
||||
d="M5.754 16a1 1 0 0 1-.958-1.287c1.133-3.773 2.16-9.794.898-11.364c-.141-.178-.354-.353-.842-.316c-.938.072-.849 2.051-.848 2.071a1 1 0 1 1-1.994.149c-.103-1.379.326-4.035 2.692-4.214c1.056-.08 1.933.287 2.552 1.057c2.371 2.951-.036 11.506-.542 13.192a1 1 0 0 1-.958.712"
|
||||
/>
|
||||
<Circle cx="25.5" cy="9.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="2" cy="18" r="2" fill="#9266cc" />
|
||||
<Circle cx="32.5" cy="19.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="23.5" cy="31.5" r="1.5" fill="#5c913b" />
|
||||
<Circle cx="28" cy="4" r="2" fill="#ffcc4d" />
|
||||
<Circle cx="32.5" cy="8.5" r="1.5" fill="#ffcc4d" />
|
||||
<Circle cx="29.5" cy="12.5" r="1.5" fill="#ffcc4d" />
|
||||
<Circle cx="7.5" cy="23.5" r="1.5" fill="#ffcc4d" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,719 @@
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn} from 'react-native-reanimated'
|
||||
import ViewShot from 'react-native-view-shot'
|
||||
import {Image} from 'expo-image'
|
||||
import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
|
||||
import * as MediaLibrary from 'expo-media-library'
|
||||
import {moderateProfile} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {networkRetry} from '#/lib/async/retry'
|
||||
import {getCanvas} from '#/lib/canvas'
|
||||
import {shareUrl} from '#/lib/sharing'
|
||||
import {logEvent} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {isIOS, isNative} from '#/platform/detection'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useProfileQuery} from '#/state/queries/profile'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
import {useComposerControls} from 'state/shell'
|
||||
import {formatCount} from '#/view/com/util/numeric/format'
|
||||
import {Logomark} from '#/view/icons/Logomark'
|
||||
import * as Toast from 'view/com/util/Toast'
|
||||
import {
|
||||
atoms as a,
|
||||
ThemeProvider,
|
||||
tokens,
|
||||
useBreakpoints,
|
||||
useTheme,
|
||||
} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
|
||||
import {OnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/OnePercent'
|
||||
import {PointOnePercent} from '#/components/dialogs/nuxs/TenMillion/icons/PointOnePercent'
|
||||
import {TenPercent} from '#/components/dialogs/nuxs/TenMillion/icons/TenPercent'
|
||||
import {Divider} from '#/components/Divider'
|
||||
import {GradientFill} from '#/components/GradientFill'
|
||||
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
|
||||
import {Download_Stroke2_Corner0_Rounded as Download} from '#/components/icons/Download'
|
||||
import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
const DEBUG = false
|
||||
const RATIO = 8 / 10
|
||||
const WIDTH = 2000
|
||||
const HEIGHT = WIDTH * RATIO
|
||||
|
||||
function getFontSize(count: number) {
|
||||
const length = count.toString().length
|
||||
if (length < 7) {
|
||||
return 80
|
||||
} else if (length < 5) {
|
||||
return 100
|
||||
} else {
|
||||
return 70
|
||||
}
|
||||
}
|
||||
|
||||
function getPercentBadge(percent: number) {
|
||||
if (percent <= 0.001) {
|
||||
return PointOnePercent
|
||||
} else if (percent <= 0.01) {
|
||||
return OnePercent
|
||||
} else if (percent <= 0.1) {
|
||||
return TenPercent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function Frame({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.relative,
|
||||
a.w_full,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
paddingTop: '80%',
|
||||
},
|
||||
]}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function TenMillion({
|
||||
showTimeout,
|
||||
onClose,
|
||||
onFallback,
|
||||
}: {
|
||||
showTimeout?: number
|
||||
onClose?: () => void
|
||||
onFallback?: () => void
|
||||
}) {
|
||||
const agent = useAgent()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const [userNumber, setUserNumber] = React.useState<number>(0)
|
||||
const fetching = React.useRef(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchUserNumber() {
|
||||
const isBlueskyHosted = agent.sessionManager.pdsUrl
|
||||
?.toString()
|
||||
.includes('bsky.network')
|
||||
|
||||
if (isBlueskyHosted && agent.session?.accessJwt) {
|
||||
const res = await fetch(
|
||||
`https://bsky.social/xrpc/com.atproto.temp.getSignupNumber`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${agent.session.accessJwt}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Network request failed')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.number && data.number <= 10_000_000) {
|
||||
setUserNumber(data.number)
|
||||
} else {
|
||||
// should be rare
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
}
|
||||
} else {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
}
|
||||
}
|
||||
|
||||
if (!fetching.current) {
|
||||
fetching.current = true
|
||||
networkRetry(3, fetchUserNumber).catch(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onFallback?.()
|
||||
})
|
||||
}
|
||||
}, [
|
||||
agent.sessionManager.pdsUrl,
|
||||
agent.session?.accessJwt,
|
||||
setUserNumber,
|
||||
nuxDialogs.dismissActiveNux,
|
||||
nuxDialogs,
|
||||
onFallback,
|
||||
])
|
||||
|
||||
return userNumber ? (
|
||||
<TenMillionInner
|
||||
userNumber={userNumber}
|
||||
showTimeout={showTimeout ?? 3e3}
|
||||
onClose={onClose}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
export function TenMillionInner({
|
||||
userNumber,
|
||||
showTimeout,
|
||||
onClose: onCloseOuter,
|
||||
}: {
|
||||
userNumber: number
|
||||
showTimeout: number
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const lightTheme = useTheme('light')
|
||||
const {_, i18n} = useLingui()
|
||||
const control = Dialog.useDialogControl()
|
||||
const {gtMobile} = useBreakpoints()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {currentAccount} = useSession()
|
||||
const {
|
||||
isLoading: isProfileLoading,
|
||||
data: profile,
|
||||
error: profileError,
|
||||
} = useProfileQuery({
|
||||
did: currentAccount!.did,
|
||||
})
|
||||
const moderationOpts = useModerationOpts()
|
||||
const nuxDialogs = useNuxDialogContext()
|
||||
const moderation = React.useMemo(() => {
|
||||
return profile && moderationOpts
|
||||
? moderateProfile(profile, moderationOpts)
|
||||
: undefined
|
||||
}, [profile, moderationOpts])
|
||||
const [uri, setUri] = React.useState<string | null>(null)
|
||||
const percent = userNumber / 10_000_000
|
||||
const Badge = getPercentBadge(percent)
|
||||
const isLoadingData = isProfileLoading || !moderation || !profile
|
||||
const isLoadingImage = !uri
|
||||
|
||||
const displayName = React.useMemo(() => {
|
||||
if (!profile || !moderation) return ''
|
||||
return sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
}, [profile, moderation])
|
||||
const handle = React.useMemo(() => {
|
||||
if (!profile) return ''
|
||||
return sanitizeHandle(profile.handle, '@')
|
||||
}, [profile])
|
||||
const joinedDate = React.useMemo(() => {
|
||||
if (!profile || !profile.createdAt) return ''
|
||||
const date = i18n.date(profile.createdAt, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})
|
||||
return date
|
||||
}, [i18n, profile])
|
||||
|
||||
const error: string = React.useMemo(() => {
|
||||
if (profileError) {
|
||||
return _(
|
||||
msg`Oh no! We weren't able to generate an image for you to share. Rest assured, we're glad you're here 🦋`,
|
||||
)
|
||||
}
|
||||
return ''
|
||||
}, [_, profileError])
|
||||
|
||||
/*
|
||||
* Opening and closing
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
control.open()
|
||||
}, showTimeout)
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [control, showTimeout])
|
||||
const onClose = React.useCallback(() => {
|
||||
nuxDialogs.dismissActiveNux()
|
||||
onCloseOuter?.()
|
||||
}, [nuxDialogs, onCloseOuter])
|
||||
|
||||
/*
|
||||
* Actions
|
||||
*/
|
||||
const sharePost = React.useCallback(() => {
|
||||
if (uri) {
|
||||
control.close(() => {
|
||||
setTimeout(() => {
|
||||
logEvent('tmd:post', {})
|
||||
openComposer({
|
||||
text: _(
|
||||
msg`Bluesky now has over 10 million users, and I was #${i18n.number(
|
||||
userNumber,
|
||||
)}!`,
|
||||
),
|
||||
imageUris: [
|
||||
{
|
||||
uri,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
altText: _(
|
||||
msg`A virtual certificate with text "Celebrating 10M users on Bluesky, #${i18n.number(
|
||||
userNumber,
|
||||
)}, ${displayName} ${handle}, joined on ${joinedDate}"`,
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}, 1e3)
|
||||
})
|
||||
}
|
||||
}, [
|
||||
_,
|
||||
i18n,
|
||||
control,
|
||||
openComposer,
|
||||
uri,
|
||||
userNumber,
|
||||
displayName,
|
||||
handle,
|
||||
joinedDate,
|
||||
])
|
||||
const onNativeShare = React.useCallback(() => {
|
||||
if (uri) {
|
||||
control.close(() => {
|
||||
logEvent('tmd:share', {})
|
||||
shareUrl(uri)
|
||||
})
|
||||
}
|
||||
}, [uri, control])
|
||||
const onNativeDownload = React.useCallback(async () => {
|
||||
if (uri) {
|
||||
const res = await requestMediaLibraryPermissionsAsync()
|
||||
|
||||
if (!res) {
|
||||
Toast.show(
|
||||
_(
|
||||
msg`You must grant access to your photo library to save the image.`,
|
||||
),
|
||||
'xmark',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await MediaLibrary.createAssetAsync(uri)
|
||||
logEvent('tmd:download', {})
|
||||
Toast.show(_(msg`Image saved to your camera roll!`))
|
||||
} catch (e: unknown) {
|
||||
console.log(e)
|
||||
Toast.show(_(msg`An error occurred while saving the image!`), 'xmark')
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [_, uri])
|
||||
const onWebDownload = React.useCallback(async () => {
|
||||
if (uri) {
|
||||
const canvas = await getCanvas(uri)
|
||||
const imgHref = canvas
|
||||
.toDataURL('image/png')
|
||||
.replace('image/png', 'image/octet-stream')
|
||||
const link = document.createElement('a')
|
||||
link.setAttribute('download', `Bluesky 10M Users.png`)
|
||||
link.setAttribute('href', imgHref)
|
||||
link.click()
|
||||
logEvent('tmd:download', {})
|
||||
}
|
||||
}, [uri])
|
||||
|
||||
/*
|
||||
* Canvas stuff
|
||||
*/
|
||||
const imageRef = React.useRef<ViewShot>(null)
|
||||
const captureInProgress = React.useRef(false)
|
||||
const onCanvasReady = React.useCallback(async () => {
|
||||
if (
|
||||
imageRef.current &&
|
||||
imageRef.current.capture &&
|
||||
!captureInProgress.current
|
||||
) {
|
||||
captureInProgress.current = true
|
||||
const uri = await imageRef.current.capture()
|
||||
setUri(uri)
|
||||
}
|
||||
}, [setUri])
|
||||
const canvas = isLoadingData ? null : (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.overflow_hidden,
|
||||
DEBUG
|
||||
? {
|
||||
width: 600,
|
||||
height: 600 * RATIO,
|
||||
}
|
||||
: {
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
]}>
|
||||
<View style={{width: 600}}>
|
||||
<ThemeProvider theme="light">
|
||||
<Frame>
|
||||
<ViewShot
|
||||
ref={imageRef}
|
||||
options={{width: WIDTH, height: HEIGHT}}
|
||||
style={[a.absolute, a.inset_0]}>
|
||||
<View
|
||||
onLayout={onCanvasReady}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{
|
||||
top: -1,
|
||||
bottom: -1,
|
||||
left: -1,
|
||||
right: -1,
|
||||
paddingVertical: 48,
|
||||
paddingHorizontal: 48,
|
||||
},
|
||||
]}>
|
||||
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.w_full,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
a.rounded_md,
|
||||
{
|
||||
backgroundColor: 'white',
|
||||
shadowRadius: 32,
|
||||
shadowOpacity: 0.1,
|
||||
elevation: 24,
|
||||
shadowColor: tokens.gradients.bonfire.values[0][1],
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.px_xl,
|
||||
a.py_xl,
|
||||
{
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
]}>
|
||||
<Logomark fill={t.palette.primary_500} width={36} />
|
||||
</View>
|
||||
|
||||
{/* Centered content */}
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingBottom: isNative ? 0 : 24,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.text_md,
|
||||
a.font_bold,
|
||||
a.text_center,
|
||||
a.pb_sm,
|
||||
lightTheme.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>
|
||||
Celebrating {formatCount(i18n, 10000000)} users
|
||||
</Trans>{' '}
|
||||
🎉
|
||||
</Text>
|
||||
<View style={[a.flex_row, a.align_start]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
color: t.palette.primary_500,
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
width: 32,
|
||||
top: isNative ? -10 : 0,
|
||||
left: 0,
|
||||
transform: [
|
||||
{
|
||||
translateX: -16,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
#
|
||||
</Text>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.relative,
|
||||
a.text_center,
|
||||
{
|
||||
fontStyle: 'italic',
|
||||
fontSize: getFontSize(userNumber),
|
||||
lineHeight: getFontSize(userNumber),
|
||||
fontWeight: '900',
|
||||
letterSpacing: -2,
|
||||
},
|
||||
]}>
|
||||
{i18n.number(userNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{Badge && (
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
width: 64,
|
||||
height: 64,
|
||||
top: isNative ? 75 : 85,
|
||||
right: '5%',
|
||||
transform: [
|
||||
{
|
||||
rotate: '8deg',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}>
|
||||
<Badge fill={t.palette.primary_500} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{/* End centered content */}
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.px_xl,
|
||||
a.py_xl,
|
||||
{
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
]}>
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
{/*
|
||||
<UserAvatar
|
||||
size={36}
|
||||
avatar={profile.avatar}
|
||||
moderation={moderation.ui('avatar')}
|
||||
onLoad={onCanvasReady}
|
||||
/>
|
||||
*/}
|
||||
<View style={[a.gap_2xs, a.flex_1]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_bold,
|
||||
a.leading_tight,
|
||||
{maxWidth: '60%'},
|
||||
]}>
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={[a.flex_row, a.justify_between, a.gap_4xl]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
numberOfLines={1}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_semibold,
|
||||
a.leading_snug,
|
||||
lightTheme.atoms.text_contrast_medium,
|
||||
]}>
|
||||
{handle}
|
||||
</Text>
|
||||
|
||||
{profile.createdAt && (
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="head"
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_sm,
|
||||
a.font_semibold,
|
||||
a.leading_snug,
|
||||
a.text_right,
|
||||
lightTheme.atoms.text_contrast_low,
|
||||
]}>
|
||||
<Trans>Joined on {joinedDate}</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ViewShot>
|
||||
</Frame>
|
||||
</ThemeProvider>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Outer control={control} onClose={onClose}>
|
||||
<Dialog.ScrollableInner
|
||||
label={_(msg`Ten Million`)}
|
||||
style={[
|
||||
{
|
||||
padding: 0,
|
||||
paddingTop: 0,
|
||||
},
|
||||
]}>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
isNative && {
|
||||
borderTopLeftRadius: 40,
|
||||
borderTopRightRadius: 40,
|
||||
},
|
||||
]}>
|
||||
<Frame>
|
||||
<View
|
||||
style={[a.absolute, a.inset_0, a.align_center, a.justify_center]}>
|
||||
<GradientFill gradient={tokens.gradients.bonfire} />
|
||||
{error ? (
|
||||
<View>
|
||||
<Text
|
||||
style={[
|
||||
a.text_md,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
a.pb_md,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
(╯°□°)╯︵ ┻━┻
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
a.text_xl,
|
||||
a.font_bold,
|
||||
a.leading_snug,
|
||||
a.text_center,
|
||||
{
|
||||
maxWidth: 300,
|
||||
},
|
||||
]}>
|
||||
{error}
|
||||
</Text>
|
||||
</View>
|
||||
) : isLoadingData || isLoadingImage ? (
|
||||
<Loader size="xl" fill="white" />
|
||||
) : (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(150)}
|
||||
style={[a.w_full, a.h_full]}>
|
||||
<Image
|
||||
accessibilityIgnoresInvertColors
|
||||
source={{uri}}
|
||||
style={[a.w_full, a.h_full]}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</Frame>
|
||||
|
||||
{canvas}
|
||||
|
||||
<View style={[gtMobile ? a.p_2xl : a.p_xl]}>
|
||||
<Text
|
||||
allowFontScaling={false}
|
||||
style={[
|
||||
a.text_5xl,
|
||||
a.leading_tight,
|
||||
a.pb_lg,
|
||||
{
|
||||
fontWeight: '900',
|
||||
},
|
||||
]}>
|
||||
<Trans>Thanks for being one of our first 10 million users.</Trans>
|
||||
</Text>
|
||||
|
||||
<Text style={[a.leading_snug, a.text_lg, a.pb_xl]}>
|
||||
<Trans>
|
||||
Together, we're rebuilding the social internet. We're glad
|
||||
you're here.
|
||||
</Trans>
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.justify_end,
|
||||
a.gap_md,
|
||||
a.pt_xl,
|
||||
]}>
|
||||
{gtMobile && (
|
||||
<Text
|
||||
style={[a.text_md, a.italic, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Brag a little!</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button
|
||||
disabled={isLoadingImage}
|
||||
label={
|
||||
isNative && isIOS
|
||||
? _(msg`Share image externally`)
|
||||
: _(msg`Download image`)
|
||||
}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
shape="square"
|
||||
onPress={
|
||||
isNative
|
||||
? isIOS
|
||||
? onNativeShare
|
||||
: onNativeDownload
|
||||
: onWebDownload
|
||||
}>
|
||||
<ButtonIcon icon={isNative && isIOS ? Share : Download} />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isLoadingImage}
|
||||
label={_(msg`Share image in post`)}
|
||||
size="large"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onPress={sharePost}>
|
||||
<ButtonText>{_(msg`Share`)}</ButtonText>
|
||||
<ButtonIcon position="right" icon={ImageIcon} />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Dialog.Close />
|
||||
</Dialog.ScrollableInner>
|
||||
</Dialog.Outer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
Nux,
|
||||
useNuxs,
|
||||
useRemoveNuxsMutation,
|
||||
useUpsertNuxMutation,
|
||||
} from '#/state/queries/nuxs'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useOnboardingState} from '#/state/shell'
|
||||
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
|
||||
import {TenMillion} from '#/components/dialogs/nuxs/TenMillion'
|
||||
import {IS_DEV} from '#/env'
|
||||
|
||||
type Context = {
|
||||
activeNux: Nux | undefined
|
||||
dismissActiveNux: () => void
|
||||
}
|
||||
|
||||
const queuedNuxs: {
|
||||
id: Nux
|
||||
enabled?: (props: {gate: ReturnType<typeof useGate>}) => boolean
|
||||
}[] = [
|
||||
{
|
||||
id: Nux.TenMillionDialog,
|
||||
},
|
||||
]
|
||||
|
||||
const Context = React.createContext<Context>({
|
||||
activeNux: undefined,
|
||||
dismissActiveNux: () => {},
|
||||
})
|
||||
|
||||
export function useNuxDialogContext() {
|
||||
return React.useContext(Context)
|
||||
}
|
||||
|
||||
export function NuxDialogs() {
|
||||
const {hasSession} = useSession()
|
||||
const onboardingState = useOnboardingState()
|
||||
return hasSession && !onboardingState.isActive ? <Inner /> : null
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const gate = useGate()
|
||||
const {nuxs} = useNuxs()
|
||||
const [snoozed, setSnoozed] = React.useState(() => {
|
||||
return isSnoozed()
|
||||
})
|
||||
const [activeNux, setActiveNux] = React.useState<Nux | undefined>()
|
||||
const {mutateAsync: upsertNux} = useUpsertNuxMutation()
|
||||
const {mutate: removeNuxs} = useRemoveNuxsMutation()
|
||||
|
||||
const snoozeNuxDialog = React.useCallback(() => {
|
||||
snooze()
|
||||
setSnoozed(true)
|
||||
}, [setSnoozed])
|
||||
|
||||
const dismissActiveNux = React.useCallback(() => {
|
||||
if (!activeNux) return
|
||||
setActiveNux(undefined)
|
||||
}, [activeNux, setActiveNux])
|
||||
|
||||
if (IS_DEV && typeof window !== 'undefined') {
|
||||
// @ts-ignore
|
||||
window.clearNuxDialog = (id: Nux) => {
|
||||
if (!IS_DEV || !id) return
|
||||
removeNuxs([id])
|
||||
unsnooze()
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (snoozed) return
|
||||
if (!nuxs) return
|
||||
|
||||
for (const {id, enabled} of queuedNuxs) {
|
||||
const nux = nuxs.find(nux => nux.id === id)
|
||||
|
||||
// check if completed first
|
||||
if (nux && nux.completed) continue
|
||||
|
||||
// then check gate (track exposure)
|
||||
if (enabled && !enabled({gate})) continue
|
||||
|
||||
// we have a winner
|
||||
setActiveNux(id)
|
||||
|
||||
// immediately snooze for a day
|
||||
snoozeNuxDialog()
|
||||
|
||||
// immediately update remote data (affects next reload)
|
||||
upsertNux({
|
||||
id,
|
||||
completed: true,
|
||||
data: undefined,
|
||||
}).catch(e => {
|
||||
logger.error(`NUX dialogs: failed to upsert '${id}' NUX`, {
|
||||
safeMessage: e.message,
|
||||
})
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
}, [nuxs, snoozed, snoozeNuxDialog, upsertNux, gate])
|
||||
|
||||
const ctx = React.useMemo(() => {
|
||||
return {
|
||||
activeNux,
|
||||
dismissActiveNux,
|
||||
}
|
||||
}, [activeNux, dismissActiveNux])
|
||||
|
||||
return (
|
||||
<Context.Provider value={ctx}>
|
||||
{activeNux === Nux.TenMillionDialog && <TenMillion />}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {simpleAreDatesEqual} from '#/lib/strings/time'
|
||||
import {device} from '#/storage'
|
||||
|
||||
export function snooze() {
|
||||
device.set(['lastNuxDialog'], new Date().toISOString())
|
||||
}
|
||||
|
||||
export function unsnooze() {
|
||||
device.set(['lastNuxDialog'], undefined)
|
||||
}
|
||||
|
||||
export function isSnoozed() {
|
||||
const lastNuxDialog = device.get(['lastNuxDialog'])
|
||||
if (!lastNuxDialog) return false
|
||||
const last = new Date(lastNuxDialog)
|
||||
const now = new Date()
|
||||
// already snoozed today
|
||||
if (simpleAreDatesEqual(last, now)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createSinglePathSVG} from './TEMPLATE'
|
||||
|
||||
export const Download_Stroke2_Corner0_Rounded = createSinglePathSVG({
|
||||
path: 'M12 3a1 1 0 0 1 1 1v8.086l1.793-1.793a1 1 0 1 1 1.414 1.414l-3.5 3.5a1 1 0 0 1-1.414 0l-3.5-3.5a1 1 0 1 1 1.414-1.414L11 12.086V4a1 1 0 0 1 1-1ZM4 14a1 1 0 0 1 1 1v4h14v-4a1 1 0 1 1 2 0v5a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-5a1 1 0 0 1 1-1Z',
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import {View} from 'react-native'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
|
||||
export function PlayButtonIcon({size = 36}: {size?: number}) {
|
||||
export function PlayButtonIcon({size = 32}: {size?: number}) {
|
||||
const t = useTheme()
|
||||
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975
|
||||
const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AppBskyFeedDefs,
|
||||
AppBskyFeedGetFeed as GetCustomFeed,
|
||||
BskyAgent,
|
||||
jsonStringToLex,
|
||||
} from '@atproto/api'
|
||||
|
||||
import {getContentLanguages} from '#/state/preferences/languages'
|
||||
@@ -111,7 +112,7 @@ async function loggedOutFetch({
|
||||
}&limit=${limit}&lang=${contentLangs}`,
|
||||
{method: 'GET', headers: {'Accept-Language': contentLangs}},
|
||||
)
|
||||
let data = res.ok ? await res.json() : null
|
||||
let data = res.ok ? jsonStringToLex(await res.text()) : null
|
||||
if (data?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
@@ -126,7 +127,7 @@ async function loggedOutFetch({
|
||||
}&limit=${limit}`,
|
||||
{method: 'GET', headers: {'Accept-Language': ''}},
|
||||
)
|
||||
data = res.ok ? await res.json() : null
|
||||
data = res.ok ? jsonStringToLex(await res.text()) : null
|
||||
if (data?.feed?.length) {
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
|
||||
return new Promise(resolve => {
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = image.width
|
||||
canvas.height = image.height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx?.drawImage(image, 0, 0)
|
||||
resolve(canvas)
|
||||
}
|
||||
image.src = base64
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '#/components/icons/Heart2'
|
||||
|
||||
const animationConfig = {
|
||||
duration: 400,
|
||||
duration: 600,
|
||||
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fill: 'forwards' as FillMode,
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function useIntentHandler() {
|
||||
}, [incomingUrl, composeIntent, verifyEmailIntent])
|
||||
}
|
||||
|
||||
function useComposeIntent() {
|
||||
export function useComposeIntent() {
|
||||
const closeAllActiveElements = useCloseAllActiveElements()
|
||||
const {openComposer} = useComposerControls()
|
||||
const {hasSession} = useSession()
|
||||
|
||||
@@ -225,4 +225,8 @@ export type LogEvents = {
|
||||
'test:gate1:sometimes': {}
|
||||
'test:gate2:always': {}
|
||||
'test:gate2:sometimes': {}
|
||||
|
||||
'tmd:share': {}
|
||||
'tmd:download': {}
|
||||
'tmd:post': {}
|
||||
}
|
||||
|
||||
+324
-241
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+219
-153
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+479
-656
File diff suppressed because it is too large
Load Diff
+1964
-2750
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+290
-231
File diff suppressed because it is too large
Load Diff
+307
-226
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+388
-238
File diff suppressed because it is too large
Load Diff
+265
-182
File diff suppressed because it is too large
Load Diff
+265
-182
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
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
|
||||
variant="solid"
|
||||
color={'primary'}
|
||||
size="medium"
|
||||
onPress={onPressNext}
|
||||
disabled={!email}>
|
||||
onPress={onPressNext}>
|
||||
<ButtonText>
|
||||
<Trans>Next</Trans>
|
||||
</ButtonText>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const LoginForm = ({
|
||||
const {track} = useAnalytics()
|
||||
const t = useTheme()
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false)
|
||||
const [isReady, setIsReady] = useState<boolean>(false)
|
||||
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
|
||||
useState<boolean>(false)
|
||||
const identifierValueRef = useRef<string>(initialHandle || '')
|
||||
@@ -83,12 +82,18 @@ export const LoginForm = ({
|
||||
Keyboard.dismiss()
|
||||
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
|
||||
setError('')
|
||||
setIsProcessing(true)
|
||||
|
||||
const identifier = identifierValueRef.current.toLowerCase().trim()
|
||||
const password = passwordValueRef.current
|
||||
const authFactorToken = authFactorTokenValueRef.current
|
||||
|
||||
if (!identifier || !password) {
|
||||
setError(_(msg`Invalid username or password`))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessing(true)
|
||||
|
||||
try {
|
||||
// try to guess the handle if the user just gave their own username
|
||||
let fullIdent = identifier
|
||||
@@ -157,22 +162,6 @@ export const LoginForm = ({
|
||||
}
|
||||
}
|
||||
|
||||
const checkIsReady = () => {
|
||||
if (
|
||||
!!serviceDescription &&
|
||||
!!identifierValueRef.current &&
|
||||
!!passwordValueRef.current
|
||||
) {
|
||||
if (!isReady) {
|
||||
setIsReady(true)
|
||||
}
|
||||
} else {
|
||||
if (isReady) {
|
||||
setIsReady(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
|
||||
<View>
|
||||
@@ -204,7 +193,6 @@ export const LoginForm = ({
|
||||
defaultValue={initialHandle || ''}
|
||||
onChangeText={v => {
|
||||
identifierValueRef.current = v
|
||||
checkIsReady()
|
||||
}}
|
||||
onSubmitEditing={() => {
|
||||
passwordRef.current?.focus()
|
||||
@@ -233,7 +221,6 @@ export const LoginForm = ({
|
||||
clearButtonMode="while-editing"
|
||||
onChangeText={v => {
|
||||
passwordValueRef.current = v
|
||||
checkIsReady()
|
||||
}}
|
||||
onSubmitEditing={onPressNext}
|
||||
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
|
||||
@@ -325,7 +312,7 @@ export const LoginForm = ({
|
||||
<Trans>Connecting...</Trans>
|
||||
</Text>
|
||||
</>
|
||||
) : isReady ? (
|
||||
) : (
|
||||
<Button
|
||||
testID="loginNextButton"
|
||||
label={_(msg`Next`)}
|
||||
@@ -339,7 +326,7 @@ export const LoginForm = ({
|
||||
</ButtonText>
|
||||
{isProcessing && <ButtonIcon icon={Loader} />}
|
||||
</Button>
|
||||
) : undefined}
|
||||
)}
|
||||
</View>
|
||||
</FormContainer>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {ImageModel} from './image'
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {openPicker} from 'lib/media/picker'
|
||||
|
||||
import {getImageDim} from 'lib/media/manip'
|
||||
import {openPicker} from 'lib/media/picker'
|
||||
import {ImageInitOptions, ImageModel} from './image'
|
||||
|
||||
interface InitialImageUri {
|
||||
uri: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
export class GalleryModel {
|
||||
images: ImageModel[] = []
|
||||
|
||||
constructor(uris?: {uri: string; width: number; height: number}[]) {
|
||||
constructor(uris?: InitialImageUri[]) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
if (uris) {
|
||||
@@ -33,7 +34,7 @@ export class GalleryModel {
|
||||
return this.images.some(image => image.altText.trim() === '')
|
||||
}
|
||||
|
||||
*add(image_: Omit<RNImage, 'size'>) {
|
||||
*add(image_: ImageInitOptions) {
|
||||
if (this.size >= 4) {
|
||||
return
|
||||
}
|
||||
@@ -59,7 +60,6 @@ export class GalleryModel {
|
||||
path: uri,
|
||||
height,
|
||||
width,
|
||||
mime: 'image/jpeg',
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
@@ -100,10 +100,10 @@ export class GalleryModel {
|
||||
async addFromUris(uris: InitialImageUri[]) {
|
||||
for (const uriObj of uris) {
|
||||
this.add({
|
||||
mime: 'image/jpeg',
|
||||
height: uriObj.height,
|
||||
width: uriObj.width,
|
||||
path: uriObj.uri,
|
||||
altText: uriObj.altText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {Image as RNImage} from 'react-native-image-crop-picker'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import * as ImageManipulator from 'expo-image-manipulator'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {ActionCrop, FlipType, SaveFormat} from 'expo-image-manipulator'
|
||||
import {makeAutoObservable, runInAction} from 'mobx'
|
||||
import {Position} from 'react-avatar-editor'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {POST_IMG_MAX} from 'lib/constants'
|
||||
import {openCropper} from 'lib/media/picker'
|
||||
import {Dimensions} from 'lib/media/types'
|
||||
import {getDataUriSize} from 'lib/media/util'
|
||||
import {isIOS} from 'platform/detection'
|
||||
|
||||
export interface ImageManipulationAttributes {
|
||||
aspectRatio?: '4:3' | '1:1' | '3:4' | 'None'
|
||||
@@ -19,6 +20,13 @@ export interface ImageManipulationAttributes {
|
||||
flipVertical?: boolean
|
||||
}
|
||||
|
||||
export interface ImageInitOptions {
|
||||
path: string
|
||||
width: number
|
||||
height: number
|
||||
altText?: string
|
||||
}
|
||||
|
||||
const MAX_IMAGE_SIZE_IN_BYTES = 976560
|
||||
|
||||
export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
@@ -41,12 +49,15 @@ export class ImageModel implements Omit<RNImage, 'size'> {
|
||||
}
|
||||
prevAttributes: ImageManipulationAttributes = {}
|
||||
|
||||
constructor(image: Omit<RNImage, 'size'>) {
|
||||
constructor(image: ImageInitOptions) {
|
||||
makeAutoObservable(this)
|
||||
|
||||
this.path = image.path
|
||||
this.width = image.width
|
||||
this.height = image.height
|
||||
if (image.altText !== undefined) {
|
||||
this.setAltText(image.altText)
|
||||
}
|
||||
}
|
||||
|
||||
setRatio(aspectRatio: ImageManipulationAttributes['aspectRatio']) {
|
||||
|
||||
@@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
|
||||
if (feedDesc.startsWith('feedgen')) {
|
||||
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
|
||||
}
|
||||
if (feedDesc.startsWith('list')) {
|
||||
let feedTuners = []
|
||||
if (feedDesc.endsWith('|as_following')) {
|
||||
// Same as Following tuners below, copypaste for now.
|
||||
feedTuners.push(FeedTuner.removeOrphans)
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
feedTuners.push(FeedTuner.removeReposts)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideReplies) {
|
||||
feedTuners.push(FeedTuner.removeReplies)
|
||||
} else {
|
||||
feedTuners.push(
|
||||
FeedTuner.followedRepliesOnly({
|
||||
userDid: currentAccount?.did || '',
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (preferences?.feedViewPrefs.hideQuotePosts) {
|
||||
feedTuners.push(FeedTuner.removeQuotePosts)
|
||||
}
|
||||
feedTuners.push(FeedTuner.dedupThreads)
|
||||
}
|
||||
return feedTuners
|
||||
}
|
||||
if (feedDesc === 'following') {
|
||||
if (feedDesc === 'following' || feedDesc.startsWith('list')) {
|
||||
const feedTuners = [FeedTuner.removeOrphans]
|
||||
|
||||
if (preferences?.feedViewPrefs.hideReposts) {
|
||||
|
||||
@@ -175,19 +175,9 @@ async function fetchSubjects(
|
||||
}> {
|
||||
const postUris = new Set<string>()
|
||||
const packUris = new Set<string>()
|
||||
|
||||
const postUrisWithLikes = new Set<string>()
|
||||
const postUrisWithReposts = new Set<string>()
|
||||
|
||||
for (const notif of groupedNotifs) {
|
||||
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
|
||||
postUris.add(notif.subjectUri)
|
||||
if (notif.type === 'post-like') {
|
||||
postUrisWithLikes.add(notif.subjectUri)
|
||||
}
|
||||
if (notif.type === 'repost') {
|
||||
postUrisWithReposts.add(notif.subjectUri)
|
||||
}
|
||||
} else if (
|
||||
notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
|
||||
) {
|
||||
@@ -216,15 +206,6 @@ async function fetchSubjects(
|
||||
AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
postsMap.set(post.uri, post)
|
||||
|
||||
// HACK. In some cases, the appview appears to lag behind and returns empty counters.
|
||||
// To prevent scroll jump due to missing metrics, fill in 1 like/repost instead of 0.
|
||||
if (post.likeCount === 0 && postUrisWithLikes.has(post.uri)) {
|
||||
post.likeCount = 1
|
||||
}
|
||||
if (post.repostCount === 0 && postUrisWithReposts.has(post.uri)) {
|
||||
post.repostCount = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const pack of packsChunks.flat()) {
|
||||
|
||||
@@ -3,27 +3,16 @@ import zod from 'zod'
|
||||
import {BaseNux} from '#/state/queries/nuxs/types'
|
||||
|
||||
export enum Nux {
|
||||
One = 'one',
|
||||
Two = 'two',
|
||||
TenMillionDialog = 'TenMillionDialog',
|
||||
}
|
||||
|
||||
export const nuxNames = new Set(Object.values(Nux))
|
||||
|
||||
export type AppNux =
|
||||
| BaseNux<{
|
||||
id: Nux.One
|
||||
data: {
|
||||
likes: number
|
||||
}
|
||||
}>
|
||||
| BaseNux<{
|
||||
id: Nux.Two
|
||||
data: undefined
|
||||
}>
|
||||
export type AppNux = BaseNux<{
|
||||
id: Nux.TenMillionDialog
|
||||
data: undefined
|
||||
}>
|
||||
|
||||
export const NuxSchemas = {
|
||||
[Nux.One]: zod.object({
|
||||
likes: zod.number(),
|
||||
}),
|
||||
[Nux.Two]: undefined,
|
||||
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
|
||||
[Nux.TenMillionDialog]: undefined,
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ export function useUpsertNuxMutation() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
retry: 3,
|
||||
mutationFn: async (nux: AppNux) => {
|
||||
await agent.bskyAppUpsertNux(serializeAppNux(nux))
|
||||
// triggers a refetch
|
||||
@@ -72,6 +73,7 @@ export function useRemoveNuxsMutation() {
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
retry: 3,
|
||||
mutationFn: async (ids: string[]) => {
|
||||
await agent.bskyAppRemoveNuxs(ids)
|
||||
// triggers a refetch
|
||||
|
||||
@@ -4,6 +4,4 @@ export type Data = Record<string, unknown> | undefined
|
||||
|
||||
export type BaseNux<
|
||||
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
|
||||
> = T & {
|
||||
completed: boolean
|
||||
}
|
||||
> = Pick<AppBskyActorDefs.Nux, 'id' | 'completed' | 'expiresAt'> & T
|
||||
|
||||
@@ -51,7 +51,6 @@ type AuthorFilter =
|
||||
| 'posts_with_media'
|
||||
type FeedUri = string
|
||||
type ListUri = string
|
||||
type ListFilter = 'as_following' // Applies current Following settings. Currently client-side.
|
||||
|
||||
export type FeedDescriptor =
|
||||
| 'following'
|
||||
@@ -59,7 +58,6 @@ export type FeedDescriptor =
|
||||
| `feedgen|${FeedUri}`
|
||||
| `likes|${ActorDid}`
|
||||
| `list|${ListUri}`
|
||||
| `list|${ListUri}|${ListFilter}`
|
||||
export interface FeedParams {
|
||||
mergeFeedEnabled?: boolean
|
||||
mergeFeedSources?: string[]
|
||||
|
||||
@@ -408,10 +408,14 @@ export function* findAllPostsInQueryData(
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
// Check notifications first. If you have a post in notifications,
|
||||
// it's often due to a like or a repost, and we want to prioritize
|
||||
// a post object with >0 likes/reposts over a stale version with no
|
||||
// metrics in order to avoid a notification->post scroll jump.
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInNotifsQueryData(queryClient, uri)) {
|
||||
for (let post of findAllPostsInFeedQueryData(queryClient, uri)) {
|
||||
yield postViewToPlaceholderThread(post)
|
||||
}
|
||||
for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) {
|
||||
|
||||
@@ -36,7 +36,7 @@ export interface ComposerOpts {
|
||||
mention?: string // handle of user to mention
|
||||
openEmojiPicker?: (pos: DOMRect | undefined) => void
|
||||
text?: string
|
||||
imageUris?: {uri: string; width: number; height: number}[]
|
||||
imageUris?: {uri: string; width: number; height: number; altText?: string}[]
|
||||
}
|
||||
|
||||
type StateContext = ComposerOpts | undefined
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react'
|
||||
|
||||
import {useDialogStateContext} from '#/state/dialogs'
|
||||
import {useLightbox} from '#/state/lightbox'
|
||||
import {useModals} from '#/state/modals'
|
||||
import {useIsDrawerOpen} from '#/state/shell/drawer-open'
|
||||
import {useComposerControls} from './'
|
||||
|
||||
/**
|
||||
* Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2}
|
||||
*/
|
||||
function shouldIgnore(event: KeyboardEvent) {
|
||||
const target: any = event.target || event.srcElement
|
||||
if (!target) return false
|
||||
const {tagName} = target
|
||||
if (!tagName) return false
|
||||
const isInput =
|
||||
tagName === 'INPUT' &&
|
||||
![
|
||||
'checkbox',
|
||||
'radio',
|
||||
'range',
|
||||
'button',
|
||||
'file',
|
||||
'reset',
|
||||
'submit',
|
||||
'color',
|
||||
].includes(target.type)
|
||||
// ignore: isContentEditable === 'true', <input> and <textarea> when readOnly state is false, <select>
|
||||
if (
|
||||
target.isContentEditable ||
|
||||
((isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') &&
|
||||
!target.readOnly)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function useComposerKeyboardShortcut() {
|
||||
const {openComposer} = useComposerControls()
|
||||
const {openDialogs} = useDialogStateContext()
|
||||
const {isModalActive} = useModals()
|
||||
const {activeLightbox} = useLightbox()
|
||||
const isDrawerOpen = useIsDrawerOpen()
|
||||
|
||||
React.useEffect(() => {
|
||||
function handler(event: KeyboardEvent) {
|
||||
if (shouldIgnore(event)) return
|
||||
if (
|
||||
openDialogs.current.size > 0 ||
|
||||
isModalActive ||
|
||||
activeLightbox ||
|
||||
isDrawerOpen
|
||||
)
|
||||
return
|
||||
if (event.key === 'n' || event.key === 'N') {
|
||||
openComposer({})
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [openComposer, isModalActive, openDialogs, activeLightbox, isDrawerOpen])
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
/**
|
||||
* Device data that's specific to the device and does not vary based account
|
||||
*/
|
||||
export type Device = {}
|
||||
export type Device = {
|
||||
lastNuxDialog: string | undefined
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useModalControls} from '#/state/modals'
|
||||
import {useSession} from '#/state/session'
|
||||
import {BSKY_SERVICE} from 'lib/constants'
|
||||
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
|
||||
@@ -38,7 +40,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentAccount?.emailConfirmed) {
|
||||
if (
|
||||
currentAccount &&
|
||||
!currentAccount.emailConfirmed &&
|
||||
getHostnameFromUrl(currentAccount.service) ===
|
||||
getHostnameFromUrl(BSKY_SERVICE)
|
||||
) {
|
||||
Keyboard.dismiss()
|
||||
control.open()
|
||||
} else {
|
||||
@@ -71,12 +78,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
|
||||
}
|
||||
}
|
||||
}, [
|
||||
onSelectVideo,
|
||||
requestVideoAccessIfNeeded,
|
||||
currentAccount,
|
||||
control,
|
||||
setError,
|
||||
_,
|
||||
control,
|
||||
currentAccount?.emailConfirmed,
|
||||
onSelectVideo,
|
||||
])
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,8 +28,9 @@ export function SubtitleFilePicker({
|
||||
if (selectedFile) {
|
||||
if (
|
||||
selectedFile.type === 'text/vtt' ||
|
||||
(selectedFile.type === 'text/plain' &&
|
||||
selectedFile.name.endsWith('.vtt'))
|
||||
// HACK: sometimes the mime type is just straight-up missing
|
||||
// best we can do is check the file extension and hope for the best
|
||||
selectedFile.name.endsWith('.vtt')
|
||||
) {
|
||||
onSelectFile(selectedFile)
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-shadow */
|
||||
import React from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useVideoPlayer, VideoView} from 'expo-video'
|
||||
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
||||
|
||||
import {CompressedVideo} from '#/lib/media/video/types'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
@@ -22,15 +21,8 @@ export function VideoPreview({
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const playerRef = React.useRef<BlueskyVideoView>(null)
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const player = useVideoPlayer(video.uri, player => {
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
if (!autoplayDisabled) {
|
||||
player.play()
|
||||
}
|
||||
})
|
||||
|
||||
let aspectRatio = asset.width / asset.height
|
||||
|
||||
if (isNaN(aspectRatio)) {
|
||||
@@ -50,12 +42,12 @@ export function VideoPreview({
|
||||
t.atoms.border_contrast_low,
|
||||
{backgroundColor: 'black'},
|
||||
]}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={a.flex_1}
|
||||
allowsPictureInPicture={false}
|
||||
nativeControls={false}
|
||||
contentFit="contain"
|
||||
<BlueskyVideoView
|
||||
url={video.uri}
|
||||
autoplay={!autoplayDisabled}
|
||||
beginMuted={true}
|
||||
forceTakeover={true}
|
||||
ref={playerRef}
|
||||
/>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
{autoplayDisabled && (
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -8,11 +17,11 @@ import {useSession} from '#/state/session'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
// import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
||||
import {Link} from '#/components/Link'
|
||||
import {useKawaiiMode} from '../../../state/preferences/kawaii'
|
||||
import {HomeHeaderLayoutMobile} from './HomeHeaderLayoutMobile'
|
||||
|
||||
export function HomeHeaderLayout(props: {
|
||||
@@ -40,7 +49,42 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||
const {hasSession} = useSession()
|
||||
const {_} = useLingui()
|
||||
|
||||
const kawaii = useKawaiiMode()
|
||||
// TEMPORARY - REMOVE AFTER MILLY
|
||||
// This will just cause the icon to shake a bit when the user first opens the app, drawing attention to the celebration
|
||||
// 🎉
|
||||
const rotate = useSharedValue(0)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Run this a single time on app mount.
|
||||
React.useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
// Waits 1500ms, then rotates 10 degrees with a spring animation. Repeats once.
|
||||
rotate.value = withDelay(
|
||||
1000,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(10, {duration: 100}),
|
||||
withSpring(0, {
|
||||
mass: 1,
|
||||
damping: 1,
|
||||
stiffness: 200,
|
||||
overshootClamping: false,
|
||||
}),
|
||||
),
|
||||
2,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}, [rotate, reducedMotion])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `${rotate.value}deg`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -57,21 +101,30 @@ function HomeHeaderLayoutDesktopAndTablet({
|
||||
t.atoms.bg,
|
||||
t.atoms.border_contrast_low,
|
||||
styles.bar,
|
||||
kawaii && {paddingTop: 22, paddingBottom: 16},
|
||||
]}>
|
||||
<View
|
||||
<Animated.View
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.pt_lg,
|
||||
a.m_auto,
|
||||
kawaii && {paddingTop: 4, paddingBottom: 0},
|
||||
{
|
||||
width: kawaii ? 84 : 28,
|
||||
width: 28,
|
||||
},
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Logo width={kawaii ? 60 : 28} />
|
||||
</View>
|
||||
<Trigger>
|
||||
{ctx => (
|
||||
<Icon
|
||||
width={28}
|
||||
style={{
|
||||
opacity: ctx.hovered || ctx.pressed ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Trigger>
|
||||
{/* <Logo width={28} /> */}
|
||||
</Animated.View>
|
||||
|
||||
<Link
|
||||
to="/feeds"
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import React from 'react'
|
||||
import {StyleSheet, TouchableOpacity, View} from 'react-native'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useReducedMotion,
|
||||
useSharedValue,
|
||||
withDelay,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withSpring,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
@@ -11,10 +20,11 @@ import {HITSLOP_10} from 'lib/constants'
|
||||
import {useMinimalShellHeaderTransform} from 'lib/hooks/useMinimalShellTransform'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {isWeb} from 'platform/detection'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
// import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms} from '#/alf'
|
||||
import {useTheme} from '#/alf'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Icon, Trigger} from '#/components/dialogs/nuxs/TenMillion/Trigger'
|
||||
import {ColorPalette_Stroke2_Corner0_Rounded as ColorPalette} from '#/components/icons/ColorPalette'
|
||||
import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag'
|
||||
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||
@@ -39,6 +49,43 @@ export function HomeHeaderLayoutMobile({
|
||||
setDrawerOpen(true)
|
||||
}, [setDrawerOpen])
|
||||
|
||||
// TEMPORARY - REMOVE AFTER MILLY
|
||||
// This will just cause the icon to shake a bit when the user first opens the app, drawing attention to the celebration
|
||||
// 🎉
|
||||
const rotate = useSharedValue(0)
|
||||
const reducedMotion = useReducedMotion()
|
||||
|
||||
// Run this a single time on app mount.
|
||||
React.useEffect(() => {
|
||||
if (reducedMotion) return
|
||||
|
||||
// Waits 1500ms, then rotates 10 degrees with a spring animation. Repeats once.
|
||||
rotate.value = withDelay(
|
||||
1000,
|
||||
withRepeat(
|
||||
withSequence(
|
||||
withTiming(10, {duration: 100}),
|
||||
withSpring(0, {
|
||||
mass: 1,
|
||||
damping: 1,
|
||||
stiffness: 200,
|
||||
overshootClamping: false,
|
||||
}),
|
||||
),
|
||||
2,
|
||||
false,
|
||||
),
|
||||
)
|
||||
}, [rotate, reducedMotion])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
rotateZ: `${rotate.value}deg`,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
|
||||
@@ -59,9 +106,19 @@ export function HomeHeaderLayoutMobile({
|
||||
<Menu size="lg" fill={t.atoms.text_contrast_medium.color} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View>
|
||||
<Logo width={30} />
|
||||
</View>
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Trigger>
|
||||
{ctx => (
|
||||
<Icon
|
||||
width={28}
|
||||
style={{
|
||||
opacity: ctx.pressed ? 0.8 : 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Trigger>
|
||||
{/* <Logo width={30} /> */}
|
||||
</Animated.View>
|
||||
<View
|
||||
style={[
|
||||
atoms.flex_row,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
@@ -26,6 +28,7 @@ function keyExtractor(item: GetLikes.Like) {
|
||||
}
|
||||
|
||||
export function PostLikedBy({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
@@ -78,6 +81,12 @@ export function PostLikedBy({uri}: {uri: string}) {
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingLikes}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyTitle={_(msg`No likes yet`)}
|
||||
emptyMessage={_(
|
||||
msg`Nobody has liked this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -97,11 +97,17 @@ export function PostQuotes({uri}: {uri: string}) {
|
||||
}
|
||||
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
|
||||
|
||||
if (isLoadingUri || isLoadingQuotes || isError) {
|
||||
if (quotes.length < 1) {
|
||||
return (
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingQuotes}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyTitle={_(msg`No quotes yet`)}
|
||||
emptyMessage={_(
|
||||
msg`Nobody has quoted this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, {useCallback, useMemo, useState} from 'react'
|
||||
import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {cleanError} from '#/lib/strings/errors'
|
||||
import {logger} from '#/logger'
|
||||
@@ -19,6 +21,7 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) {
|
||||
}
|
||||
|
||||
export function PostRepostedBy({uri}: {uri: string}) {
|
||||
const {_} = useLingui()
|
||||
const initialNumToRender = useInitialNumToRender()
|
||||
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
@@ -71,6 +74,12 @@ export function PostRepostedBy({uri}: {uri: string}) {
|
||||
<ListMaybePlaceholder
|
||||
isLoading={isLoadingUri || isLoadingRepostedBy}
|
||||
isError={isError}
|
||||
emptyType="results"
|
||||
emptyTitle={_(msg`No reposts yet`)}
|
||||
emptyMessage={_(
|
||||
msg`Nobody has reposted this yet. Maybe you should be the first!`,
|
||||
)}
|
||||
errorMessage={cleanError(resolveError || error)}
|
||||
sideBorders={false}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, {memo} from 'react'
|
||||
import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
|
||||
import {runOnJS, useSharedValue} from 'react-native-reanimated'
|
||||
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
|
||||
|
||||
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
@@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext'
|
||||
import {useDedupe} from 'lib/hooks/useDedupe'
|
||||
import {addStyle} from 'lib/styles'
|
||||
import {isIOS} from 'platform/detection'
|
||||
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
|
||||
import {FlatList_INTERNAL} from './Views'
|
||||
|
||||
export type ListMethods = FlatList_INTERNAL
|
||||
@@ -69,7 +69,7 @@ function ListImpl<ItemT>(
|
||||
onBeginDragFromContext?.(e, ctx)
|
||||
},
|
||||
onEndDrag(e, ctx) {
|
||||
runOnJS(updateActiveViewAsync)()
|
||||
runOnJS(updateActiveVideoViewAsync)()
|
||||
onEndDragFromContext?.(e, ctx)
|
||||
},
|
||||
onScroll(e, ctx) {
|
||||
@@ -84,13 +84,13 @@ function ListImpl<ItemT>(
|
||||
}
|
||||
|
||||
if (isIOS) {
|
||||
runOnJS(dedupe)(updateActiveViewAsync)
|
||||
runOnJS(dedupe)(updateActiveVideoViewAsync)
|
||||
}
|
||||
},
|
||||
// Note: adding onMomentumBegin here makes simulator scroll
|
||||
// lag on Android. So either don't add it, or figure out why.
|
||||
onMomentumEnd(e, ctx) {
|
||||
runOnJS(updateActiveViewAsync)()
|
||||
runOnJS(updateActiveVideoViewAsync)()
|
||||
onMomentumEndFromContext?.(e, ctx)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
|
||||
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
|
||||
import {Link} from '#/components/Link'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Menu from '#/components/Menu'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
|
||||
@@ -43,6 +44,7 @@ interface BaseUserAvatarProps {
|
||||
interface UserAvatarProps extends BaseUserAvatarProps {
|
||||
moderation?: ModerationUI
|
||||
usePlainRNImage?: boolean
|
||||
onLoad?: () => void
|
||||
}
|
||||
|
||||
interface EditableUserAvatarProps extends BaseUserAvatarProps {
|
||||
@@ -174,6 +176,7 @@ let UserAvatar = ({
|
||||
avatar,
|
||||
moderation,
|
||||
usePlainRNImage = false,
|
||||
onLoad,
|
||||
}: UserAvatarProps): React.ReactNode => {
|
||||
const pal = usePalette('default')
|
||||
const backgroundColor = pal.colors.backgroundLight
|
||||
@@ -224,6 +227,7 @@ let UserAvatar = ({
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
) : (
|
||||
<HighPriorityImage
|
||||
@@ -234,8 +238,16 @@ let UserAvatar = ({
|
||||
uri: hackModifyThumbnailPath(avatar, size < 90),
|
||||
}}
|
||||
blurRadius={moderation?.blur ? BLUR_AMOUNT : 0}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder
|
||||
style={[
|
||||
{
|
||||
borderRadius: aviStyle.borderRadius,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{alert}
|
||||
</View>
|
||||
) : (
|
||||
|
||||
@@ -10,7 +10,8 @@ import {Dimensions} from '#/lib/media/types'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
|
||||
import {Crop_Stroke2_Corner0_Rounded as Crop} from '#/components/icons/Crop'
|
||||
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function useImageAspectRatio({
|
||||
@@ -140,6 +141,7 @@ export function AutoSizedImage({
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
|
||||
{(hasAlt || isCropped) && !hideBadge ? (
|
||||
<View
|
||||
@@ -147,33 +149,59 @@ export function AutoSizedImage({
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
padding: 3,
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
opacity: 0.8,
|
||||
gap: 3,
|
||||
},
|
||||
largeAlt && [
|
||||
{
|
||||
gap: 4,
|
||||
padding: 5,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
{isCropped && (
|
||||
<Crop
|
||||
fill={t.atoms.text_contrast_high.color}
|
||||
width={largeAlt ? 18 : 12}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAlt && [
|
||||
{
|
||||
padding: 5,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<Fullscreen
|
||||
fill={t.atoms.text_contrast_high.color}
|
||||
width={largeAlt ? 18 : 12}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{hasAlt && (
|
||||
<Text style={[a.font_heavy, largeAlt ? a.text_xs : {fontSize: 8}]}>
|
||||
ALT
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
a.justify_center,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
padding: 3,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAlt && [
|
||||
{
|
||||
padding: 5,
|
||||
},
|
||||
],
|
||||
]}>
|
||||
<Text
|
||||
style={[a.font_heavy, largeAlt ? a.text_xs : {fontSize: 8}]}>
|
||||
ALT
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {PostEmbedViewContext} from '#/view/com/util/post-embeds/types'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
type EventFunction = (index: number) => void
|
||||
@@ -46,7 +47,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
|
||||
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.rounded_xs,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
imageStyle,
|
||||
@@ -62,6 +63,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</Pressable>
|
||||
{hasAlt && !hideBadges ? (
|
||||
<View
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react'
|
||||
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
||||
|
||||
import {isAndroid, isNative} from '#/platform/detection'
|
||||
|
||||
const Context = React.createContext<{
|
||||
activeSource: string
|
||||
activeViewId: string | undefined
|
||||
setActiveSource: (src: string | null, viewId: string | null) => void
|
||||
player: VideoPlayer
|
||||
} | null>(null)
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
if (!isNative) {
|
||||
throw new Error('ActiveVideoProvider may only be used on native.')
|
||||
}
|
||||
|
||||
const [activeSource, setActiveSource] = React.useState('')
|
||||
const [activeViewId, setActiveViewId] = React.useState<string>()
|
||||
|
||||
const player = useVideoPlayer(activeSource, p => {
|
||||
p.muted = true
|
||||
p.loop = true
|
||||
// We want to immediately call `play` so we get the loading state
|
||||
p.play()
|
||||
})
|
||||
|
||||
const setActiveSourceOuter = (src: string | null, viewId: string | null) => {
|
||||
// HACK
|
||||
// expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually
|
||||
// unregister that player to let the new screen register it. This is only a problem on Android, so we only need to
|
||||
// apply it there.
|
||||
if (src === activeSource && isAndroid) {
|
||||
setActiveSource('')
|
||||
setTimeout(() => {
|
||||
setActiveSource(src ? src : '')
|
||||
}, 100)
|
||||
} else {
|
||||
setActiveSource(src ? src : '')
|
||||
}
|
||||
setActiveViewId(viewId ? viewId : '')
|
||||
}
|
||||
|
||||
return (
|
||||
<Context.Provider
|
||||
value={{
|
||||
activeSource,
|
||||
setActiveSource: setActiveSourceOuter,
|
||||
activeViewId,
|
||||
player,
|
||||
}}>
|
||||
{children}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useActiveVideoNative() {
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useActiveVideoNative must be used within a ActiveVideoNativeProvider',
|
||||
)
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const Context = React.createContext<{
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
if (!isWeb) {
|
||||
throw new Error('ActiveVideoWebContext may onl be used on web.')
|
||||
throw new Error('ActiveVideoWebContext may only be used on web.')
|
||||
}
|
||||
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||
|
||||
@@ -5,19 +5,21 @@ import {
|
||||
LayoutChangeEvent,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {Image, ImageLoadEventData} from 'expo-image'
|
||||
import {AppBskyEmbedExternal} from '@atproto/api'
|
||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {EmbedPlayerParams, getGifDims} from '#/lib/strings/embed-player'
|
||||
import {isIOS, isNative, isWeb} from '#/platform/detection'
|
||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
|
||||
export function ExternalGifEmbed({
|
||||
link,
|
||||
@@ -26,6 +28,7 @@ export function ExternalGifEmbed({
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const externalEmbedsPrefs = useExternalEmbedsPrefs()
|
||||
|
||||
const {_} = useLingui()
|
||||
@@ -113,26 +116,19 @@ export function ExternalGifEmbed({
|
||||
<Pressable
|
||||
style={[
|
||||
{height: imageDims.height},
|
||||
styles.topRadius,
|
||||
styles.gifContainer,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
onPress={onPlayPress}
|
||||
onLayout={onLayout}
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(msg`Plays the GIF`)}
|
||||
accessibilityLabel={_(msg`Play ${link.title}`)}>
|
||||
{(!isPrefetched || !isAnimating) && ( // If we have not loaded or are not animating, show the overlay
|
||||
<View style={[styles.layer, styles.overlayLayer]}>
|
||||
<View style={[styles.overlayContainer, styles.topRadius]}>
|
||||
{!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
|
||||
<FontAwesomeIcon icon="play" size={42} color="white" />
|
||||
) : (
|
||||
// Activity indicator while gif loads
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<Image
|
||||
source={{
|
||||
uri:
|
||||
@@ -150,6 +146,35 @@ export function ExternalGifEmbed({
|
||||
accessibilityHint={link.title}
|
||||
cachePolicy={isIOS ? 'disk' : 'memory-disk'} // cant control playback with memory-disk on ios
|
||||
/>
|
||||
|
||||
{(!isPrefetched || !isAnimating) && (
|
||||
<Fill style={[a.align_center, a.justify_center]}>
|
||||
<Fill
|
||||
style={[
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{!isAnimating || !isPlayerActive ? ( // Play button when not animating or not active
|
||||
<PlayButtonIcon />
|
||||
) : (
|
||||
// Activity indicator while gif loads
|
||||
<ActivityIndicator size="large" color="white" />
|
||||
)}
|
||||
</Fill>
|
||||
)}
|
||||
<MediaInsetBorder
|
||||
opaque
|
||||
style={[
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Pressable>
|
||||
</>
|
||||
)
|
||||
@@ -171,7 +196,6 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
overlayLayer: {
|
||||
zIndex: 2,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {ExternalGifEmbed} from 'view/com/util/post-embeds/ExternalGifEmbed'
|
||||
import {ExternalPlayer} from 'view/com/util/post-embeds/ExternalPlayerEmbed'
|
||||
import {GifEmbed} from 'view/com/util/post-embeds/GifEmbed'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Text} from '../text/Text'
|
||||
|
||||
export const ExternalLinkEmbed = ({
|
||||
@@ -36,6 +37,7 @@ export const ExternalLinkEmbed = ({
|
||||
}) => {
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
const t = useTheme()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
const externalEmbedPrefs = useExternalEmbedsPrefs()
|
||||
|
||||
@@ -57,22 +59,33 @@ export const ExternalLinkEmbed = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}>
|
||||
<View style={[a.flex_col, a.rounded_sm]}>
|
||||
<LinkWrapper link={link} onOpen={onOpen} style={style}>
|
||||
{imageUri && !embedPlayerParams ? (
|
||||
<Image
|
||||
style={{
|
||||
aspectRatio: 1.91,
|
||||
borderTopRightRadius: 6,
|
||||
borderTopLeftRadius: 6,
|
||||
}}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityLabel={starterPackParsed ? link.title : undefined}
|
||||
accessibilityHint={
|
||||
starterPackParsed ? _(msg`Navigate to starter pack`) : undefined
|
||||
}
|
||||
/>
|
||||
<View>
|
||||
<Image
|
||||
style={{
|
||||
aspectRatio: 1.91,
|
||||
borderTopRightRadius: 8,
|
||||
borderTopLeftRadius: 8,
|
||||
}}
|
||||
source={{uri: imageUri}}
|
||||
accessibilityIgnoresInvertColors
|
||||
accessibilityLabel={starterPackParsed ? link.title : undefined}
|
||||
accessibilityHint={
|
||||
starterPackParsed ? _(msg`Navigate to starter pack`) : undefined
|
||||
}
|
||||
/>
|
||||
<MediaInsetBorder
|
||||
opaque
|
||||
style={[
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
) : undefined}
|
||||
{embedPlayerParams?.isGif ? (
|
||||
<ExternalGifEmbed link={link} params={embedPlayerParams} />
|
||||
@@ -81,11 +94,18 @@ export const ExternalLinkEmbed = ({
|
||||
) : undefined}
|
||||
<View
|
||||
style={[
|
||||
a.border_b,
|
||||
a.border_l,
|
||||
a.border_r,
|
||||
a.flex_1,
|
||||
a.py_sm,
|
||||
t.atoms.border_contrast_low,
|
||||
{
|
||||
borderBottomRightRadius: 8,
|
||||
borderBottomLeftRadius: 8,
|
||||
paddingHorizontal: isMobile ? 10 : 14,
|
||||
},
|
||||
!imageUri && !embedPlayerParams && [a.border, a.rounded_sm],
|
||||
]}>
|
||||
<Text
|
||||
type="sm"
|
||||
@@ -124,8 +144,6 @@ function LinkWrapper({
|
||||
style?: StyleProp<ViewStyle>
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
const onShareExternal = useCallback(() => {
|
||||
if (link.uri && isNative) {
|
||||
shareUrl(link.uri)
|
||||
@@ -137,14 +155,7 @@ function LinkWrapper({
|
||||
asAnchor
|
||||
anchorNoUnderline
|
||||
href={link.uri}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.border,
|
||||
a.rounded_sm,
|
||||
t.atoms.border_contrast_medium,
|
||||
style,
|
||||
]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
style={[a.flex_1, a.rounded_sm, style]}
|
||||
onBeforePress={onOpen}
|
||||
onLongPress={onShareExternal}>
|
||||
{children}
|
||||
|
||||
@@ -25,9 +25,11 @@ import {NavigationProp} from '#/lib/routes/types'
|
||||
import {EmbedPlayerParams, getPlayerAspect} from '#/lib/strings/embed-player'
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {useExternalEmbedsPrefs} from '#/state/preferences'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {EmbedConsentDialog} from '#/components/dialogs/EmbedConsent'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {EventStopper} from '../EventStopper'
|
||||
|
||||
@@ -106,6 +108,16 @@ function Player({
|
||||
style={styles.webview}
|
||||
setSupportMultipleWindows={false} // Prevent any redirects from opening a new window (ads)
|
||||
/>
|
||||
|
||||
<MediaInsetBorder
|
||||
opaque
|
||||
style={[
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EventStopper>
|
||||
)
|
||||
}
|
||||
@@ -118,6 +130,7 @@ export function ExternalPlayer({
|
||||
link: AppBskyEmbedExternal.ViewExternal
|
||||
params: EmbedPlayerParams
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const navigation = useNavigation<NavigationProp>()
|
||||
const insets = useSafeAreaInsets()
|
||||
const windowDims = useWindowDimensions()
|
||||
@@ -211,14 +224,69 @@ export function ExternalPlayer({
|
||||
onAccept={onAcceptConsent}
|
||||
/>
|
||||
|
||||
<Animated.View ref={viewRef} collapsable={false} style={[aspect]}>
|
||||
{link.thumb && (!isPlayerActive || isLoading) && (
|
||||
<Image
|
||||
style={[a.flex_1, styles.topRadius]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
<Animated.View
|
||||
ref={viewRef}
|
||||
collapsable={false}
|
||||
style={[
|
||||
aspect,
|
||||
a.rounded_sm,
|
||||
a.overflow_hidden,
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}>
|
||||
{link.thumb && (!isPlayerActive || isLoading) ? (
|
||||
<>
|
||||
<Image
|
||||
style={[a.flex_1, styles.topRadius]}
|
||||
source={{uri: link.thumb}}
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<Fill
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<MediaInsetBorder
|
||||
opaque
|
||||
style={[
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Fill
|
||||
style={[
|
||||
a.rounded_sm,
|
||||
{
|
||||
backgroundColor:
|
||||
t.name === 'light' ? t.palette.contrast_975 : 'black',
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder
|
||||
opaque
|
||||
style={[
|
||||
{
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PlaceholderOverlay
|
||||
isLoading={isLoading}
|
||||
isPlayerActive={isPlayerActive}
|
||||
@@ -236,14 +304,13 @@ export function ExternalPlayer({
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topRadius: {
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
},
|
||||
overlayContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
overlayLayer: {
|
||||
zIndex: 2,
|
||||
@@ -252,6 +319,8 @@ const styles = StyleSheet.create({
|
||||
zIndex: 3,
|
||||
},
|
||||
webview: {
|
||||
borderTopRightRadius: 8,
|
||||
borderTopLeftRadius: 8,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
gifContainer: {
|
||||
|
||||
@@ -18,7 +18,9 @@ import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {EmbedPlayerParams} from 'lib/strings/embed-player'
|
||||
import {useAutoplayDisabled} from 'state/preferences'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Fill} from '#/components/Fill'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import * as Prompt from '#/components/Prompt'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
@@ -56,8 +58,6 @@ function PlaybackControls({
|
||||
zIndex: 2,
|
||||
backgroundColor: !isLoaded
|
||||
? t.atoms.bg_contrast_25.backgroundColor
|
||||
: !isPlaying
|
||||
? 'rgba(0, 0, 0, 0.3)'
|
||||
: undefined,
|
||||
},
|
||||
]}
|
||||
@@ -86,6 +86,7 @@ export function GifEmbed({
|
||||
hideAlt?: boolean
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
|
||||
@@ -138,6 +139,17 @@ export function GifEmbed({
|
||||
accessibilityHint={_(msg`Animated GIF`)}
|
||||
accessibilityLabel={parsedAlt.alt}
|
||||
/>
|
||||
{!playerState.isPlaying && (
|
||||
<Fill
|
||||
style={[
|
||||
t.name === 'light' ? t.atoms.bg_contrast_975 : t.atoms.bg,
|
||||
{
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<MediaInsetBorder />
|
||||
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -33,7 +33,7 @@ import {InfoCircleIcon} from 'lib/icons'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {ComposerOptsQuote} from 'state/shell/composer'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {ContentHider} from '../../../../components/moderation/ContentHider'
|
||||
import {PostAlerts} from '../../../../components/moderation/PostAlerts'
|
||||
@@ -56,6 +56,7 @@ export function MaybeQuoteEmbed({
|
||||
allowNestedQuotes?: boolean
|
||||
viewContext?: QuoteEmbedViewContext
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const {currentAccount} = useSession()
|
||||
if (
|
||||
@@ -75,7 +76,8 @@ export function MaybeQuoteEmbed({
|
||||
)
|
||||
} else if (AppBskyEmbedRecord.isViewBlocked(embed.record)) {
|
||||
return (
|
||||
<View style={[styles.errorContainer, pal.borderDark]}>
|
||||
<View
|
||||
style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
|
||||
<InfoCircleIcon size={18} style={pal.text} />
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Blocked</Trans>
|
||||
@@ -84,7 +86,8 @@ export function MaybeQuoteEmbed({
|
||||
)
|
||||
} else if (AppBskyEmbedRecord.isViewNotFound(embed.record)) {
|
||||
return (
|
||||
<View style={[styles.errorContainer, pal.borderDark]}>
|
||||
<View
|
||||
style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
|
||||
<InfoCircleIcon size={18} style={pal.text} />
|
||||
<Text type="lg" style={pal.text}>
|
||||
<Trans>Deleted</Trans>
|
||||
@@ -96,7 +99,8 @@ export function MaybeQuoteEmbed({
|
||||
? embed.record.uri.includes(currentAccount.did)
|
||||
: false
|
||||
return (
|
||||
<View style={[styles.errorContainer, pal.borderDark]}>
|
||||
<View
|
||||
style={[styles.errorContainer, a.border, t.atoms.border_contrast_low]}>
|
||||
<InfoCircleIcon size={18} style={pal.text} />
|
||||
<Text type="lg" style={pal.text}>
|
||||
{isViewerOwner ? (
|
||||
@@ -169,6 +173,7 @@ export function QuoteEmbed({
|
||||
allowNestedQuotes?: boolean
|
||||
viewContext?: QuoteEmbedViewContext
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const pal = usePalette('default')
|
||||
const itemUrip = new AtUri(quote.uri)
|
||||
@@ -214,7 +219,7 @@ export function QuoteEmbed({
|
||||
return (
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[styles.container, pal.borderDark, style]}
|
||||
style={[styles.container, a.border, t.atoms.border_contrast_low, style]}
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
<Link
|
||||
hoverStyle={{borderColor: pal.colors.borderLinkHover}}
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import React, {useCallback, useEffect, useId, useState} from 'react'
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {ImageBackground} from 'expo-image'
|
||||
import {PlayerError, VideoPlayerStatus} from 'expo-video'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useAutoplayDisabled} from 'state/preferences'
|
||||
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useThrottledValue} from '#/components/hooks/useThrottledValue'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
interface Props {
|
||||
@@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) {
|
||||
|
||||
function InnerWrapper({embed}: Props) {
|
||||
const {_} = useLingui()
|
||||
const {activeSource, activeViewId, setActiveSource, player} =
|
||||
useActiveVideoNative()
|
||||
const viewId = useId()
|
||||
const ref = React.useRef<{togglePlayback: () => void}>(null)
|
||||
|
||||
const [playerStatus, setPlayerStatus] = useState<
|
||||
VideoPlayerStatus | 'paused'
|
||||
>('paused')
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const disableAutoplay = useAutoplayDisabled() || isWithinMessage
|
||||
const isActive = embed.playlist === activeSource && activeViewId === viewId
|
||||
// There are some different loading states that we should pay attention to and show a spinner for
|
||||
const isLoading =
|
||||
isActive &&
|
||||
(playerStatus === 'waitingToPlayAtSpecifiedRate' ||
|
||||
playerStatus === 'loading')
|
||||
// This happens whenever the visibility view decides that another video should start playing
|
||||
const showOverlay = !isActive || isLoading || playerStatus === 'paused'
|
||||
const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
|
||||
'pending',
|
||||
)
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [isActive, setIsActive] = React.useState(false)
|
||||
const showSpinner = useThrottledValue(isActive && isLoading, 100)
|
||||
|
||||
// send error up to error boundary
|
||||
const [error, setError] = useState<Error | PlayerError | null>(null)
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
const showOverlay =
|
||||
!isActive ||
|
||||
isLoading ||
|
||||
(status === 'paused' && !isActive) ||
|
||||
status === 'pending'
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
const timeSub = player.addListener(
|
||||
'timeRemainingChange',
|
||||
secondsRemaining => {
|
||||
setTimeRemaining(secondsRemaining)
|
||||
},
|
||||
)
|
||||
const statusSub = player.addListener(
|
||||
'statusChange',
|
||||
(status, oldStatus, playerError) => {
|
||||
setPlayerStatus(status)
|
||||
if (status === 'error') {
|
||||
setError(playerError ?? new Error('Unknown player error'))
|
||||
}
|
||||
if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') {
|
||||
player.play()
|
||||
}
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
volumeSub.remove()
|
||||
timeSub.remove()
|
||||
statusSub.remove()
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (!isActive && status !== 'pending') {
|
||||
setStatus('pending')
|
||||
}
|
||||
}, [player, isActive, disableAutoplay])
|
||||
|
||||
// The source might already be active (for example, if you are scrolling a list of quotes and its all the same
|
||||
// video). In those cases, just start playing. Otherwise, setting the active source will result in the video
|
||||
// start playback immediately
|
||||
const startPlaying = (ignoreAutoplayPreference: boolean) => {
|
||||
if (disableAutoplay && !ignoreAutoplayPreference) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
player.play()
|
||||
} else {
|
||||
setActiveSource(embed.playlist, viewId)
|
||||
}
|
||||
}
|
||||
|
||||
const onVisibilityStatusChange = (isVisible: boolean) => {
|
||||
// When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change
|
||||
// events
|
||||
if (isFullscreen) {
|
||||
return
|
||||
}
|
||||
if (isVisible) {
|
||||
startPlaying(false)
|
||||
} else {
|
||||
// Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted
|
||||
// until it gets replaced by another video
|
||||
if (disableAutoplay) {
|
||||
setActiveSource(null, null)
|
||||
} else {
|
||||
player.muted = true
|
||||
if (player.playing) {
|
||||
player.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [isActive, status])
|
||||
|
||||
return (
|
||||
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}>
|
||||
{isActive ? (
|
||||
<VideoEmbedInnerNative
|
||||
embed={embed}
|
||||
timeRemaining={timeRemaining}
|
||||
isMuted={isMuted}
|
||||
isFullscreen={isFullscreen}
|
||||
setIsFullscreen={setIsFullscreen}
|
||||
/>
|
||||
) : null}
|
||||
<>
|
||||
<VideoEmbedInnerNative
|
||||
embed={embed}
|
||||
setStatus={setStatus}
|
||||
setIsLoading={setIsLoading}
|
||||
setIsActive={setIsActive}
|
||||
ref={ref}
|
||||
/>
|
||||
<ImageBackground
|
||||
source={{uri: embed.thumbnail}}
|
||||
accessibilityIgnoresInvertColors
|
||||
@@ -185,17 +104,18 @@ function InnerWrapper({embed}: Props) {
|
||||
>
|
||||
<Button
|
||||
style={[a.flex_1, a.align_center, a.justify_center]}
|
||||
onPress={() => startPlaying(true)}
|
||||
onPress={() => {
|
||||
ref.current?.togglePlayback()
|
||||
}}
|
||||
label={_(msg`Play video`)}
|
||||
color="secondary">
|
||||
{isLoading ? (
|
||||
{showSpinner ? (
|
||||
<View
|
||||
style={[
|
||||
a.rounded_full,
|
||||
a.p_xs,
|
||||
a.align_center,
|
||||
a.justify_center,
|
||||
{backgroundColor: 'rgba(0,0,0,0.5)'},
|
||||
]}>
|
||||
<Loader size="2xl" style={{color: 'white'}} />
|
||||
</View>
|
||||
@@ -204,7 +124,7 @@ function InnerWrapper({embed}: Props) {
|
||||
)}
|
||||
</Button>
|
||||
</ImageBackground>
|
||||
</VisibilityView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import {StyleProp, ViewStyle} from 'react-native'
|
||||
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
||||
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
@@ -8,7 +9,13 @@ import {Text} from '#/components/Typography'
|
||||
* Absolutely positioned time indicator showing how many seconds are remaining
|
||||
* Time is in seconds
|
||||
*/
|
||||
export function TimeIndicator({time}: {time: number}) {
|
||||
export function TimeIndicator({
|
||||
time,
|
||||
style,
|
||||
}: {
|
||||
time: number
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (isNaN(time)) {
|
||||
@@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) {
|
||||
<Animated.View
|
||||
entering={native(FadeInDown.duration(300))}
|
||||
exiting={native(FadeOutDown.duration(500))}
|
||||
pointerEvents="none"
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 6,
|
||||
bottom: 6,
|
||||
minHeight: 21,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
a.absolute,
|
||||
a.justify_center,
|
||||
style,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
|
||||
@@ -1,134 +1,125 @@
|
||||
import React, {useCallback, useRef} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import React, {useRef} from 'react'
|
||||
import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
|
||||
import Animated, {FadeInDown} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {BlueskyVideoView} from '@haileyok/bluesky-video'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isAndroid} from 'platform/detection'
|
||||
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import {useAutoplayDisabled} from '#/state/preferences'
|
||||
import {useVideoMuteState} from 'view/com/util/post-embeds/VideoVolumeContext'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative({
|
||||
embed,
|
||||
isFullscreen,
|
||||
setIsFullscreen,
|
||||
isMuted,
|
||||
timeRemaining,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
isFullscreen: boolean
|
||||
setIsFullscreen: (isFullscreen: boolean) => void
|
||||
timeRemaining: number
|
||||
isMuted: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {player} = useActiveVideoNative()
|
||||
const ref = useRef<VideoView>(null)
|
||||
export const VideoEmbedInnerNative = React.forwardRef(
|
||||
function VideoEmbedInnerNative(
|
||||
{
|
||||
embed,
|
||||
setStatus,
|
||||
setIsLoading,
|
||||
setIsActive,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
setStatus: (status: 'playing' | 'paused') => void
|
||||
setIsLoading: (isLoading: boolean) => void
|
||||
setIsActive: (isActive: boolean) => void
|
||||
},
|
||||
ref: React.Ref<{togglePlayback: () => void}>,
|
||||
) {
|
||||
const {_} = useLingui()
|
||||
const videoRef = useRef<BlueskyVideoView>(null)
|
||||
const autoplayDisabled = useAutoplayDisabled()
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const [muted, setMuted] = useVideoMuteState()
|
||||
|
||||
const enterFullscreen = useCallback(() => {
|
||||
ref.current?.enterFullscreen()
|
||||
}, [])
|
||||
const [isPlaying, setIsPlaying] = React.useState(false)
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
const [error, setError] = React.useState<string>()
|
||||
|
||||
let aspectRatio = 16 / 9
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
togglePlayback: () => {
|
||||
videoRef.current?.togglePlayback()
|
||||
},
|
||||
}))
|
||||
|
||||
if (embed.aspectRatio) {
|
||||
const {width, height} = embed.aspectRatio
|
||||
aspectRatio = width / height
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
}
|
||||
if (error) {
|
||||
throw new Error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={[a.flex_1, a.rounded_sm]}
|
||||
contentFit="cover"
|
||||
nativeControls={isFullscreen}
|
||||
accessibilityIgnoresInvertColors
|
||||
onFullscreenEnter={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||
PlatformInfo.setAudioActive(true)
|
||||
player.muted = false
|
||||
setIsFullscreen(true)
|
||||
if (isAndroid) {
|
||||
player.play()
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<BlueskyVideoView
|
||||
url={embed.playlist}
|
||||
autoplay={!autoplayDisabled && !isWithinMessage}
|
||||
beginMuted={autoplayDisabled ? false : muted}
|
||||
style={[a.rounded_sm]}
|
||||
onActiveChange={e => {
|
||||
setIsActive(e.nativeEvent.isActive)
|
||||
}}
|
||||
onLoadingChange={e => {
|
||||
setIsLoading(e.nativeEvent.isLoading)
|
||||
}}
|
||||
onMutedChange={e => {
|
||||
setMuted(e.nativeEvent.isMuted)
|
||||
}}
|
||||
onStatusChange={e => {
|
||||
setStatus(e.nativeEvent.status)
|
||||
setIsPlaying(e.nativeEvent.status === 'playing')
|
||||
}}
|
||||
onTimeRemainingChange={e => {
|
||||
setTimeRemaining(e.nativeEvent.timeRemaining)
|
||||
}}
|
||||
onError={e => {
|
||||
setError(e.nativeEvent.error)
|
||||
}}
|
||||
ref={videoRef}
|
||||
accessibilityLabel={
|
||||
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
|
||||
}
|
||||
}}
|
||||
onFullscreenExit={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
player.muted = true
|
||||
player.playbackRate = 1
|
||||
setIsFullscreen(false)
|
||||
}}
|
||||
accessibilityLabel={
|
||||
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<VideoControls
|
||||
player={player}
|
||||
enterFullscreen={enterFullscreen}
|
||||
isMuted={isMuted}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<VideoControls
|
||||
enterFullscreen={() => {
|
||||
videoRef.current?.enterFullscreen()
|
||||
}}
|
||||
toggleMuted={() => {
|
||||
videoRef.current?.toggleMuted()
|
||||
}}
|
||||
togglePlayback={() => {
|
||||
videoRef.current?.togglePlayback()
|
||||
}}
|
||||
isPlaying={isPlaying}
|
||||
timeRemaining={timeRemaining}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</View>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
toggleMuted,
|
||||
togglePlayback,
|
||||
timeRemaining,
|
||||
isMuted,
|
||||
isPlaying,
|
||||
}: {
|
||||
player: VideoPlayer
|
||||
enterFullscreen: () => void
|
||||
toggleMuted: () => void
|
||||
togglePlayback: () => void
|
||||
timeRemaining: number
|
||||
isMuted: boolean
|
||||
isPlaying: boolean
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
switch (player.status) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
case 'readyToPlay': {
|
||||
if (!player.playing) player.play()
|
||||
enterFullscreen()
|
||||
break
|
||||
}
|
||||
case 'error': {
|
||||
player.replay()
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [player, enterFullscreen])
|
||||
|
||||
const toggleMuted = useCallback(() => {
|
||||
const muted = !player.muted
|
||||
// We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when
|
||||
// the video is muted, and vice versa.
|
||||
const mix = !muted
|
||||
const category = muted ? AudioCategory.Ambient : AudioCategory.Playback
|
||||
|
||||
PlatformInfo.setAudioCategory(category)
|
||||
PlatformInfo.setAudioActive(mix)
|
||||
player.muted = muted
|
||||
}, [player])
|
||||
const [muted] = useVideoMuteState()
|
||||
|
||||
// show countdown when:
|
||||
// 1. timeRemaining is a number - was seeing NaNs
|
||||
@@ -138,44 +129,84 @@ function VideoControls({
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
{showTime && <TimeIndicator time={timeRemaining} />}
|
||||
<Pressable
|
||||
onPress={onPressFullscreen}
|
||||
onPress={enterFullscreen}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={_(msg`Video`)}
|
||||
accessibilityHint={_(msg`Tap to enter full screen`)}
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 4,
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
minHeight: 21,
|
||||
minWidth: 21,
|
||||
},
|
||||
]}>
|
||||
<Pressable
|
||||
onPress={toggleMuted}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)}
|
||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{isMuted ? (
|
||||
<MuteIcon width={13} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={13} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
<ControlButton
|
||||
onPress={togglePlayback}
|
||||
label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
|
||||
accessibilityHint={_(msg`Tap to play or pause`)}
|
||||
style={{left: 6}}>
|
||||
{isPlaying ? (
|
||||
<PauseIcon width={13} fill={t.palette.white} />
|
||||
) : (
|
||||
<PlayIcon width={13} fill={t.palette.white} />
|
||||
)}
|
||||
</ControlButton>
|
||||
{showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
|
||||
|
||||
<ControlButton
|
||||
onPress={toggleMuted}
|
||||
label={
|
||||
muted
|
||||
? _(msg({message: `Unmute`, context: 'video'}))
|
||||
: _(msg({message: `Mute`, context: 'video'}))
|
||||
}
|
||||
accessibilityHint={_(msg`Tap to toggle sound`)}
|
||||
style={{right: 6}}>
|
||||
{muted ? (
|
||||
<MuteIcon width={13} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={13} fill={t.palette.white} />
|
||||
)}
|
||||
</ControlButton>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
onPress,
|
||||
children,
|
||||
label,
|
||||
accessibilityHint,
|
||||
style,
|
||||
}: {
|
||||
onPress: () => void
|
||||
children: React.ReactNode
|
||||
label: string
|
||||
accessibilityHint: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.rounded_full,
|
||||
a.justify_center,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 4,
|
||||
bottom: 6,
|
||||
minHeight: 21,
|
||||
minWidth: 21,
|
||||
},
|
||||
style,
|
||||
]}>
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={a.flex_1}
|
||||
accessibilityLabel={label}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityRole="button"
|
||||
hitSlop={HITSLOP_30}>
|
||||
{children}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Controls} from './VideoWebControls'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {Controls} from './web-controls/VideoControls'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
embed,
|
||||
@@ -119,6 +120,7 @@ export function VideoEmbedInnerWeb({
|
||||
fullscreenRef={containerRef}
|
||||
hasSubtitleTrack={hasSubtitleTrack}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,896 +0,0 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isIPhoneWeb} from '#/platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
toggleMute,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoUtils(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.documentElement.style.scrollbarGutter = 'unset'
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('scrollbar-gutter')
|
||||
}
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (onScreen) {
|
||||
if (!autoplayDisabled) play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
// allow 30s of buffering
|
||||
hlsRef.current.config.maxMaxBufferLength = 30
|
||||
} else {
|
||||
// back to what we initially set
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
hlsRef.current.config.maxMaxBufferLength = 10
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
if (autoplayDisabled) play()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleMute()
|
||||
}, [drawFocus, toggleMute])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const onSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (!videoRef.current) return
|
||||
if (videoRef.current.fastSeek) {
|
||||
videoRef.current.fastSeek(time)
|
||||
} else {
|
||||
videoRef.current.currentTime = time
|
||||
}
|
||||
},
|
||||
[videoRef],
|
||||
)
|
||||
|
||||
const playStateBeforeSeekRef = useRef(false)
|
||||
|
||||
const onSeekStart = useCallback(() => {
|
||||
drawFocus()
|
||||
playStateBeforeSeekRef.current = playing
|
||||
pause()
|
||||
}, [playing, pause, drawFocus])
|
||||
|
||||
const onSeekEnd = useCallback(() => {
|
||||
if (playStateBeforeSeekRef.current) {
|
||||
play()
|
||||
}
|
||||
}, [play])
|
||||
|
||||
const seekLeft = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime - 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const seekRight = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime + 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const onPointerMoveEmptySpace = useCallback(() => {
|
||||
setShowCursor(true)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
cursorTimeoutRef.current = setTimeout(() => {
|
||||
setShowCursor(false)
|
||||
onEndHover()
|
||||
}, 2000)
|
||||
}, [onEndHover])
|
||||
const onPointerLeaveEmptySpace = useCallback(() => {
|
||||
setShowCursor(false)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// these are used to trigger the hover state. on mobile, the hover state
|
||||
// should stick around for a bit after they tap, and if the controls aren't
|
||||
// present this initial tab should *only* show the controls and not activate anything
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.pointerType !== 'mouse' && !hovered) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
},
|
||||
[hovered],
|
||||
)
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const onHoverWithTimeout = useCallback(() => {
|
||||
onHover()
|
||||
clearTimeout(timeoutRef.current)
|
||||
}, [onHover])
|
||||
|
||||
const onEndHoverWithTimeout = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
// if touch, end after 3s
|
||||
// if mouse, end immediately
|
||||
if (evt.pointerType !== 'mouse') {
|
||||
setTimeout(onEndHover, 3000)
|
||||
} else {
|
||||
onEndHover()
|
||||
}
|
||||
},
|
||||
[onEndHover],
|
||||
)
|
||||
|
||||
const showControls =
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onPointerEnter={onHoverWithTimeout}
|
||||
onPointerMove={onHoverWithTimeout}
|
||||
onPointerLeave={onEndHoverWithTimeout}
|
||||
onPointerDown={onPointerDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityHint={_(
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
|
||||
]}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{!showControls && !focused && duration > 0 && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
{opacity: showControls ? 1 : 0},
|
||||
{transition: 'opacity 0.2s ease-in-out'},
|
||||
]}>
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ControlButton
|
||||
active={playing}
|
||||
activeLabel={_(msg`Pause`)}
|
||||
inactiveLabel={_(msg`Play`)}
|
||||
activeIcon={PauseIcon}
|
||||
inactiveIcon={PlayIcon}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
/>
|
||||
)}
|
||||
<ControlButton
|
||||
active={muted}
|
||||
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
|
||||
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
|
||||
activeIcon={MuteIcon}
|
||||
inactiveIcon={UnmuteIcon}
|
||||
onPress={onPressMute}
|
||||
/>
|
||||
{!isIPhoneWeb && (
|
||||
<ControlButton
|
||||
active={isFullscreen}
|
||||
activeLabel={_(msg`Exit fullscreen`)}
|
||||
inactiveLabel={_(msg`Fullscreen`)}
|
||||
activeIcon={ArrowsInIcon}
|
||||
inactiveIcon={ArrowsOutIcon}
|
||||
onPress={onPressFullscreen}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(buffering || error) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ControlButton({
|
||||
active,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon: ActiveIcon,
|
||||
inactiveIcon: InactiveIcon,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean
|
||||
activeLabel: string
|
||||
inactiveLabel: string
|
||||
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
label={active ? activeLabel : inactiveLabel}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="medium"
|
||||
style={a.p_2xs}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||
{active ? (
|
||||
<ActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<InactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function Scrubber({
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSeekEnd,
|
||||
onSeekStart,
|
||||
seekLeft,
|
||||
seekRight,
|
||||
togglePlayPause,
|
||||
drawFocus,
|
||||
}: {
|
||||
duration: number
|
||||
currentTime: number
|
||||
onSeek: (time: number) => void
|
||||
onSeekEnd: () => void
|
||||
onSeekStart: () => void
|
||||
seekLeft: () => void
|
||||
seekRight: () => void
|
||||
togglePlayPause: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [scrubberActive, setScrubberActive] = useState(false)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onStartHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [seekPosition, setSeekPosition] = useState(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const seek = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!barRef.current) return
|
||||
const {left, width} = barRef.current.getBoundingClientRect()
|
||||
const x = evt.clientX
|
||||
const percent = clamp((x - left) / width, 0, 1) * duration
|
||||
onSeek(percent)
|
||||
setSeekPosition(percent)
|
||||
},
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.setPointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = true
|
||||
seek(evt)
|
||||
setScrubberActive(true)
|
||||
onSeekStart()
|
||||
}
|
||||
},
|
||||
[seek, onSeekStart],
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isSeekingRef.current) {
|
||||
evt.preventDefault()
|
||||
seek(evt)
|
||||
}
|
||||
},
|
||||
[seek],
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (isSeekingRef.current && target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.releasePointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
}
|
||||
},
|
||||
[onSeekEnd],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// HACK: there's divergent browser behaviour about what to do when
|
||||
// a pointerUp event is fired outside the element that captured the
|
||||
// pointer. Firefox clicks on the element the mouse is over, so we have
|
||||
// to make everything unclickable while seeking -sfn
|
||||
if (isFirefox && scrubberActive) {
|
||||
document.body.classList.add('force-no-clicks')
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
}
|
||||
}
|
||||
}, [scrubberActive, onSeekEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!circleRef.current) return
|
||||
if (focused) {
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
circleRef.current.addEventListener(
|
||||
'keydown',
|
||||
evt => {
|
||||
// space: play/pause
|
||||
// arrow left: seek backward
|
||||
// arrow right: seek forward
|
||||
|
||||
if (evt.key === ' ') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
} else if (evt.key === 'ArrowLeft') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekRight()
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => abortController.abort()
|
||||
}
|
||||
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
||||
|
||||
const progress = scrubberActive ? seekPosition : currentTime
|
||||
const progressPercent = (progress / duration) * 100
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="scrubber"
|
||||
style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs, a.py_xs]}
|
||||
onPointerEnter={onStartHover}
|
||||
onPointerLeave={onEndHover}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||
{height: hovered || scrubberActive ? 6 : 3},
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{width: `${progressPercent}%`},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<div
|
||||
ref={circleRef}
|
||||
aria-label={_(msg`Seek slider`)}
|
||||
role="slider"
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuetext={_(
|
||||
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: 16,
|
||||
width: 16,
|
||||
left: `calc(${progressPercent}% - 8px)`,
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale:
|
||||
hovered || scrubberActive || focused
|
||||
? scrubberActive
|
||||
? 1
|
||||
: 0.6
|
||||
: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 200) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const mute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = true
|
||||
}, [ref])
|
||||
|
||||
const unmute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = false
|
||||
}, [ref])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.muted = !ref.current.muted
|
||||
}, [ref])
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
mute,
|
||||
unmute,
|
||||
toggleMute,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
|
||||
export function ControlButton({
|
||||
active,
|
||||
activeLabel,
|
||||
inactiveLabel,
|
||||
activeIcon: ActiveIcon,
|
||||
inactiveIcon: InactiveIcon,
|
||||
onPress,
|
||||
}: {
|
||||
active: boolean
|
||||
activeLabel: string
|
||||
inactiveLabel: string
|
||||
activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<Button
|
||||
label={active ? activeLabel : inactiveLabel}
|
||||
onPress={onPress}
|
||||
variant="ghost"
|
||||
shape="round"
|
||||
size="medium"
|
||||
style={a.p_2xs}
|
||||
hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.1)'}}>
|
||||
{active ? (
|
||||
<ActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<InactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {formatTime} from './utils'
|
||||
|
||||
export function Scrubber({
|
||||
duration,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onSeekEnd,
|
||||
onSeekStart,
|
||||
seekLeft,
|
||||
seekRight,
|
||||
togglePlayPause,
|
||||
drawFocus,
|
||||
}: {
|
||||
duration: number
|
||||
currentTime: number
|
||||
onSeek: (time: number) => void
|
||||
onSeekEnd: () => void
|
||||
onSeekStart: () => void
|
||||
seekLeft: () => void
|
||||
seekRight: () => void
|
||||
togglePlayPause: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [scrubberActive, setScrubberActive] = useState(false)
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onStartHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [seekPosition, setSeekPosition] = useState(0)
|
||||
const isSeekingRef = useRef(false)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const circleRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const seek = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!barRef.current) return
|
||||
const {left, width} = barRef.current.getBoundingClientRect()
|
||||
const x = evt.clientX
|
||||
const percent = clamp((x - left) / width, 0, 1) * duration
|
||||
onSeek(percent)
|
||||
setSeekPosition(percent)
|
||||
},
|
||||
[duration, onSeek],
|
||||
)
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.setPointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = true
|
||||
seek(evt)
|
||||
setScrubberActive(true)
|
||||
onSeekStart()
|
||||
}
|
||||
},
|
||||
[seek, onSeekStart],
|
||||
)
|
||||
|
||||
const onPointerMove = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isSeekingRef.current) {
|
||||
evt.preventDefault()
|
||||
seek(evt)
|
||||
}
|
||||
},
|
||||
[seek],
|
||||
)
|
||||
|
||||
const onPointerUp = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
const target = evt.target
|
||||
if (isSeekingRef.current && target instanceof Element) {
|
||||
evt.preventDefault()
|
||||
target.releasePointerCapture(evt.pointerId)
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
}
|
||||
},
|
||||
[onSeekEnd],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// HACK: there's divergent browser behaviour about what to do when
|
||||
// a pointerUp event is fired outside the element that captured the
|
||||
// pointer. Firefox clicks on the element the mouse is over, so we have
|
||||
// to make everything unclickable while seeking -sfn
|
||||
if (isFirefox && scrubberActive) {
|
||||
document.body.classList.add('force-no-clicks')
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
}
|
||||
}
|
||||
}, [scrubberActive, onSeekEnd])
|
||||
|
||||
useEffect(() => {
|
||||
if (!circleRef.current) return
|
||||
if (focused) {
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
circleRef.current.addEventListener(
|
||||
'keydown',
|
||||
evt => {
|
||||
// space: play/pause
|
||||
// arrow left: seek backward
|
||||
// arrow right: seek forward
|
||||
|
||||
if (evt.key === ' ') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
} else if (evt.key === 'ArrowLeft') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekLeft()
|
||||
} else if (evt.key === 'ArrowRight') {
|
||||
evt.preventDefault()
|
||||
drawFocus()
|
||||
seekRight()
|
||||
}
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => abortController.abort()
|
||||
}
|
||||
}, [focused, seekLeft, seekRight, togglePlayPause, drawFocus])
|
||||
|
||||
const progress = scrubberActive ? seekPosition : currentTime
|
||||
const progressPercent = (progress / duration) * 100
|
||||
|
||||
return (
|
||||
<View
|
||||
testID="scrubber"
|
||||
style={[{height: 18, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||
onPointerEnter={onStartHover}
|
||||
onPointerLeave={onEndHover}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||
{height: hovered || scrubberActive ? 6 : 3},
|
||||
web({transition: 'height 0.1s ease'}),
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{width: `${progressPercent}%`},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<div
|
||||
ref={circleRef}
|
||||
aria-label={_(msg`Seek slider`)}
|
||||
role="slider"
|
||||
aria-valuemax={duration}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={currentTime}
|
||||
aria-valuetext={_(
|
||||
msg`${formatTime(currentTime)} of ${formatTime(duration)}`,
|
||||
)}
|
||||
tabIndex={0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: 16,
|
||||
width: 16,
|
||||
left: `calc(${progressPercent}% - 8px)`,
|
||||
borderRadius: 8,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.h_full,
|
||||
a.rounded_full,
|
||||
{backgroundColor: t.palette.white},
|
||||
{
|
||||
transform: [
|
||||
{
|
||||
scale:
|
||||
hovered || scrubberActive || focused
|
||||
? scrubberActive
|
||||
? 1
|
||||
: 0.6
|
||||
: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isTouchDevice} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isIPhoneWeb} from '#/platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {useIsWithinMessage} from '#/components/dms/MessageContext'
|
||||
import {useFullscreen} from '#/components/hooks/useFullscreen'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {
|
||||
ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon,
|
||||
ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon,
|
||||
} from '#/components/icons/ArrowsDiagonal'
|
||||
import {
|
||||
CC_Filled_Corner0_Rounded as CCActiveIcon,
|
||||
CC_Stroke2_Corner0_Rounded as CCInactiveIcon,
|
||||
} from '#/components/icons/CC'
|
||||
import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause'
|
||||
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TimeIndicator} from '../TimeIndicator'
|
||||
import {ControlButton} from './ControlButton'
|
||||
import {Scrubber} from './Scrubber'
|
||||
import {formatTime, useVideoElement} from './utils'
|
||||
import {VolumeControl} from './VolumeControl'
|
||||
|
||||
export function Controls({
|
||||
videoRef,
|
||||
hlsRef,
|
||||
active,
|
||||
setActive,
|
||||
focused,
|
||||
setFocused,
|
||||
onScreen,
|
||||
fullscreenRef,
|
||||
hasSubtitleTrack,
|
||||
}: {
|
||||
videoRef: React.RefObject<HTMLVideoElement>
|
||||
hlsRef: React.RefObject<Hls | undefined>
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
focused: boolean
|
||||
setFocused: (focused: boolean) => void
|
||||
onScreen: boolean
|
||||
fullscreenRef: React.RefObject<HTMLDivElement>
|
||||
hasSubtitleTrack: boolean
|
||||
}) {
|
||||
const {
|
||||
play,
|
||||
pause,
|
||||
playing,
|
||||
muted,
|
||||
changeMuted,
|
||||
togglePlayPause,
|
||||
currentTime,
|
||||
duration,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
} = useVideoElement(videoRef)
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const subtitlesEnabled = useSubtitlesEnabled()
|
||||
const setSubtitlesEnabled = useSetSubtitlesEnabled()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onHover,
|
||||
onOut: onEndHover,
|
||||
} = useInteractionState()
|
||||
const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef)
|
||||
const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState()
|
||||
const [interactingViaKeypress, setInteractingViaKeypress] = useState(false)
|
||||
const {
|
||||
state: volumeHovered,
|
||||
onIn: onVolumeHover,
|
||||
onOut: onVolumeEndHover,
|
||||
} = useInteractionState()
|
||||
|
||||
const onKeyDown = useCallback(() => {
|
||||
setInteractingViaKeypress(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (interactingViaKeypress) {
|
||||
document.addEventListener('click', () => setInteractingViaKeypress(false))
|
||||
return () => {
|
||||
document.removeEventListener('click', () =>
|
||||
setInteractingViaKeypress(false),
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [interactingViaKeypress])
|
||||
|
||||
useEffect(() => {
|
||||
if (isFullscreen) {
|
||||
document.documentElement.style.scrollbarGutter = 'unset'
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('scrollbar-gutter')
|
||||
}
|
||||
}
|
||||
}, [isFullscreen])
|
||||
|
||||
// pause + unfocus when another video is active
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
pause()
|
||||
setFocused(false)
|
||||
}
|
||||
}, [active, pause, setFocused])
|
||||
|
||||
// autoplay/pause based on visibility
|
||||
const isWithinMessage = useIsWithinMessage()
|
||||
const autoplayDisabled = useAutoplayDisabled() || isWithinMessage
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (onScreen) {
|
||||
if (!autoplayDisabled) play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}
|
||||
}, [onScreen, pause, active, play, autoplayDisabled])
|
||||
|
||||
// use minimal quality when not focused
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (focused) {
|
||||
// auto decide quality based on network conditions
|
||||
hlsRef.current.autoLevelCapping = -1
|
||||
// allow 30s of buffering
|
||||
hlsRef.current.config.maxMaxBufferLength = 30
|
||||
} else {
|
||||
// back to what we initially set
|
||||
hlsRef.current.autoLevelCapping = 0
|
||||
hlsRef.current.config.maxMaxBufferLength = 10
|
||||
}
|
||||
}, [hlsRef, focused])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsRef.current) return
|
||||
if (hasSubtitleTrack && subtitlesEnabled && canPlay) {
|
||||
hlsRef.current.subtitleTrack = 0
|
||||
} else {
|
||||
hlsRef.current.subtitleTrack = -1
|
||||
}
|
||||
}, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay])
|
||||
|
||||
// clicking on any button should focus the player, if it's not already focused
|
||||
const drawFocus = useCallback(() => {
|
||||
if (!active) {
|
||||
setActive()
|
||||
}
|
||||
setFocused(true)
|
||||
}, [active, setActive, setFocused])
|
||||
|
||||
const onPressEmptySpace = useCallback(() => {
|
||||
if (!focused) {
|
||||
drawFocus()
|
||||
if (autoplayDisabled) play()
|
||||
} else {
|
||||
togglePlayPause()
|
||||
}
|
||||
}, [togglePlayPause, drawFocus, focused, autoplayDisabled, play])
|
||||
|
||||
const onPressPlayPause = useCallback(() => {
|
||||
drawFocus()
|
||||
togglePlayPause()
|
||||
}, [drawFocus, togglePlayPause])
|
||||
|
||||
const onPressSubtitles = useCallback(() => {
|
||||
drawFocus()
|
||||
setSubtitlesEnabled(!subtitlesEnabled)
|
||||
}, [drawFocus, setSubtitlesEnabled, subtitlesEnabled])
|
||||
|
||||
const onPressFullscreen = useCallback(() => {
|
||||
drawFocus()
|
||||
toggleFullscreen()
|
||||
}, [drawFocus, toggleFullscreen])
|
||||
|
||||
const onSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (!videoRef.current) return
|
||||
if (videoRef.current.fastSeek) {
|
||||
videoRef.current.fastSeek(time)
|
||||
} else {
|
||||
videoRef.current.currentTime = time
|
||||
}
|
||||
},
|
||||
[videoRef],
|
||||
)
|
||||
|
||||
const playStateBeforeSeekRef = useRef(false)
|
||||
|
||||
const onSeekStart = useCallback(() => {
|
||||
drawFocus()
|
||||
playStateBeforeSeekRef.current = playing
|
||||
pause()
|
||||
}, [playing, pause, drawFocus])
|
||||
|
||||
const onSeekEnd = useCallback(() => {
|
||||
if (playStateBeforeSeekRef.current) {
|
||||
play()
|
||||
}
|
||||
}, [play])
|
||||
|
||||
const seekLeft = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime - 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const seekRight = useCallback(() => {
|
||||
if (!videoRef.current) return
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const currentTime = videoRef.current.currentTime
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const duration = videoRef.current.duration || 0
|
||||
onSeek(clamp(currentTime + 5, 0, duration))
|
||||
}, [onSeek, videoRef])
|
||||
|
||||
const [showCursor, setShowCursor] = useState(true)
|
||||
const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const onPointerMoveEmptySpace = useCallback(() => {
|
||||
setShowCursor(true)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
cursorTimeoutRef.current = setTimeout(() => {
|
||||
setShowCursor(false)
|
||||
onEndHover()
|
||||
}, 2000)
|
||||
}, [onEndHover])
|
||||
const onPointerLeaveEmptySpace = useCallback(() => {
|
||||
setShowCursor(false)
|
||||
if (cursorTimeoutRef.current) {
|
||||
clearTimeout(cursorTimeoutRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// these are used to trigger the hover state. on mobile, the hover state
|
||||
// should stick around for a bit after they tap, and if the controls aren't
|
||||
// present this initial tab should *only* show the controls and not activate anything
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.pointerType !== 'mouse' && !hovered) {
|
||||
evt.preventDefault()
|
||||
}
|
||||
clearTimeout(timeoutRef.current)
|
||||
},
|
||||
[hovered],
|
||||
)
|
||||
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const onHoverWithTimeout = useCallback(() => {
|
||||
onHover()
|
||||
clearTimeout(timeoutRef.current)
|
||||
}, [onHover])
|
||||
|
||||
const onEndHoverWithTimeout = useCallback(
|
||||
(evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
// if touch, end after 3s
|
||||
// if mouse, end immediately
|
||||
if (evt.pointerType !== 'mouse') {
|
||||
setTimeout(onEndHover, 3000)
|
||||
} else {
|
||||
onEndHover()
|
||||
}
|
||||
},
|
||||
[onEndHover],
|
||||
)
|
||||
|
||||
const showControls =
|
||||
((focused || autoplayDisabled) && !playing) ||
|
||||
(interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={evt => {
|
||||
evt.stopPropagation()
|
||||
setInteractingViaKeypress(false)
|
||||
}}
|
||||
onPointerEnter={onHoverWithTimeout}
|
||||
onPointerMove={onHoverWithTimeout}
|
||||
onPointerLeave={onEndHoverWithTimeout}
|
||||
onPointerDown={onPointerDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={onKeyDown}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPointerEnter={onPointerMoveEmptySpace}
|
||||
onPointerMove={onPointerMoveEmptySpace}
|
||||
onPointerLeave={onPointerLeaveEmptySpace}
|
||||
accessibilityHint={_(
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
: msg`Play video`,
|
||||
)}
|
||||
style={[
|
||||
a.flex_1,
|
||||
web({cursor: showCursor || !playing ? 'pointer' : 'none'}),
|
||||
]}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{!showControls && !focused && duration > 0 && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
{opacity: showControls ? 1 : 0},
|
||||
{transition: 'opacity 0.2s ease-in-out'},
|
||||
]}>
|
||||
{(!volumeHovered || isTouchDevice) && (
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_2xs,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
<ControlButton
|
||||
active={playing}
|
||||
activeLabel={_(msg`Pause`)}
|
||||
inactiveLabel={_(msg`Play`)}
|
||||
activeIcon={PauseIcon}
|
||||
inactiveIcon={PlayIcon}
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
/>
|
||||
)}
|
||||
<VolumeControl
|
||||
muted={muted}
|
||||
changeMuted={changeMuted}
|
||||
hovered={volumeHovered}
|
||||
onHover={onVolumeHover}
|
||||
onEndHover={onVolumeEndHover}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
{!isIPhoneWeb && (
|
||||
<ControlButton
|
||||
active={isFullscreen}
|
||||
activeLabel={_(msg`Exit fullscreen`)}
|
||||
inactiveLabel={_(msg`Fullscreen`)}
|
||||
activeIcon={ArrowsInIcon}
|
||||
inactiveIcon={ArrowsOutIcon}
|
||||
onPress={onPressFullscreen}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{(buffering || error) && (
|
||||
<View
|
||||
pointerEvents="none"
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, {useCallback} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {isSafari, isTouchDevice} from '#/lib/browser'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {useVideoVolumeState} from '../../VideoVolumeContext'
|
||||
import {ControlButton} from './ControlButton'
|
||||
|
||||
export function VolumeControl({
|
||||
muted,
|
||||
changeMuted,
|
||||
hovered,
|
||||
onHover,
|
||||
onEndHover,
|
||||
drawFocus,
|
||||
}: {
|
||||
muted: boolean
|
||||
changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void
|
||||
hovered: boolean
|
||||
onHover: () => void
|
||||
onEndHover: () => void
|
||||
drawFocus: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [volume, setVolume] = useVideoVolumeState()
|
||||
|
||||
const onVolumeChange = useCallback(
|
||||
(evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
drawFocus()
|
||||
const vol = sliderVolumeToVideoVolume(Number(evt.target.value))
|
||||
setVolume(vol)
|
||||
changeMuted(vol === 0)
|
||||
},
|
||||
[setVolume, drawFocus, changeMuted],
|
||||
)
|
||||
|
||||
const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume)
|
||||
|
||||
const isZeroVolume = volume === 0
|
||||
const onPressMute = useCallback(() => {
|
||||
drawFocus()
|
||||
if (isZeroVolume) {
|
||||
setVolume(1)
|
||||
changeMuted(false)
|
||||
} else {
|
||||
changeMuted(prevMuted => !prevMuted)
|
||||
}
|
||||
}, [drawFocus, setVolume, isZeroVolume, changeMuted])
|
||||
|
||||
return (
|
||||
<View
|
||||
onPointerEnter={onHover}
|
||||
onPointerLeave={onEndHover}
|
||||
style={[a.relative]}>
|
||||
{hovered && !isTouchDevice && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(100)}
|
||||
exiting={FadeOut.duration(100)}
|
||||
style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}>
|
||||
<View
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.mb_xs,
|
||||
a.px_2xs,
|
||||
a.py_xs,
|
||||
{backgroundColor: 'rgba(0, 0, 0, 0.6)'},
|
||||
a.rounded_xs,
|
||||
a.align_center,
|
||||
]}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
value={sliderVolume}
|
||||
style={
|
||||
// Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h
|
||||
isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'}
|
||||
}
|
||||
onChange={onVolumeChange}
|
||||
// @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn
|
||||
orient="vertical"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
<ControlButton
|
||||
active={muted || volume === 0}
|
||||
activeLabel={_(msg({message: `Unmute`, context: 'video'}))}
|
||||
inactiveLabel={_(msg({message: `Mute`, context: 'video'}))}
|
||||
activeIcon={MuteIcon}
|
||||
inactiveIcon={UnmuteIcon}
|
||||
onPress={onPressMute}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function sliderVolumeToVideoVolume(value: number) {
|
||||
return Math.pow(value / 100, 4)
|
||||
}
|
||||
|
||||
function videoVolumeToSliderVolume(value: number) {
|
||||
return Math.round(Math.pow(value, 1 / 4) * 100)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
|
||||
import {isSafari} from '#/lib/browser'
|
||||
import {useVideoVolumeState} from '../../VideoVolumeContext'
|
||||
|
||||
export function useVideoElement(ref: React.RefObject<HTMLVideoElement>) {
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [muted, setMuted] = useState(true)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [volume, setVolume] = useVideoVolumeState()
|
||||
const [duration, setDuration] = useState(0)
|
||||
const [buffering, setBuffering] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [canPlay, setCanPlay] = useState(false)
|
||||
const playWhenReadyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
ref.current.volume = volume
|
||||
}, [ref, volume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
let bufferingTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function round(num: number) {
|
||||
return Math.round(num * 100) / 100
|
||||
}
|
||||
|
||||
// Initial values
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
setMuted(ref.current.muted)
|
||||
setPlaying(!ref.current.paused)
|
||||
setVolume(ref.current.volume)
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (!ref.current) return
|
||||
setCurrentTime(round(ref.current.currentTime) || 0)
|
||||
// HACK: Safari randomly fires `stalled` events when changing between segments
|
||||
// let's just clear the buffering state if the video is still progressing -sfn
|
||||
if (isSafari) {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDurationChange = () => {
|
||||
if (!ref.current) return
|
||||
setDuration(round(ref.current.duration) || 0)
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
setPlaying(true)
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
setPlaying(false)
|
||||
}
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (!ref.current) return
|
||||
setMuted(ref.current.muted)
|
||||
}
|
||||
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
}
|
||||
|
||||
const handleCanPlay = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setCanPlay(true)
|
||||
|
||||
if (!ref.current) return
|
||||
if (playWhenReadyRef.current) {
|
||||
ref.current.play()
|
||||
playWhenReadyRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleWaiting = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 500) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handlePlaying = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
setBuffering(true)
|
||||
}, 500) // Delay to avoid frequent buffering state changes
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaying(false)
|
||||
setBuffering(false)
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
|
||||
ref.current.addEventListener('timeupdate', handleTimeUpdate, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('durationchange', handleDurationChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('play', handlePlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('pause', handlePause, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('error', handleError, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplay', handleCanPlay, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('canplaythrough', handleCanPlayThrough, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('waiting', handleWaiting, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('ended', handleEnded, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('volumechange', handleVolumeChange, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
return () => {
|
||||
abortController.abort()
|
||||
clearTimeout(bufferingTimeout)
|
||||
}
|
||||
}, [ref, setVolume])
|
||||
|
||||
const play = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.ended) {
|
||||
ref.current.currentTime = 0
|
||||
}
|
||||
|
||||
if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) {
|
||||
playWhenReadyRef.current = true
|
||||
} else {
|
||||
const promise = ref.current.play()
|
||||
if (promise !== undefined) {
|
||||
promise.catch(err => {
|
||||
console.error('Error playing video:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
ref.current.pause()
|
||||
playWhenReadyRef.current = false
|
||||
}, [ref])
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!ref.current) return
|
||||
|
||||
if (ref.current.paused) {
|
||||
play()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
}, [ref, play, pause])
|
||||
|
||||
const changeMuted = useCallback(
|
||||
(newMuted: boolean | ((prev: boolean) => boolean)) => {
|
||||
if (!ref.current) return
|
||||
|
||||
const value =
|
||||
typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted
|
||||
ref.current.muted = value
|
||||
},
|
||||
[ref],
|
||||
)
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
togglePlayPause,
|
||||
duration,
|
||||
currentTime,
|
||||
playing,
|
||||
muted,
|
||||
changeMuted,
|
||||
buffering,
|
||||
error,
|
||||
canPlay,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTime(time: number) {
|
||||
if (isNaN(time)) {
|
||||
return '--'
|
||||
}
|
||||
|
||||
time = Math.round(time)
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return `${minutes}:${seconds}`
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
|
||||
const Context = React.createContext<{
|
||||
// native
|
||||
muted: boolean
|
||||
setMuted: React.Dispatch<React.SetStateAction<boolean>>
|
||||
// web
|
||||
volume: number
|
||||
setVolume: React.Dispatch<React.SetStateAction<number>>
|
||||
} | null>(null)
|
||||
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
const [muted, setMuted] = React.useState(true)
|
||||
const [volume, setVolume] = React.useState(1)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
muted,
|
||||
setMuted,
|
||||
volume,
|
||||
setVolume,
|
||||
}),
|
||||
[muted, setMuted, volume, setVolume],
|
||||
)
|
||||
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useVideoVolumeState() {
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useVideoVolumeState must be used within a VideoVolumeProvider',
|
||||
)
|
||||
}
|
||||
return [context.volume, context.setVolume] as const
|
||||
}
|
||||
|
||||
export function useVideoMuteState() {
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useVideoMuteState must be used within a VideoVolumeProvider',
|
||||
)
|
||||
}
|
||||
return [context.muted, context.setMuted] as const
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react'
|
||||
import Svg, {Path, PathProps, SvgProps} from 'react-native-svg'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
|
||||
const ratio = 54 / 61
|
||||
|
||||
export function Logomark({
|
||||
fill,
|
||||
...rest
|
||||
}: {fill?: PathProps['fill']} & SvgProps) {
|
||||
const pal = usePalette('default')
|
||||
// @ts-ignore it's fiiiiine
|
||||
const size = parseInt(rest.width || 32)
|
||||
|
||||
return (
|
||||
<Svg
|
||||
fill="none"
|
||||
viewBox="0 0 61 54"
|
||||
{...rest}
|
||||
width={size}
|
||||
height={Number(size) * ratio}>
|
||||
<Path
|
||||
fill={fill || pal.text.color}
|
||||
d="M13.223 3.602C20.215 8.832 27.738 19.439 30.5 25.13c2.762-5.691 10.284-16.297 17.278-21.528C52.824-.172 61-3.093 61 6.2c0 1.856-1.068 15.59-1.694 17.82-2.178 7.752-10.112 9.73-17.17 8.532 12.337 2.092 15.475 9.021 8.697 15.95-12.872 13.159-18.5-3.302-19.943-7.52-.264-.773-.388-1.135-.39-.827-.002-.308-.126.054-.39.827-1.442 4.218-7.071 20.679-19.943 7.52-6.778-6.929-3.64-13.858 8.697-15.95-7.058 1.197-14.992-.78-17.17-8.532C1.068 21.79 0 8.056 0 6.2 0-3.093 8.176-.172 13.223 3.602Z"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export function Composer({}: {winHeight: number}) {
|
||||
mention={state.mention}
|
||||
openEmojiPicker={onOpenPicker}
|
||||
text={state.text}
|
||||
imageUris={state.imageUris}
|
||||
/>
|
||||
</View>
|
||||
<EmojiPicker state={pickerState} close={onClosePicker} />
|
||||
|
||||
@@ -262,6 +262,51 @@
|
||||
.force-no-clicks * {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
input[type=range][orient=vertical] {
|
||||
writing-mode: vertical-lr;
|
||||
direction: rtl;
|
||||
appearance: slider-vertical;
|
||||
width: 16px;
|
||||
vertical-align: bottom;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-webkit-slider-runnable-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-track {
|
||||
background: white;
|
||||
height: 100%;
|
||||
width: 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
|
||||
input[type="range"][orient=vertical]::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background-color: white;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-left: -6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -4104,6 +4104,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
|
||||
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
|
||||
|
||||
"@haileyok/bluesky-video@0.1.10":
|
||||
version "0.1.10"
|
||||
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.10.tgz#2756e8c83a78caeb6b120a175578eac1eb6889a9"
|
||||
integrity sha512-W8+DNdek+xjAqTO1zmuuSrkVFxDepcP8+Gs8MvIcYSgXEJlpQimZpcMwAduiDI/jZMn/2U6cnMahx7YuiZlZ7g==
|
||||
|
||||
"@hapi/accept@^6.0.3":
|
||||
version "6.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab"
|
||||
@@ -6001,6 +6006,34 @@
|
||||
dset "^3.1.1"
|
||||
tiny-hashes "^1.0.1"
|
||||
|
||||
"@sentry-internal/feedback@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-7.119.0.tgz#429b3ea0fd34e928d2e7de5dcbe9377272a3f221"
|
||||
integrity sha512-om8TkAU5CQGO8nkmr7qsSBVkP+/vfeS4JgtW3sjoTK0fhj26+DljR6RlfCGWtYQdPSP6XV7atcPTjbSnsmG9FQ==
|
||||
dependencies:
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry-internal/replay-canvas@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-7.119.0.tgz#85669d184ba79150e64d05de02f5e2b616e68371"
|
||||
integrity sha512-NL02VQx6ekPxtVRcsdp1bp5Tb5w6vnfBKSIfMKuDRBy5A10Uc3GSoy/c3mPyHjOxB84452A+xZSx6bliEzAnuA==
|
||||
dependencies:
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/replay" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry-internal/tracing@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.119.0.tgz#201561af2a4ad1837333287c26050a5e688537ca"
|
||||
integrity sha512-oKdFJnn+56f0DHUADlL8o9l8jTib3VDLbWQBVkjD9EprxfaCwt2m8L5ACRBdQ8hmpxCEo4I8/6traZ7qAdBUqA==
|
||||
dependencies:
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry-internal/tracing@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.52.0.tgz#c4e0750ad0c3949c5bb4b59cbb708b0fef274080"
|
||||
@@ -6021,6 +6054,25 @@
|
||||
"@sentry/utils" "7.52.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/babel-plugin-component-annotate@2.20.1":
|
||||
version "2.20.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-2.20.1.tgz#204c63ed006a048f48f633876e1b8bacf87a9722"
|
||||
integrity sha512-4mhEwYTK00bIb5Y9UWIELVUfru587Vaeg0DQGswv4aIRHIiMKLyNqCEejaaybQ/fNChIZOKmvyqXk430YVd7Qg==
|
||||
|
||||
"@sentry/browser@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.119.0.tgz#65004015c107be5d2f49a852ebcffc5d19d90e0d"
|
||||
integrity sha512-WwmW1Y4D764kVGeKmdsNvQESZiAn9t8LmCWO0ucBksrjL2zw9gBPtOpRcO6l064sCLeSxxzCN+kIxhRm1gDFEA==
|
||||
dependencies:
|
||||
"@sentry-internal/feedback" "7.119.0"
|
||||
"@sentry-internal/replay-canvas" "7.119.0"
|
||||
"@sentry-internal/tracing" "7.119.0"
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/integrations" "7.119.0"
|
||||
"@sentry/replay" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry/browser@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-7.52.0.tgz#55d266c89ed668389ff687e5cc885c27016ea85c"
|
||||
@@ -6045,6 +6097,41 @@
|
||||
"@sentry/utils" "7.52.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/cli-darwin@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-darwin/-/cli-darwin-2.36.1.tgz#786adf6984dbe3c6fb7dac51b625c314117b807d"
|
||||
integrity sha512-JOHQjVD8Kqxm1jUKioAP5ohLOITf+Dh6+DBz4gQjCNdotsvNW5m63TKROwq2oB811p+Jzv5304ujmN4cAqW1Ww==
|
||||
|
||||
"@sentry/cli-linux-arm64@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.36.1.tgz#ff449d7e7e715166257998c02cf635ca02acbadd"
|
||||
integrity sha512-R//3ZEkbzvoStr3IA7nxBZNiBYyxOljOqAhgiTnejXHmnuwDzM3TBA2n5vKPE/kBFxboEBEw0UTzTIRb1T0bgw==
|
||||
|
||||
"@sentry/cli-linux-arm@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-2.36.1.tgz#1ae5d335a1b4cd217a34c2bd6c6f5e0670136eb3"
|
||||
integrity sha512-gvEOKN0fWL2AVqUBKHNXPRZfJNvKTs8kQhS8cQqahZGgZHiPCI4BqW45cKMq+ZTt1UUbLmt6khx5Dz7wi+0i5A==
|
||||
|
||||
"@sentry/cli-linux-i686@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-2.36.1.tgz#112b9e26357e918cbbba918114ec8cdab07cdf60"
|
||||
integrity sha512-R7sW5Vk/HacVy2YgQoQB+PwccvFYf2CZVPSFSFm2z7MEfNe77UYHWUq+sjJ4vxWG6HDWGVmaX0fjxyDkE01JRA==
|
||||
|
||||
"@sentry/cli-linux-x64@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-2.36.1.tgz#c3e5bdb0c9a4bb44c83927c62a3cd7e006709bf7"
|
||||
integrity sha512-UMr3ik8ksA7zQfbzsfwCb+ztenLnaeAbX94Gp+bzANZwPfi/vVHf2bLyqsBs4OyVt9SPlN1bbM/RTGfMjZ3JOw==
|
||||
|
||||
"@sentry/cli-win32-i686@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-2.36.1.tgz#819573320e885e1dbf59b0a01d3bd370c84c1708"
|
||||
integrity sha512-CflvhnvxPEs5GWQuuDtYSLqPrBaPbcSJFlBuUIb+8WNzRxvVfjgw1qzfZmkQqABqiy/YEsEekllOoMFZAvCcVA==
|
||||
|
||||
"@sentry/cli-win32-x64@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-2.36.1.tgz#80779b4bffb4e2e32944eae72c60e6f40151b4dc"
|
||||
integrity sha512-wWqht2xghcK3TGnooHZSzA3WSjdtno/xFjZLvWmw38rblGwgKMxLZnlxV6uDyS+OJ6CbfDTlCRay/0TIqA0N8g==
|
||||
|
||||
"@sentry/cli@2.17.5":
|
||||
version "2.17.5"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.17.5.tgz#d41e24893a843bcd41e14274044a7ddea9332824"
|
||||
@@ -6056,6 +6143,33 @@
|
||||
proxy-from-env "^1.1.0"
|
||||
which "^2.0.2"
|
||||
|
||||
"@sentry/cli@2.36.1":
|
||||
version "2.36.1"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/cli/-/cli-2.36.1.tgz#a9146b798cb6d2f782a7a48d74633ddcd88dc8d3"
|
||||
integrity sha512-gzK5uQKDWKhyH5udoB5+oaUNrS//urWyaXgKvHKz4gFfl744HuKY9dpLPP2nMnf0zLGmGM6xJnMXLqILq0mtxw==
|
||||
dependencies:
|
||||
https-proxy-agent "^5.0.0"
|
||||
node-fetch "^2.6.7"
|
||||
progress "^2.0.3"
|
||||
proxy-from-env "^1.1.0"
|
||||
which "^2.0.2"
|
||||
optionalDependencies:
|
||||
"@sentry/cli-darwin" "2.36.1"
|
||||
"@sentry/cli-linux-arm" "2.36.1"
|
||||
"@sentry/cli-linux-arm64" "2.36.1"
|
||||
"@sentry/cli-linux-i686" "2.36.1"
|
||||
"@sentry/cli-linux-x64" "2.36.1"
|
||||
"@sentry/cli-win32-i686" "2.36.1"
|
||||
"@sentry/cli-win32-x64" "2.36.1"
|
||||
|
||||
"@sentry/core@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.119.0.tgz#a6e41119bb03ec27689f9ad04e79d1fba5b7fc37"
|
||||
integrity sha512-CS2kUv9rAJJEjiRat6wle3JATHypB0SyD7pt4cpX5y0dN5dZ1JrF57oLHRMnga9fxRivydHz7tMTuBhSSwhzjw==
|
||||
dependencies:
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry/core@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/core/-/core-7.52.0.tgz#6c820ca48fe2f06bfd6b290044c96de2375f2ad4"
|
||||
@@ -6074,6 +6188,15 @@
|
||||
"@sentry/utils" "7.52.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/hub@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.119.0.tgz#a94d657b9d3cfd4cc061c5c238f86faefb55d5d8"
|
||||
integrity sha512-183h5B/rZosLxpB+ZYOvFdHk0rwZbKskxqKFtcyPbDAfpCUgCass41UTqyxF6aH1qLgCRxX8GcLRF7frIa/SOg==
|
||||
dependencies:
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry/hub@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-7.52.0.tgz#ffc087d58c745d57108862faa0f701b15503dcc2"
|
||||
@@ -6084,6 +6207,16 @@
|
||||
"@sentry/utils" "7.52.0"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/integrations@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.119.0.tgz#5b25c603026dbacfe1ae7bb8d768506a129149fb"
|
||||
integrity sha512-OHShvtsRW0A+ZL/ZbMnMqDEtJddPasndjq+1aQXw40mN+zeP7At/V1yPZyFaURy86iX7Ucxw5BtmzuNy7hLyTA==
|
||||
dependencies:
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
localforage "^1.8.1"
|
||||
|
||||
"@sentry/integrations@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/integrations/-/integrations-7.52.0.tgz#632aa5e54bdfdab910a24057c2072634a2670409"
|
||||
@@ -6104,6 +6237,21 @@
|
||||
localforage "^1.8.1"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/react-native@5.32.0":
|
||||
version "5.32.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.32.0.tgz#14c46a65f7359f5e74a9cef7edb9d7bf9a0a449d"
|
||||
integrity sha512-Rh5EEYuWUyPaTaL8b/tl3okGiWb3LSvfYiS/WkvTYH+pFYr2RNtrZSCxhP5TpWSqkoy8VVXeYhSmlBvjfD/uDg==
|
||||
dependencies:
|
||||
"@sentry/babel-plugin-component-annotate" "2.20.1"
|
||||
"@sentry/browser" "7.119.0"
|
||||
"@sentry/cli" "2.36.1"
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/hub" "7.119.0"
|
||||
"@sentry/integrations" "7.119.0"
|
||||
"@sentry/react" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry/react-native@5.5.0":
|
||||
version "5.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react-native/-/react-native-5.5.0.tgz#b1283f68465b1772ad6059ebba149673cef33f2d"
|
||||
@@ -6118,6 +6266,17 @@
|
||||
"@sentry/types" "7.52.0"
|
||||
"@sentry/utils" "7.52.0"
|
||||
|
||||
"@sentry/react@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.119.0.tgz#79f2c9d94322a3afbfa8af9f5b872f7c2e9b0820"
|
||||
integrity sha512-cf8Cei+qdSA26gx+IMAuc/k44PeBImNzIpXi3930SLhUe44ypT5OZ/44L6xTODHZzTIyMSJPduf59vT2+eW9yg==
|
||||
dependencies:
|
||||
"@sentry/browser" "7.119.0"
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
|
||||
"@sentry/react@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/react/-/react-7.52.0.tgz#d12d270ec82dea0474e69deb9112181affe7c524"
|
||||
@@ -6140,6 +6299,16 @@
|
||||
hoist-non-react-statics "^3.3.2"
|
||||
tslib "^1.9.3"
|
||||
|
||||
"@sentry/replay@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.119.0.tgz#50881079d013c77f87a994331d8bcad1d49e0960"
|
||||
integrity sha512-BnNsYL+X5I4WCH6wOpY6HQtp4MgVt0NVlhLUsEyrvMUiTs0bPkDBrulsgZQBUKJsbOr3l9nHrFoNVB/0i6WNLA==
|
||||
dependencies:
|
||||
"@sentry-internal/tracing" "7.119.0"
|
||||
"@sentry/core" "7.119.0"
|
||||
"@sentry/types" "7.119.0"
|
||||
"@sentry/utils" "7.119.0"
|
||||
|
||||
"@sentry/replay@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-7.52.0.tgz#4d78e88282d2c1044ea4b648a68d1b22173e810d"
|
||||
@@ -6158,6 +6327,11 @@
|
||||
"@sentry/types" "7.52.1"
|
||||
"@sentry/utils" "7.52.1"
|
||||
|
||||
"@sentry/types@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.119.0.tgz#8b3d7a1405c362e75cd900d46089df4e70919d2a"
|
||||
integrity sha512-27qQbutDBPKGbuJHROxhIWc1i0HJaGLA90tjMu11wt0E4UNxXRX+UQl4Twu68v4EV3CPvQcEpQfgsViYcXmq+w==
|
||||
|
||||
"@sentry/types@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.0.tgz#b7d5372f17355e3991cbe818ad567f3fe277cc6b"
|
||||
@@ -6168,6 +6342,13 @@
|
||||
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.52.1.tgz#bcff6d0462d9b9b7b9ec31c0068fe02d44f25da2"
|
||||
integrity sha512-OMbGBPrJsw0iEXwZ2bJUYxewI1IEAU2e1aQGc0O6QW5+6hhCh+8HO8Xl4EymqwejjztuwStkl6G1qhK+Q0/Row==
|
||||
|
||||
"@sentry/utils@7.119.0":
|
||||
version "7.119.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.119.0.tgz#debe29020f6ef3786a5bd855cf1b97116b7be826"
|
||||
integrity sha512-ZwyXexWn2ZIe2bBoYnXJVPc2esCSbKpdc6+0WJa8eutXfHq3FRKg4ohkfCBpfxljQGEfP1+kfin945lA21Ka+A==
|
||||
dependencies:
|
||||
"@sentry/types" "7.119.0"
|
||||
|
||||
"@sentry/utils@7.52.0":
|
||||
version "7.52.0"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-7.52.0.tgz#cacc36d905036ba7084c14965e964fc44239d7f0"
|
||||
@@ -12415,10 +12596,6 @@ expo-updates@~0.25.14:
|
||||
ignore "^5.3.1"
|
||||
resolve-from "^5.0.0"
|
||||
|
||||
"expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz":
|
||||
version "1.2.4"
|
||||
resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz#4127dd5cea5fdf7ab745104c73b8ecf5506f5d34"
|
||||
|
||||
expo-web-browser@~13.0.3:
|
||||
version "13.0.3"
|
||||
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"
|
||||
@@ -19031,10 +19208,10 @@ react-native-get-random-values@~1.11.0:
|
||||
dependencies:
|
||||
fast-base64-decode "^1.0.0"
|
||||
|
||||
react-native-image-crop-picker@0.40.3:
|
||||
version "0.40.3"
|
||||
resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.40.3.tgz#a6b135cd1218a33ad126c1a148ec5a1bd01737ff"
|
||||
integrity sha512-45PKcTnsnLS+E36YwoXutllQdRSOuOsMN0IRcAcwsFXOuAQIOtugXlAuGGL28JHKb/ATaSSPvqCSrdG65Jv3GA==
|
||||
react-native-image-crop-picker@0.41.2:
|
||||
version "0.41.2"
|
||||
resolved "https://registry.yarnpkg.com/react-native-image-crop-picker/-/react-native-image-crop-picker-0.41.2.tgz#824fa8fee8391fbb3e0b5ae2973221a2dff0cafb"
|
||||
integrity sha512-GcDu/adXU/1y/MrxsbOfqcGRGWC2pTttt5VGy/jyRJ6GXfoC29fTQf8SG5kGtc5schSR6K+mKYO4uW6eJPljlQ==
|
||||
|
||||
react-native-ios-context-menu@^1.15.3:
|
||||
version "1.15.3"
|
||||
@@ -19119,10 +19296,10 @@ react-native-svg@^15.3.0:
|
||||
css-select "^5.1.0"
|
||||
css-tree "^1.1.3"
|
||||
|
||||
react-native-uitextview@^1.1.7:
|
||||
version "1.1.7"
|
||||
resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.1.7.tgz#ee60ca279b0f4d081451f0df707b0ceac4580de1"
|
||||
integrity sha512-7NaeizflafRvdsuomR6F4UxGV3XY8ilgntX0npLkE3YyH0YJTikBAHunrRWEPtj1vjybYN2DuThMYRELcVqyuA==
|
||||
react-native-uitextview@^1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.3.0.tgz#78b24e2b790e1197c73758df30ed2bfb4b8a0c98"
|
||||
integrity sha512-UXW1xIg1qeYvwBvdL8BLIkqZhbQ5TVVcHJho3vKDjOK+FW4ZLPygefcDXaCTX56ztisV4ARxJNdzblRAytFCHQ==
|
||||
|
||||
react-native-url-polyfill@^1.3.0:
|
||||
version "1.3.0"
|
||||
@@ -20630,7 +20807,16 @@ string-natural-compare@^3.0.1:
|
||||
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
|
||||
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -20739,7 +20925,7 @@ stringify-object@^3.3.0:
|
||||
is-obj "^1.0.1"
|
||||
is-regexp "^1.0.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -20753,6 +20939,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
|
||||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
||||
@@ -22468,7 +22661,7 @@ workbox-window@6.6.1:
|
||||
"@types/trusted-types" "^2.0.2"
|
||||
workbox-core "6.6.1"
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -22486,6 +22679,15 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.0.1, wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
||||
Reference in New Issue
Block a user