Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-09-18 09:24:37 +09:00
98 changed files with 10373 additions and 8162 deletions
+1 -13
View File
@@ -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"
+2 -4
View File
@@ -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:
-1
View File
@@ -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

+45
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+1 -1
View File
@@ -256,7 +256,7 @@ export const ScrollableInner = React.forwardRef<
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
flatten(style),
style,
]}
contentContainerStyle={a.pb_4xl}
ref={ref}>
+11
View File
@@ -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>
}
+1
View File
@@ -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!`,
)}
+45
View File
@@ -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>
)
}
+2
View File
@@ -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>
)
+3 -1
View File
@@ -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>
)
}
+121
View File
@@ -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>
)
}
+22
View File
@@ -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
}
+5
View File
@@ -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',
})
+1 -1
View File
@@ -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
+3 -2
View File
@@ -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,
+15
View File
@@ -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
})
}
+1 -1
View File
@@ -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,
}
+1 -1
View File
@@ -71,7 +71,7 @@ export function useIntentHandler() {
}, [incomingUrl, composeIntent, verifyEmailIntent])
}
function useComposeIntent() {
export function useComposeIntent() {
const closeAllActiveElements = useCloseAllActiveElements()
const {openComposer} = useComposerControls()
const {hasSession} = useSession()
+4
View File
@@ -225,4 +225,8 @@ export type LogEvents = {
'test:gate1:sometimes': {}
'test:gate2:always': {}
'test:gate2:sometimes': {}
'tmd:share': {}
'tmd:download': {}
'tmd:post': {}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
variant="solid"
color={'primary'}
size="medium"
onPress={onPressNext}
disabled={!email}>
onPress={onPressNext}>
<ButtonText>
<Trans>Next</Trans>
</ButtonText>
+9 -22
View File
@@ -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>
)
+7 -7
View File
@@ -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,
})
}
}
+18 -7
View File
@@ -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']) {
+1 -25
View File
@@ -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) {
-19
View File
@@ -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()) {
+7 -18
View File
@@ -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,
}
+2
View File
@@ -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
+1 -3
View File
@@ -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
-2
View File
@@ -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[]
+6 -2
View File
@@ -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])
}
+3 -1
View File
@@ -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 {
+8 -16
View File
@@ -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 && (
+63 -10
View File
@@ -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"
+62 -5
View File
@@ -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,
+9
View File
@@ -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}
/>
)
+7 -1
View File
@@ -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}
/>
)
+4 -4
View File
@@ -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)
},
})
+12
View File
@@ -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>
) : (
+43 -15
View File
@@ -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}
+3 -1
View File
@@ -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: {
+14 -2
View File
@@ -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>
+10 -5
View File
@@ -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}}
+31 -111
View File
@@ -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
}
+29
View File
@@ -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>
)
}
+1
View File
@@ -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} />
+45
View File
@@ -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>
+217 -15
View File
@@ -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"