Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
+8
-11
@@ -52,7 +52,7 @@ 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 {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {Shell} from '#/view/shell'
|
||||
import {ThemeProvider as Alf} from '#/alf'
|
||||
@@ -60,7 +60,6 @@ import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Splash} from '#/Splash'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
|
||||
|
||||
@@ -127,15 +126,13 @@ function InnerApp() {
|
||||
<UnreadNotifsProvider>
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<GestureHandlerRootView
|
||||
style={s.h100pct}>
|
||||
<TestCtrls />
|
||||
<Shell />
|
||||
</GestureHandlerRootView>
|
||||
</ProgressGuideProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
</UnreadNotifsProvider>
|
||||
|
||||
+4
-7
@@ -40,7 +40,7 @@ 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 {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
|
||||
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||
import {Shell} from '#/view/shell/index'
|
||||
@@ -48,7 +48,6 @@ import {ThemeProvider as Alf} from '#/alf'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
|
||||
import {Provider as PortalProvider} from '#/components/Portal'
|
||||
import {Provider as TourProvider} from '#/tours'
|
||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||
|
||||
function InnerApp() {
|
||||
@@ -111,11 +110,9 @@ function InnerApp() {
|
||||
<BackgroundNotificationPreferencesProvider>
|
||||
<MutedThreadsProvider>
|
||||
<SafeAreaProvider>
|
||||
<TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</TourProvider>
|
||||
<ProgressGuideProvider>
|
||||
<Shell />
|
||||
</ProgressGuideProvider>
|
||||
</SafeAreaProvider>
|
||||
</MutedThreadsProvider>
|
||||
</BackgroundNotificationPreferencesProvider>
|
||||
|
||||
@@ -178,7 +178,7 @@ let ListMaybePlaceholder = ({
|
||||
return (
|
||||
<CenteredView
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.h_full_vh,
|
||||
a.align_center,
|
||||
!gtMobile ? a.justify_between : a.gap_5xl,
|
||||
t.atoms.border_contrast_low,
|
||||
|
||||
@@ -5,10 +5,10 @@ export function useInteractionState() {
|
||||
|
||||
const onIn = React.useCallback(() => {
|
||||
setState(true)
|
||||
}, [setState])
|
||||
}, [])
|
||||
const onOut = React.useCallback(() => {
|
||||
setState(false)
|
||||
}, [setState])
|
||||
}, [])
|
||||
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -392,27 +392,20 @@ export class FeedTuner {
|
||||
slices: FeedViewPostsSlice[],
|
||||
_dryRun: boolean,
|
||||
): FeedViewPostsSlice[] => {
|
||||
const candidateSlices = slices.slice()
|
||||
|
||||
// early return if no languages have been specified
|
||||
if (!preferredLangsCode2.length || preferredLangsCode2.length === 0) {
|
||||
return slices
|
||||
}
|
||||
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
let hasPreferredLang = false
|
||||
for (const item of slices[i].items) {
|
||||
const candidateSlices = slices.filter(slice => {
|
||||
for (const item of slice.items) {
|
||||
if (isPostInLanguage(item.post, preferredLangsCode2)) {
|
||||
hasPreferredLang = true
|
||||
break
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// if item does not fit preferred language, remove it
|
||||
if (!hasPreferredLang) {
|
||||
candidateSlices.splice(i, 1)
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// if the language filter cleared out the entire page, return the original set
|
||||
// so that something always shows
|
||||
|
||||
+27
-11
@@ -3,12 +3,14 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedPostgate,
|
||||
AtUri,
|
||||
BlobRef,
|
||||
BskyAgent,
|
||||
ComAtprotoLabelDefs,
|
||||
RichText,
|
||||
} from '@atproto/api'
|
||||
import {AtUri} from '@atproto/api'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {writePostgateRecord} from '#/state/queries/postgate'
|
||||
@@ -43,10 +45,7 @@ interface PostOpts {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: {
|
||||
uri: string
|
||||
cid: string
|
||||
}
|
||||
video?: BlobRef
|
||||
extLink?: ExternalEmbedDraft
|
||||
images?: ImageModel[]
|
||||
labels?: string[]
|
||||
@@ -61,18 +60,16 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
| AppBskyEmbedImages.Main
|
||||
| AppBskyEmbedExternal.Main
|
||||
| AppBskyEmbedRecord.Main
|
||||
| AppBskyEmbedVideo.Main
|
||||
| AppBskyEmbedRecordWithMedia.Main
|
||||
| undefined
|
||||
let reply
|
||||
let rt = new RichText(
|
||||
{text: opts.rawText.trimEnd()},
|
||||
{
|
||||
cleanNewlines: true,
|
||||
},
|
||||
)
|
||||
let rt = new RichText({text: opts.rawText.trimEnd()}, {cleanNewlines: true})
|
||||
|
||||
opts.onStateChange?.('Processing...')
|
||||
|
||||
await rt.detectFacets(agent)
|
||||
|
||||
rt = shortenLinks(rt)
|
||||
rt = stripInvalidMentions(rt)
|
||||
|
||||
@@ -129,6 +126,25 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||
}
|
||||
}
|
||||
|
||||
// add video embed if present
|
||||
if (opts.video) {
|
||||
if (opts.quote) {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.recordWithMedia',
|
||||
record: embed,
|
||||
media: {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
} as AppBskyEmbedVideo.Main,
|
||||
} as AppBskyEmbedRecordWithMedia.Main
|
||||
} else {
|
||||
embed = {
|
||||
$type: 'app.bsky.embed.video',
|
||||
video: opts.video,
|
||||
} as AppBskyEmbedVideo.Main
|
||||
}
|
||||
}
|
||||
|
||||
// add external embed if present
|
||||
if (opts.extLink && !opts.images?.length) {
|
||||
if (opts.extLink.embed) {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function cancelable<A, T>(
|
||||
f: (args: A) => Promise<T>,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
return (args: A) => {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new AbortError())
|
||||
})
|
||||
f(args).then(resolve, reject)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AbortError extends Error {
|
||||
constructor() {
|
||||
super('Aborted')
|
||||
this.name = 'AbortError'
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import React from 'react'
|
||||
import {Dimensions} from 'react-native'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
|
||||
|
||||
const MIN_POST_HEIGHT = 100
|
||||
|
||||
export function useInitialNumToRender(minItemHeight: number = MIN_POST_HEIGHT) {
|
||||
return React.useMemo(() => {
|
||||
const screenHeight = Dimensions.get('window').height
|
||||
return Math.ceil(screenHeight / minItemHeight) + 1
|
||||
}, [minItemHeight])
|
||||
export function useInitialNumToRender({
|
||||
minItemHeight = MIN_POST_HEIGHT,
|
||||
screenHeightOffset = 0,
|
||||
}: {minItemHeight?: number; screenHeightOffset?: number} = {}) {
|
||||
const {height: screenHeight} = useWindowDimensions()
|
||||
const {top: topInset} = useSafeAreaInsets()
|
||||
const bottomBarHeight = useBottomBarOffset()
|
||||
|
||||
const finalHeight =
|
||||
screenHeight - screenHeightOffset - topInset - bottomBarHeight
|
||||
return Math.floor(finalHeight / minItemHeight) + 1
|
||||
}
|
||||
|
||||
@@ -8,19 +8,25 @@ export type CompressedVideo = {
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
opts?: {
|
||||
getCancellationId?: (id: string) => void
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const {onProgress, getCancellationId} = opts || {}
|
||||
const {onProgress, signal} = opts || {}
|
||||
|
||||
const compressed = await Video.compress(
|
||||
file,
|
||||
{
|
||||
getCancellationId,
|
||||
compressionMethod: 'manual',
|
||||
bitrate: 3_000_000, // 3mbps
|
||||
maxSize: 1920,
|
||||
getCancellationId: id => {
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => {
|
||||
Video.cancelCompression(id)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
onProgress,
|
||||
)
|
||||
|
||||
@@ -10,8 +10,9 @@ export type CompressedVideo = {
|
||||
// doesn't actually compress, but throws if >100MB
|
||||
export async function compressVideo(
|
||||
file: string,
|
||||
_callbacks?: {
|
||||
onProgress: (progress: number) => void
|
||||
_opts?: {
|
||||
signal?: AbortSignal
|
||||
onProgress?: (progress: number) => void
|
||||
},
|
||||
): Promise<CompressedVideo> {
|
||||
const blob = await fetch(file).then(res => res.blob())
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* TEMPORARY: THIS IS A TEMPORARY PLACEHOLDER. THAT MEANS IT IS TEMPORARY. I.E. WILL BE REMOVED. NOT TO USE IN PRODUCTION.
|
||||
* @temporary
|
||||
* PS: This is a temporary placeholder for the video types. It will be removed once the actual types are implemented.
|
||||
* Not joking, this is temporary.
|
||||
*/
|
||||
|
||||
export interface JobStatus {
|
||||
jobId: string
|
||||
did: string
|
||||
cid: string
|
||||
state: JobState
|
||||
progress?: number
|
||||
errorHuman?: string
|
||||
errorMachine?: string
|
||||
}
|
||||
|
||||
export enum JobState {
|
||||
JOB_STATE_UNSPECIFIED = 'JOB_STATE_UNSPECIFIED',
|
||||
JOB_STATE_CREATED = 'JOB_STATE_CREATED',
|
||||
JOB_STATE_ENCODING = 'JOB_STATE_ENCODING',
|
||||
JOB_STATE_ENCODED = 'JOB_STATE_ENCODED',
|
||||
JOB_STATE_UPLOADING = 'JOB_STATE_UPLOADING',
|
||||
JOB_STATE_UPLOADED = 'JOB_STATE_UPLOADED',
|
||||
JOB_STATE_CDN_PROCESSING = 'JOB_STATE_CDN_PROCESSING',
|
||||
JOB_STATE_CDN_PROCESSED = 'JOB_STATE_CDN_PROCESSED',
|
||||
JOB_STATE_FAILED = 'JOB_STATE_FAILED',
|
||||
JOB_STATE_COMPLETED = 'JOB_STATE_COMPLETED',
|
||||
}
|
||||
|
||||
export interface UploadVideoResponse {
|
||||
job_id: string
|
||||
did: string
|
||||
cid: string
|
||||
state: JobState
|
||||
}
|
||||
@@ -216,12 +216,6 @@ export type LogEvents = {
|
||||
|
||||
'profile:header:suggestedFollowsCard:press': {}
|
||||
|
||||
'debug:followingPrefs': {
|
||||
followingShowRepliesFromPref: 'all' | 'following' | 'off'
|
||||
followingRepliesMinLikePref: number
|
||||
}
|
||||
'debug:followingDisplayed': {}
|
||||
|
||||
'test:all:always': {}
|
||||
'test:all:sometimes': {}
|
||||
'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'}
|
||||
|
||||
@@ -2,9 +2,7 @@ export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
| 'debug_show_feedcontext'
|
||||
| 'fixed_bottom_bar'
|
||||
| 'new_user_guided_tour'
|
||||
| 'onboarding_minimum_interests'
|
||||
| 'show_follow_back_label_v2'
|
||||
| 'suggested_feeds_interstitial'
|
||||
| 'video_debug'
|
||||
| 'videos'
|
||||
|
||||
@@ -339,3 +339,21 @@ export function shortLinkToHref(url: string): string {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function getHostnameFromUrl(url: string): string | null {
|
||||
let urlp
|
||||
try {
|
||||
urlp = new URL(url)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
return urlp.hostname
|
||||
}
|
||||
|
||||
export function getServiceAuthAudFromUrl(url: string): string | null {
|
||||
const hostname = getHostnameFromUrl(url)
|
||||
if (!hostname) {
|
||||
return null
|
||||
}
|
||||
return `did:web:${hostname}`
|
||||
}
|
||||
|
||||
+94
-94
@@ -68,7 +68,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'alt', code2: '', name: 'Southern Altai'},
|
||||
{code3: 'amh', code2: 'am', name: 'Amharic'},
|
||||
{code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)'},
|
||||
{code3: 'anp ', code2: 'Angika', name: 'Angika'},
|
||||
{code3: 'anp', code2: '', name: 'Angika'},
|
||||
{code3: 'apa', code2: '', name: 'Apache languages'},
|
||||
{code3: 'ara', code2: 'ar', name: 'Arabic'},
|
||||
{
|
||||
@@ -233,7 +233,7 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'gre', code2: 'el', name: 'Greek, Modern (1453-)'},
|
||||
{code3: 'grn', code2: 'gn', name: 'Guarani'},
|
||||
{code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian'},
|
||||
{code3: 'gujgu', code2: 'Gujarati', name: 'goudjrati'},
|
||||
{code3: 'guj', code2: 'gu', name: 'Gujarati'},
|
||||
{code3: 'gwi', code2: '', name: "Gwich'in"},
|
||||
{code3: 'hai', code2: '', name: 'Haida'},
|
||||
{code3: 'hat', code2: 'ht', name: 'Haitian; Haitian Creole'},
|
||||
@@ -339,8 +339,8 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'lun', code2: '', name: 'Lunda'},
|
||||
{
|
||||
code3: 'luo',
|
||||
code2: ' Luo (Kenya and Tanzania)',
|
||||
name: 'luo (Kenya et Tanzanie)',
|
||||
code2: '',
|
||||
name: 'Luo (Kenya and Tanzania)',
|
||||
},
|
||||
{code3: 'lus', code2: '', name: 'Lushai'},
|
||||
{code3: 'mac', code2: 'mk', name: 'Macedonian'},
|
||||
@@ -430,162 +430,162 @@ export const LANGUAGES: Language[] = [
|
||||
{code3: 'oto', code2: '', name: 'Otomian languages'},
|
||||
{code3: 'paa', code2: '', name: 'Papuan languages'},
|
||||
{code3: 'pag', code2: '', name: 'Pangasinan'},
|
||||
{code3: 'pal', code2: ' ', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: ' ', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'paPanjabi; Punjabi', name: 'pendjabi'},
|
||||
{code3: 'pap', code2: ' ', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: ' ', name: 'Palauan'},
|
||||
{code3: 'peo', code2: ' ', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'pal', code2: '', name: 'Pahlavi'},
|
||||
{code3: 'pam', code2: '', name: 'Pampanga; Kapampangan'},
|
||||
{code3: 'pan', code2: 'pa', name: 'Panjabi; Punjabi'},
|
||||
{code3: 'pap', code2: '', name: 'Papiamento'},
|
||||
{code3: 'pau', code2: '', name: 'Palauan'},
|
||||
{code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)'},
|
||||
{code3: 'per', code2: 'fa', name: 'Persian'},
|
||||
{code3: 'phi', code2: ' ', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: ' ', name: 'Phoenician'},
|
||||
{code3: 'phi', code2: '', name: 'Philippine languages'},
|
||||
{code3: 'phn', code2: '', name: 'Phoenician'},
|
||||
{code3: 'pli', code2: 'pi', name: 'Pali'},
|
||||
{code3: 'pol', code2: 'pl', name: 'Polish'},
|
||||
{code3: 'pon', code2: ' ', name: 'Pohnpeian'},
|
||||
{code3: 'pon', code2: '', name: 'Pohnpeian'},
|
||||
{code3: 'por', code2: 'pt', name: 'Portuguese'},
|
||||
{code3: 'pra', code2: ' ', name: 'Prakrit languages'},
|
||||
{code3: 'pra', code2: '', name: 'Prakrit languages'},
|
||||
{
|
||||
code3: 'pro',
|
||||
code2: ' ',
|
||||
code2: '',
|
||||
name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)',
|
||||
},
|
||||
{code3: 'pus', code2: 'ps', name: 'Pushto; Pashto'},
|
||||
{code3: 'que', code2: 'qu', name: 'Quechua'},
|
||||
{code3: 'raj', code2: ' ', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: ' ', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: ' ', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: ' ', name: 'Romance languages'},
|
||||
{code3: 'raj', code2: '', name: 'Rajasthani'},
|
||||
{code3: 'rap', code2: '', name: 'Rapanui'},
|
||||
{code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori'},
|
||||
{code3: 'roa', code2: '', name: 'Romance languages'},
|
||||
{code3: 'roh', code2: 'rm', name: 'Romansh'},
|
||||
{code3: 'rom', code2: ' ', name: 'Romany'},
|
||||
{code3: 'rom', code2: '', name: 'Romany'},
|
||||
{code3: 'rum', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||
{code3: 'ron', code2: 'ro', name: 'Romanian; Moldavian; Moldovan'},
|
||||
{code3: 'run', code2: 'rn', name: 'Rundi'},
|
||||
{code3: 'rup', code2: ' ', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
||||
{code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian'},
|
||||
{code3: 'rus', code2: 'ru', name: 'Russian'},
|
||||
{code3: 'sad', code2: ' ', name: 'Sandawe'},
|
||||
{code3: 'sad', code2: '', name: 'Sandawe'},
|
||||
{code3: 'sag', code2: 'sg', name: 'Sango'},
|
||||
{code3: 'sah', code2: ' ', name: 'Yakut'},
|
||||
{code3: 'sai', code2: ' ', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: ' ', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: ' ', name: 'Samaritan Aramaic'},
|
||||
{code3: 'sah', code2: '', name: 'Yakut'},
|
||||
{code3: 'sai', code2: '', name: 'South American Indian languages'},
|
||||
{code3: 'sal', code2: '', name: 'Salishan languages'},
|
||||
{code3: 'sam', code2: '', name: 'Samaritan Aramaic'},
|
||||
{code3: 'san', code2: 'sa', name: 'Sanskrit'},
|
||||
{code3: 'sas', code2: ' ', name: 'Sasak'},
|
||||
{code3: 'sat', code2: ' ', name: 'Santali'},
|
||||
{code3: 'scn', code2: ' ', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: ' ', name: 'Scots'},
|
||||
{code3: 'sel', code2: ' ', name: 'Selkup'},
|
||||
{code3: 'sem', code2: ' ', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: ' ', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: ' ', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: ' ', name: 'Shan'},
|
||||
{code3: 'sid', code2: ' ', name: 'Sidamo'},
|
||||
{code3: 'sas', code2: '', name: 'Sasak'},
|
||||
{code3: 'sat', code2: '', name: 'Santali'},
|
||||
{code3: 'scn', code2: '', name: 'Sicilian'},
|
||||
{code3: 'sco', code2: '', name: 'Scots'},
|
||||
{code3: 'sel', code2: '', name: 'Selkup'},
|
||||
{code3: 'sem', code2: '', name: 'Semitic languages'},
|
||||
{code3: 'sga', code2: '', name: 'Irish, Old (to 900)'},
|
||||
{code3: 'sgn', code2: '', name: 'Sign Languages'},
|
||||
{code3: 'shn', code2: '', name: 'Shan'},
|
||||
{code3: 'sid', code2: '', name: 'Sidamo'},
|
||||
{code3: 'sin', code2: 'si', name: 'Sinhala; Sinhalese'},
|
||||
{code3: 'sio', code2: ' ', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: ' ', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: ' ', name: 'Slavic languages'},
|
||||
{code3: 'sio', code2: '', name: 'Siouan languages'},
|
||||
{code3: 'sit', code2: '', name: 'Sino-Tibetan languages'},
|
||||
{code3: 'sla', code2: '', name: 'Slavic languages'},
|
||||
{code3: 'slo', code2: 'sk', name: 'Slovak'},
|
||||
{code3: 'slk', code2: 'sk', name: 'Slovak'},
|
||||
{code3: 'slv', code2: 'sl', name: 'Slovenian'},
|
||||
{code3: 'sma', code2: ' ', name: 'Southern Sami'},
|
||||
{code3: 'sma', code2: '', name: 'Southern Sami'},
|
||||
{code3: 'sme', code2: 'se', name: 'Northern Sami'},
|
||||
{code3: 'smi', code2: ' ', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: ' ', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: ' ', name: 'Inari Sami'},
|
||||
{code3: 'smi', code2: '', name: 'Sami languages'},
|
||||
{code3: 'smj', code2: '', name: 'Lule Sami'},
|
||||
{code3: 'smn', code2: '', name: 'Inari Sami'},
|
||||
{code3: 'smo', code2: 'sm', name: 'Samoan'},
|
||||
{code3: 'sms', code2: ' ', name: 'Skolt Sami'},
|
||||
{code3: 'sms', code2: '', name: 'Skolt Sami'},
|
||||
{code3: 'sna', code2: 'sn', name: 'Shona'},
|
||||
{code3: 'snd', code2: 'sd', name: 'Sindhi'},
|
||||
{code3: 'snk', code2: ' ', name: 'Soninke'},
|
||||
{code3: 'sog', code2: ' ', name: 'Sogdian'},
|
||||
{code3: 'snk', code2: '', name: 'Soninke'},
|
||||
{code3: 'sog', code2: '', name: 'Sogdian'},
|
||||
{code3: 'som', code2: 'so', name: 'Somali'},
|
||||
{code3: 'son', code2: ' ', name: 'Songhai languages'},
|
||||
{code3: 'son', code2: '', name: 'Songhai languages'},
|
||||
{code3: 'sot', code2: 'st', name: 'Sotho, Southern'},
|
||||
{code3: 'spa', code2: 'es', name: 'Spanish'},
|
||||
{code3: 'sqi', code2: 'sq', name: 'Albanian'},
|
||||
{code3: 'srd', code2: 'sc', name: 'Sardinian'},
|
||||
{code3: 'srn', code2: ' ', name: 'Sranan Tongo'},
|
||||
{code3: 'srn', code2: '', name: 'Sranan Tongo'},
|
||||
{code3: 'srp', code2: 'sr', name: 'Serbian'},
|
||||
{code3: 'srr', code2: ' ', name: 'Serer'},
|
||||
{code3: 'ssa', code2: ' ', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'srr', code2: '', name: 'Serer'},
|
||||
{code3: 'ssa', code2: '', name: 'Nilo-Saharan languages'},
|
||||
{code3: 'ssw', code2: 'ss', name: 'Swati'},
|
||||
{code3: 'suk', code2: ' ', name: 'Sukuma'},
|
||||
{code3: 'suk', code2: '', name: 'Sukuma'},
|
||||
{code3: 'sun', code2: 'su', name: 'Sundanese'},
|
||||
{code3: 'sus', code2: ' ', name: 'Susu'},
|
||||
{code3: 'sux', code2: ' ', name: 'Sumerian'},
|
||||
{code3: 'sus', code2: '', name: 'Susu'},
|
||||
{code3: 'sux', code2: '', name: 'Sumerian'},
|
||||
{code3: 'swa', code2: 'sw', name: 'Swahili'},
|
||||
{code3: 'swe', code2: 'sv', name: 'Swedish'},
|
||||
{code3: 'syc', code2: ' ', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: ' ', name: 'Syriac'},
|
||||
{code3: 'syc', code2: '', name: 'Classical Syriac'},
|
||||
{code3: 'syr', code2: '', name: 'Syriac'},
|
||||
{code3: 'tah', code2: 'ty', name: 'Tahitian'},
|
||||
{code3: 'tai', code2: ' ', name: 'Tai languages'},
|
||||
{code3: 'tai', code2: '', name: 'Tai languages'},
|
||||
{code3: 'tam', code2: 'ta', name: 'Tamil'},
|
||||
{code3: 'tat', code2: 'tt', name: 'Tatar'},
|
||||
{code3: 'tel', code2: 'te', name: 'Telugu'},
|
||||
{code3: 'tem', code2: ' ', name: 'Timne'},
|
||||
{code3: 'ter', code2: ' ', name: 'Tereno'},
|
||||
{code3: 'tet', code2: ' ', name: 'Tetum'},
|
||||
{code3: 'tem', code2: '', name: 'Timne'},
|
||||
{code3: 'ter', code2: '', name: 'Tereno'},
|
||||
{code3: 'tet', code2: '', name: 'Tetum'},
|
||||
{code3: 'tgk', code2: 'tg', name: 'Tajik'},
|
||||
{code3: 'tgl', code2: 'tl', name: 'Tagalog'},
|
||||
{code3: 'tha', code2: 'th', name: 'Thai'},
|
||||
{code3: 'tib', code2: 'bo', name: 'Tibetan'},
|
||||
{code3: 'tig', code2: ' ', name: 'Tigre'},
|
||||
{code3: 'tig', code2: '', name: 'Tigre'},
|
||||
{code3: 'tir', code2: 'ti', name: 'Tigrinya'},
|
||||
{code3: 'tiv', code2: ' ', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: ' ', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: ' ', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: ' ', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: ' ', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: ' ', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'tiv', code2: '', name: 'Tiv'},
|
||||
{code3: 'tkl', code2: '', name: 'Tokelau'},
|
||||
{code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol'},
|
||||
{code3: 'tli', code2: '', name: 'Tlingit'},
|
||||
{code3: 'tmh', code2: '', name: 'Tamashek'},
|
||||
{code3: 'tog', code2: '', name: 'Tonga (Nyasa)'},
|
||||
{code3: 'ton', code2: 'to', name: 'Tonga (Tonga Islands)'},
|
||||
{code3: 'tpi', code2: ' ', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: ' ', name: 'Tsimshian'},
|
||||
{code3: 'tpi', code2: '', name: 'Tok Pisin'},
|
||||
{code3: 'tsi', code2: '', name: 'Tsimshian'},
|
||||
{code3: 'tsn', code2: 'tn', name: 'Tswana'},
|
||||
{code3: 'tso', code2: 'ts', name: 'Tsonga'},
|
||||
{code3: 'tuk', code2: 'tk', name: 'Turkmen'},
|
||||
{code3: 'tum', code2: ' ', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: ' ', name: 'Tupi languages'},
|
||||
{code3: 'tum', code2: '', name: 'Tumbuka'},
|
||||
{code3: 'tup', code2: '', name: 'Tupi languages'},
|
||||
{code3: 'tur', code2: 'tr', name: 'Turkish'},
|
||||
{code3: 'tut', code2: ' ', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: ' ', name: 'Tuvalu'},
|
||||
{code3: 'tut', code2: '', name: 'Altaic languages'},
|
||||
{code3: 'tvl', code2: '', name: 'Tuvalu'},
|
||||
{code3: 'twi', code2: 'tw', name: 'Twi'},
|
||||
{code3: 'tyv', code2: ' ', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: ' ', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: ' ', name: 'Ugaritic'},
|
||||
{code3: 'tyv', code2: '', name: 'Tuvinian'},
|
||||
{code3: 'udm', code2: '', name: 'Udmurt'},
|
||||
{code3: 'uga', code2: '', name: 'Ugaritic'},
|
||||
{code3: 'uig', code2: 'ug', name: 'Uighur; Uyghur'},
|
||||
{code3: 'ukr', code2: 'uk', name: 'Ukrainian'},
|
||||
{code3: 'umb', code2: ' ', name: 'Umbundu'},
|
||||
{code3: 'und', code2: ' ', name: 'Undetermined'},
|
||||
{code3: 'umb', code2: '', name: 'Umbundu'},
|
||||
{code3: 'und', code2: '', name: 'Undetermined'},
|
||||
{code3: 'urd', code2: 'ur', name: 'Urdu'},
|
||||
{code3: 'uzb', code2: 'uz', name: 'Uzbek'},
|
||||
{code3: 'vai', code2: ' ', name: 'Vai'},
|
||||
{code3: 'vai', code2: '', name: 'Vai'},
|
||||
{code3: 'ven', code2: 've', name: 'Venda'},
|
||||
{code3: 'vie', code2: 'vi', name: 'Vietnamese'},
|
||||
{code3: 'vol', code2: 'vo', name: 'Volapük'},
|
||||
{code3: 'vot', code2: ' ', name: 'Votic'},
|
||||
{code3: 'wak', code2: ' ', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: ' ', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: ' ', name: 'Waray'},
|
||||
{code3: 'was', code2: ' ', name: 'Washo'},
|
||||
{code3: 'vot', code2: '', name: 'Votic'},
|
||||
{code3: 'wak', code2: '', name: 'Wakashan languages'},
|
||||
{code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta'},
|
||||
{code3: 'war', code2: '', name: 'Waray'},
|
||||
{code3: 'was', code2: '', name: 'Washo'},
|
||||
{code3: 'wel', code2: 'cy', name: 'Welsh'},
|
||||
{code3: 'wen', code2: ' ', name: 'Sorbian languages'},
|
||||
{code3: 'wen', code2: '', name: 'Sorbian languages'},
|
||||
{code3: 'wln', code2: 'wa', name: 'Walloon'},
|
||||
{code3: 'wol', code2: 'wo', name: 'Wolof'},
|
||||
{code3: 'xal', code2: ' ', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xal', code2: '', name: 'Kalmyk; Oirat'},
|
||||
{code3: 'xho', code2: 'xh', name: 'Xhosa'},
|
||||
{code3: 'yao', code2: ' ', name: 'Yao'},
|
||||
{code3: 'yap', code2: ' ', name: 'Yapese'},
|
||||
{code3: 'yao', code2: '', name: 'Yao'},
|
||||
{code3: 'yap', code2: '', name: 'Yapese'},
|
||||
{code3: 'yid', code2: 'yi', name: 'Yiddish'},
|
||||
{code3: 'yor', code2: 'yo', name: 'Yoruba'},
|
||||
{code3: 'ypk', code2: ' ', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: ' ', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: ' ', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: ' ', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: ' ', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'ypk', code2: '', name: 'Yupik languages'},
|
||||
{code3: 'zap', code2: '', name: 'Zapotec'},
|
||||
{code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss'},
|
||||
{code3: 'zen', code2: '', name: 'Zenaga'},
|
||||
{code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight'},
|
||||
{code3: 'zha', code2: 'za', name: 'Zhuang; Chuang'},
|
||||
{code3: 'zho', code2: 'zh', name: 'Chinese'},
|
||||
{code3: 'znd', code2: ' ', name: 'Zande languages'},
|
||||
{code3: 'znd', code2: '', name: 'Zande languages'},
|
||||
{code3: 'zul', code2: 'zu', name: 'Zulu'},
|
||||
{code3: 'zun', code2: ' ', name: 'Zuni'},
|
||||
{code3: 'zun', code2: '', name: 'Zuni'},
|
||||
{
|
||||
code3: 'zza',
|
||||
code2: '',
|
||||
|
||||
+13
-15
@@ -1,12 +1,11 @@
|
||||
import React from 'react'
|
||||
import {ListRenderItemInfo, Pressable, StyleSheet, View} from 'react-native'
|
||||
import {ListRenderItemInfo, Pressable, View} from 'react-native'
|
||||
import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
import {NativeStackScreenProps} from '@react-navigation/native-stack'
|
||||
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {HITSLOP_10} from 'lib/constants'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {CommonNavigatorParams} from 'lib/routes/types'
|
||||
@@ -39,7 +38,6 @@ export default function HashtagScreen({
|
||||
}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
|
||||
const {tag, author} = route.params
|
||||
const {_} = useLingui()
|
||||
const pal = usePalette('default')
|
||||
|
||||
const fullTag = React.useMemo(() => {
|
||||
return `#${decodeURIComponent(tag)}`
|
||||
@@ -111,7 +109,7 @@ export default function HashtagScreen({
|
||||
|
||||
return (
|
||||
<>
|
||||
<CenteredView sideBorders style={[pal.border, pal.view]}>
|
||||
<CenteredView sideBorders={true}>
|
||||
<ViewHeader
|
||||
showOnDesktop
|
||||
title={headerTitle}
|
||||
@@ -138,8 +136,17 @@ export default function HashtagScreen({
|
||||
onPageSelected={onPageSelected}
|
||||
renderTabBar={props => (
|
||||
<CenteredView
|
||||
sideBorders
|
||||
style={[pal.border, pal.view, styles.tabBarContainer]}>
|
||||
sideBorders={true}
|
||||
// @ts-ignore web only
|
||||
style={
|
||||
isWeb
|
||||
? {
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<TabBar items={sections.map(section => section.title)} {...props} />
|
||||
</CenteredView>
|
||||
)}
|
||||
@@ -234,12 +241,3 @@ function HashtagScreenTab({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
tabBarContainer: {
|
||||
// @ts-ignore web only
|
||||
position: isWeb ? 'sticky' : '',
|
||||
top: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -96,7 +96,7 @@ export function MessagesScreen({navigation, route}: Props) {
|
||||
)
|
||||
}, [_, t])
|
||||
|
||||
const initialNumToRender = useInitialNumToRender(80)
|
||||
const initialNumToRender = useInitialNumToRender({minItemHeight: 80})
|
||||
const [isPTRing, setIsPTRing] = useState(false)
|
||||
|
||||
const {
|
||||
|
||||
@@ -44,7 +44,6 @@ import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
|
||||
import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
|
||||
import {Loader} from '#/components/Loader'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {TOURS, useSetQueuedTour} from '#/tours'
|
||||
|
||||
export function StepFinished() {
|
||||
const {_} = useLingui()
|
||||
@@ -59,7 +58,6 @@ export function StepFinished() {
|
||||
const activeStarterPack = useActiveStarterPack()
|
||||
const setActiveStarterPack = useSetActiveStarterPack()
|
||||
const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
|
||||
const setQueuedTour = useSetQueuedTour()
|
||||
const {startProgressGuide} = useProgressGuideControls()
|
||||
|
||||
const finishOnboarding = React.useCallback(async () => {
|
||||
@@ -189,7 +187,6 @@ export function StepFinished() {
|
||||
setSaving(false)
|
||||
setActiveStarterPack(undefined)
|
||||
setHasCheckedForStarterPack(true)
|
||||
setQueuedTour(TOURS.HOME)
|
||||
startProgressGuide('like-10-and-follow-7')
|
||||
dispatch({type: 'finish'})
|
||||
onboardDispatch({type: 'finish'})
|
||||
@@ -223,7 +220,6 @@ export function StepFinished() {
|
||||
requestNotificationsPermission,
|
||||
setActiveStarterPack,
|
||||
setHasCheckedForStarterPack,
|
||||
setQueuedTour,
|
||||
startProgressGuide,
|
||||
])
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {isNative} from '#/platform/detection'
|
||||
import {FeedDescriptor} from '#/state/queries/post-feed'
|
||||
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {Feed} from 'view/com/posts/Feed'
|
||||
@@ -42,6 +43,10 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
const queryClient = useQueryClient()
|
||||
const [hasNew, setHasNew] = React.useState(false)
|
||||
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
|
||||
const shouldUseAdjustedNumToRender = feed.endsWith('posts_and_author_threads')
|
||||
const adjustedInitialNumToRender = useInitialNumToRender({
|
||||
screenHeightOffset: headerHeight,
|
||||
})
|
||||
|
||||
const onScrollToTop = React.useCallback(() => {
|
||||
scrollElRef.current?.scrollToOffset({
|
||||
@@ -79,7 +84,9 @@ export const ProfileFeedSection = React.forwardRef<
|
||||
headerOffset={headerHeight}
|
||||
renderEndOfFeed={ProfileEndOfFeed}
|
||||
ignoreFilterFor={ignoreFilterFor}
|
||||
outsideHeaderOffset={headerHeight}
|
||||
initialNumToRender={
|
||||
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||
}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -41,10 +41,9 @@ export function StepCaptcha() {
|
||||
(code: string) => {
|
||||
setCompleted(true)
|
||||
logEvent('signup:captchaSuccess', {})
|
||||
const submitTask = {code, mutableProcessed: false}
|
||||
dispatch({
|
||||
type: 'submit',
|
||||
task: submitTask,
|
||||
task: {verificationCode: code, mutableProcessed: false},
|
||||
})
|
||||
},
|
||||
[dispatch],
|
||||
|
||||
@@ -65,8 +65,10 @@ export function StepHandle() {
|
||||
})
|
||||
// phoneVerificationRequired is actually whether a captcha is required
|
||||
if (!state.serviceDescription?.phoneVerificationRequired) {
|
||||
const submitTask = {code: undefined, mutableProcessed: false}
|
||||
dispatch({type: 'submit', task: submitTask})
|
||||
dispatch({
|
||||
type: 'submit',
|
||||
task: {verificationCode: undefined, mutableProcessed: false},
|
||||
})
|
||||
return
|
||||
}
|
||||
dispatch({type: 'next'})
|
||||
|
||||
@@ -27,7 +27,7 @@ export enum SignupStep {
|
||||
}
|
||||
|
||||
type SubmitTask = {
|
||||
code: string | undefined
|
||||
verificationCode: string | undefined
|
||||
mutableProcessed: boolean // OK to mutate assuming it's never read in render.
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ export type SignupAction =
|
||||
| {type: 'setDateOfBirth'; value: Date}
|
||||
| {type: 'setInviteCode'; value: string}
|
||||
| {type: 'setHandle'; value: string}
|
||||
| {type: 'setVerificationCode'; value: string}
|
||||
| {type: 'setError'; value: string}
|
||||
| {type: 'setIsLoading'; value: boolean}
|
||||
| {type: 'submit'; task: SubmitTask}
|
||||
@@ -189,11 +188,7 @@ export function useSubmitSignup() {
|
||||
const onboardingDispatch = useOnboardingDispatch()
|
||||
|
||||
return useCallback(
|
||||
async (
|
||||
state: SignupState,
|
||||
dispatch: (action: SignupAction) => void,
|
||||
verificationCode?: string,
|
||||
) => {
|
||||
async (state: SignupState, dispatch: (action: SignupAction) => void) => {
|
||||
if (!state.email) {
|
||||
dispatch({type: 'setStep', value: SignupStep.INFO})
|
||||
return dispatch({
|
||||
@@ -224,7 +219,7 @@ export function useSubmitSignup() {
|
||||
}
|
||||
if (
|
||||
state.serviceDescription?.phoneVerificationRequired &&
|
||||
!verificationCode
|
||||
!state.pendingSubmit?.verificationCode
|
||||
) {
|
||||
dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
|
||||
logger.error('Signup Flow Error', {
|
||||
@@ -247,7 +242,7 @@ export function useSubmitSignup() {
|
||||
password: state.password,
|
||||
birthDate: state.dateOfBirth,
|
||||
inviteCode: state.inviteCode.trim(),
|
||||
verificationCode: verificationCode,
|
||||
verificationCode: state.pendingSubmit?.verificationCode,
|
||||
})
|
||||
/*
|
||||
* Must happen last so that if the user has multiple tabs open and
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
|
||||
|
||||
export function useCompressVideoMutation({
|
||||
onProgress,
|
||||
onSuccess,
|
||||
onError,
|
||||
signal,
|
||||
}: {
|
||||
onProgress: (progress: number) => void
|
||||
onError: (e: any) => void
|
||||
onSuccess: (video: CompressedVideo) => void
|
||||
signal: AbortSignal
|
||||
}) {
|
||||
return useMutation({
|
||||
mutationFn: async (asset: ImagePickerAsset) => {
|
||||
return await compressVideo(asset.uri, {
|
||||
onProgress: num => onProgress(trunc2dp(num)),
|
||||
})
|
||||
},
|
||||
mutationKey: ['video', 'compress'],
|
||||
mutationFn: cancelable(
|
||||
(asset: ImagePickerAsset) =>
|
||||
compressVideo(asset.uri, {
|
||||
onProgress: num => onProgress(trunc2dp(num)),
|
||||
signal,
|
||||
}),
|
||||
signal,
|
||||
),
|
||||
onError,
|
||||
onSuccess,
|
||||
onMutate: () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
|
||||
import {useMemo} from 'react'
|
||||
import {AtpAgent} from '@atproto/api'
|
||||
|
||||
const UPLOAD_ENDPOINT = 'https://video.bsky.app/'
|
||||
|
||||
export const createVideoEndpointUrl = (
|
||||
route: string,
|
||||
@@ -13,3 +16,11 @@ export const createVideoEndpointUrl = (
|
||||
}
|
||||
return url.href
|
||||
}
|
||||
|
||||
export function useVideoAgent() {
|
||||
return useMemo(() => {
|
||||
return new AtpAgent({
|
||||
service: UPLOAD_ENDPOINT,
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
if (!currentAccount?.service) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
|
||||
if (!serviceAuthAud) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||
{
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const uploadTask = createUploadTask(
|
||||
uri,
|
||||
video.uri,
|
||||
{
|
||||
headers: {
|
||||
'dev-key': UPLOAD_HEADER,
|
||||
'content-type': 'video/mp4', // @TODO same question here. does the compression step always output mp4?
|
||||
'content-type': 'video/mp4',
|
||||
Authorization: `Bearer ${serviceAuth.token}`,
|
||||
},
|
||||
httpMethod: 'POST',
|
||||
@@ -59,12 +66,9 @@ export const useUploadVideoMutation = ({
|
||||
throw new Error('No response')
|
||||
}
|
||||
|
||||
// @TODO rm, useful for debugging/getting video cid
|
||||
console.log('[VIDEO]', res.body)
|
||||
const responseBody = JSON.parse(res.body) as UploadVideoResponse
|
||||
onSuccess(responseBody)
|
||||
const responseBody = JSON.parse(res.body) as AppBskyVideoDefs.JobStatus
|
||||
return responseBody
|
||||
},
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
import {AppBskyVideoDefs} from '@atproto/api'
|
||||
import {useMutation} from '@tanstack/react-query'
|
||||
import {nanoid} from 'nanoid/non-secure'
|
||||
|
||||
import {cancelable} from '#/lib/async/cancelable'
|
||||
import {CompressedVideo} from '#/lib/media/video/compress'
|
||||
import {UploadVideoResponse} from '#/lib/media/video/types'
|
||||
import {createVideoEndpointUrl} from '#/state/queries/video/util'
|
||||
import {useAgent, useSession} from '#/state/session'
|
||||
|
||||
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
|
||||
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
|
||||
|
||||
export const useUploadVideoMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
setProgress,
|
||||
signal,
|
||||
}: {
|
||||
onSuccess: (response: UploadVideoResponse) => void
|
||||
onSuccess: (response: AppBskyVideoDefs.JobStatus) => void
|
||||
onError: (e: any) => void
|
||||
setProgress: (progress: number) => void
|
||||
signal: AbortSignal
|
||||
}) => {
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/upload', {
|
||||
mutationKey: ['video', 'upload'],
|
||||
mutationFn: cancelable(async (video: CompressedVideo) => {
|
||||
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
|
||||
did: currentAccount!.did,
|
||||
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
|
||||
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
|
||||
})
|
||||
|
||||
// a logged-in agent should have this set, but we'll check just in case
|
||||
if (!agent.pdsUrl) {
|
||||
if (!currentAccount?.service) {
|
||||
throw new Error('User is not logged in')
|
||||
}
|
||||
|
||||
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
|
||||
if (!serviceAuthAud) {
|
||||
throw new Error('Agent does not have a PDS URL')
|
||||
}
|
||||
|
||||
const {data: serviceAuth} =
|
||||
await agent.api.com.atproto.server.getServiceAuth({
|
||||
aud: `did:web:${agent.pdsUrl.hostname}`,
|
||||
const {data: serviceAuth} = await agent.com.atproto.server.getServiceAuth(
|
||||
{
|
||||
aud: serviceAuthAud,
|
||||
lxm: 'com.atproto.repo.uploadBlob',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const bytes = await fetch(video.uri).then(res => res.arrayBuffer())
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const res = (await new Promise((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
const progress = e.loaded / e.total
|
||||
setProgress(progress)
|
||||
})
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
const uploadRes = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as UploadVideoResponse
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
const res = await new Promise<AppBskyVideoDefs.JobStatus>(
|
||||
(resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', e => {
|
||||
const progress = e.loaded / e.total
|
||||
setProgress(progress)
|
||||
})
|
||||
xhr.onloadend = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
const uploadRes = JSON.parse(
|
||||
xhr.responseText,
|
||||
) as AppBskyVideoDefs.JobStatus
|
||||
resolve(uploadRes)
|
||||
onSuccess(uploadRes)
|
||||
} else {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
reject()
|
||||
onError(new Error('Failed to upload video'))
|
||||
}
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4') // @TODO how we we set the proper content type?
|
||||
// @TODO remove this header for prod
|
||||
xhr.setRequestHeader('dev-key', UPLOAD_HEADER)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
})) as UploadVideoResponse
|
||||
xhr.open('POST', uri)
|
||||
xhr.setRequestHeader('Content-Type', 'video/mp4')
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${serviceAuth.token}`)
|
||||
xhr.send(bytes)
|
||||
},
|
||||
)
|
||||
|
||||
// @TODO rm for prod
|
||||
console.log('[VIDEO]', res)
|
||||
return res
|
||||
},
|
||||
}, signal),
|
||||
onError,
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
import React from 'react'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {CompressedVideo} from 'lib/media/video/compress'
|
||||
import {VideoTooLargeError} from 'lib/media/video/errors'
|
||||
import {JobState, JobStatus} from 'lib/media/video/types'
|
||||
import {useCompressVideoMutation} from 'state/queries/video/compress-video'
|
||||
import {createVideoEndpointUrl} from 'state/queries/video/util'
|
||||
import {useVideoAgent} from 'state/queries/video/util'
|
||||
import {useUploadVideoMutation} from 'state/queries/video/video-upload'
|
||||
|
||||
type Status = 'idle' | 'compressing' | 'processing' | 'uploading' | 'done'
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'SetStatus'
|
||||
status: Status
|
||||
}
|
||||
| {
|
||||
type: 'SetProgress'
|
||||
progress: number
|
||||
}
|
||||
| {
|
||||
type: 'SetError'
|
||||
error: string | undefined
|
||||
}
|
||||
| {type: 'SetStatus'; status: Status}
|
||||
| {type: 'SetProgress'; progress: number}
|
||||
| {type: 'SetError'; error: string | undefined}
|
||||
| {type: 'Reset'}
|
||||
| {type: 'SetAsset'; asset: ImagePickerAsset}
|
||||
| {type: 'SetVideo'; video: CompressedVideo}
|
||||
| {type: 'SetJobStatus'; jobStatus: JobStatus}
|
||||
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
|
||||
| {type: 'SetBlobRef'; blobRef: BlobRef}
|
||||
|
||||
export interface State {
|
||||
status: Status
|
||||
progress: number
|
||||
asset?: ImagePickerAsset
|
||||
video: CompressedVideo | null
|
||||
jobStatus?: JobStatus
|
||||
jobStatus?: AppBskyVideoDefs.JobStatus
|
||||
blobRef?: BlobRef
|
||||
error?: string
|
||||
abortController: AbortController
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
let updatedState = state
|
||||
if (action.type === 'SetStatus') {
|
||||
updatedState = {...state, status: action.status}
|
||||
} else if (action.type === 'SetProgress') {
|
||||
updatedState = {...state, progress: action.progress}
|
||||
} else if (action.type === 'SetError') {
|
||||
updatedState = {...state, error: action.error}
|
||||
} else if (action.type === 'Reset') {
|
||||
updatedState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
function reducer(queryClient: QueryClient) {
|
||||
return (state: State, action: Action): State => {
|
||||
let updatedState = state
|
||||
if (action.type === 'SetStatus') {
|
||||
updatedState = {...state, status: action.status}
|
||||
} else if (action.type === 'SetProgress') {
|
||||
updatedState = {...state, progress: action.progress}
|
||||
} else if (action.type === 'SetError') {
|
||||
updatedState = {...state, error: action.error}
|
||||
} else if (action.type === 'Reset') {
|
||||
state.abortController.abort()
|
||||
queryClient.cancelQueries({
|
||||
queryKey: ['video'],
|
||||
})
|
||||
updatedState = {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
blobRef: undefined,
|
||||
abortController: new AbortController(),
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {...state, asset: action.asset}
|
||||
} else if (action.type === 'SetVideo') {
|
||||
updatedState = {...state, video: action.video}
|
||||
} else if (action.type === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
} else if (action.type === 'SetBlobRef') {
|
||||
updatedState = {...state, blobRef: action.blobRef}
|
||||
}
|
||||
} else if (action.type === 'SetAsset') {
|
||||
updatedState = {...state, asset: action.asset}
|
||||
} else if (action.type === 'SetVideo') {
|
||||
updatedState = {...state, video: action.video}
|
||||
} else if (action.type === 'SetJobStatus') {
|
||||
updatedState = {...state, jobStatus: action.jobStatus}
|
||||
return updatedState
|
||||
}
|
||||
return updatedState
|
||||
}
|
||||
|
||||
export function useUploadVideo({
|
||||
@@ -73,14 +77,16 @@ export function useUploadVideo({
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const [state, dispatch] = React.useReducer(reducer, {
|
||||
const queryClient = useQueryClient()
|
||||
const [state, dispatch] = React.useReducer(reducer(queryClient), {
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
video: null,
|
||||
abortController: new AbortController(),
|
||||
})
|
||||
|
||||
const {setJobId} = useUploadStatusQuery({
|
||||
onStatusChange: (status: JobStatus) => {
|
||||
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => {
|
||||
// This might prove unuseful, most of the job status steps happen too quickly to even be displayed to the user
|
||||
// Leaving it for now though
|
||||
dispatch({
|
||||
@@ -89,7 +95,11 @@ export function useUploadVideo({
|
||||
})
|
||||
setStatus(status.state.toString())
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: blobRef => {
|
||||
dispatch({
|
||||
type: 'SetBlobRef',
|
||||
blobRef,
|
||||
})
|
||||
dispatch({
|
||||
type: 'SetStatus',
|
||||
status: 'idle',
|
||||
@@ -104,7 +114,7 @@ export function useUploadVideo({
|
||||
type: 'SetStatus',
|
||||
status: 'processing',
|
||||
})
|
||||
setJobId(response.job_id)
|
||||
setJobId(response.jobId)
|
||||
},
|
||||
onError: e => {
|
||||
dispatch({
|
||||
@@ -116,6 +126,7 @@ export function useUploadVideo({
|
||||
setProgress: p => {
|
||||
dispatch({type: 'SetProgress', progress: p})
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const {mutate: onSelectVideo} = useCompressVideoMutation({
|
||||
@@ -148,6 +159,7 @@ export function useUploadVideo({
|
||||
})
|
||||
onVideoCompressed(video)
|
||||
},
|
||||
signal: state.abortController.signal,
|
||||
})
|
||||
|
||||
const selectVideo = (asset: ImagePickerAsset) => {
|
||||
@@ -163,7 +175,6 @@ export function useUploadVideo({
|
||||
}
|
||||
|
||||
const clearVideo = () => {
|
||||
// @TODO cancel any running jobs
|
||||
dispatch({type: 'Reset'})
|
||||
}
|
||||
|
||||
@@ -179,21 +190,27 @@ const useUploadStatusQuery = ({
|
||||
onStatusChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
onStatusChange: (status: JobStatus) => void
|
||||
onSuccess: () => void
|
||||
onStatusChange: (status: AppBskyVideoDefs.JobStatus) => void
|
||||
onSuccess: (blobRef: BlobRef) => void
|
||||
}) => {
|
||||
const videoAgent = useVideoAgent()
|
||||
const [enabled, setEnabled] = React.useState(true)
|
||||
const [jobId, setJobId] = React.useState<string>()
|
||||
|
||||
const {isLoading, isError} = useQuery({
|
||||
queryKey: ['video-upload'],
|
||||
queryKey: ['video', 'upload status', jobId],
|
||||
queryFn: async () => {
|
||||
const url = createVideoEndpointUrl(`/job/${jobId}/status`)
|
||||
const res = await fetch(url)
|
||||
const status = (await res.json()) as JobStatus
|
||||
if (status.state === JobState.JOB_STATE_COMPLETED) {
|
||||
if (!jobId) return // this won't happen, can ignore
|
||||
|
||||
const {data} = await videoAgent.app.bsky.video.getJobStatus({jobId})
|
||||
const status = data.jobStatus
|
||||
if (status.state === 'JOB_STATE_COMPLETED') {
|
||||
setEnabled(false)
|
||||
onSuccess()
|
||||
if (!status.blob)
|
||||
throw new Error('Job completed, but did not return a blob')
|
||||
onSuccess(status.blob)
|
||||
} else if (status.state === 'JOB_STATE_FAILED') {
|
||||
throw new Error('Job failed to process')
|
||||
}
|
||||
onStatusChange(status)
|
||||
return status
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import React from 'react'
|
||||
import {useTourGuideController} from 'rn-tourguide'
|
||||
|
||||
import {Button} from '#/components/Button'
|
||||
import {Text} from '#/components/Typography'
|
||||
|
||||
export function TourDebugButton() {
|
||||
const {start} = useTourGuideController('home')
|
||||
return (
|
||||
<Button
|
||||
label="Start tour"
|
||||
onPress={() => {
|
||||
start()
|
||||
}}>
|
||||
{() => <Text>t</Text>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {
|
||||
IStep,
|
||||
TourGuideZone,
|
||||
TourGuideZoneByPosition,
|
||||
useTourGuideController,
|
||||
} from 'rn-tourguide'
|
||||
|
||||
import {DISCOVER_FEED_URI} from '#/lib/constants'
|
||||
import {isWeb} from '#/platform/detection'
|
||||
import {useSetSelectedFeed} from '#/state/shell/selected-feed'
|
||||
import {TOURS} from '.'
|
||||
import {useHeaderPosition} from './positioning'
|
||||
|
||||
export function HomeTour() {
|
||||
const {_} = useLingui()
|
||||
const {tourKey, eventEmitter} = useTourGuideController(TOURS.HOME)
|
||||
const setSelectedFeed = useSetSelectedFeed()
|
||||
const headerPosition = useHeaderPosition()
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleOnStepChange = (step?: IStep) => {
|
||||
if (step?.order === 2) {
|
||||
setSelectedFeed('following')
|
||||
} else if (step?.order === 3) {
|
||||
setSelectedFeed(`feedgen|${DISCOVER_FEED_URI}`)
|
||||
}
|
||||
}
|
||||
eventEmitter?.on('stepChange', handleOnStepChange)
|
||||
return () => {
|
||||
eventEmitter?.off('stepChange', handleOnStepChange)
|
||||
}
|
||||
}, [eventEmitter, setSelectedFeed])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourGuideZoneByPosition
|
||||
isTourGuide
|
||||
tourKey={tourKey}
|
||||
zone={1}
|
||||
top={headerPosition.top}
|
||||
left={headerPosition.left}
|
||||
width={headerPosition.width}
|
||||
height={headerPosition.height}
|
||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
||||
text={_(msg`Switch between feeds to control your experience.`)}
|
||||
/>
|
||||
<TourGuideZoneByPosition
|
||||
isTourGuide
|
||||
tourKey={tourKey}
|
||||
zone={2}
|
||||
top={headerPosition.top}
|
||||
left={headerPosition.left}
|
||||
width={headerPosition.width}
|
||||
height={headerPosition.height}
|
||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
||||
text={_(msg`Following shows the latest posts from people you follow.`)}
|
||||
/>
|
||||
<TourGuideZoneByPosition
|
||||
isTourGuide
|
||||
tourKey={tourKey}
|
||||
zone={3}
|
||||
top={headerPosition.top}
|
||||
left={headerPosition.left}
|
||||
width={headerPosition.width}
|
||||
height={headerPosition.height}
|
||||
borderRadiusObject={headerPosition.borderRadiusObject}
|
||||
text={_(msg`Discover learns which posts you like as you browse.`)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomeTourExploreWrapper({
|
||||
children,
|
||||
}: React.PropsWithChildren<{}>) {
|
||||
const {_} = useLingui()
|
||||
const {tourKey} = useTourGuideController(TOURS.HOME)
|
||||
return (
|
||||
<TourGuideZone
|
||||
tourKey={tourKey}
|
||||
zone={4}
|
||||
tooltipBottomOffset={50}
|
||||
shape={isWeb ? 'rectangle' : 'circle'}
|
||||
text={_(
|
||||
msg`Find more feeds and accounts to follow in the Explore page.`,
|
||||
)}>
|
||||
{children}
|
||||
</TourGuideZone>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import {
|
||||
AccessibilityInfo,
|
||||
findNodeHandle,
|
||||
Pressable,
|
||||
Text as RNText,
|
||||
View,
|
||||
} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {FocusScope} from '@tamagui/focus-scope'
|
||||
import {IStep, Labels} from 'rn-tourguide'
|
||||
|
||||
import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {Logo} from '#/view/icons/Logo'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonText} from '#/components/Button'
|
||||
import {leading, Text} from '#/components/Typography'
|
||||
|
||||
const stopPropagation = (e: any) => e.stopPropagation()
|
||||
|
||||
export interface TooltipComponentProps {
|
||||
isFirstStep?: boolean
|
||||
isLastStep?: boolean
|
||||
currentStep: IStep
|
||||
labels?: Labels
|
||||
handleNext?: () => void
|
||||
handlePrev?: () => void
|
||||
handleStop?: () => void
|
||||
}
|
||||
|
||||
export function TooltipComponent({
|
||||
isLastStep,
|
||||
handleNext,
|
||||
handleStop,
|
||||
currentStep,
|
||||
labels,
|
||||
}: TooltipComponentProps) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const btnRef = React.useRef<View>(null)
|
||||
const textRef = React.useRef<RNText>(null)
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
useWebBodyScrollLock(true)
|
||||
|
||||
const focusTextNode = () => {
|
||||
const node = textRef.current ? findNodeHandle(textRef.current) : undefined
|
||||
if (node) {
|
||||
AccessibilityInfo.setAccessibilityFocus(node)
|
||||
}
|
||||
}
|
||||
|
||||
// handle initial focus immediately on mount
|
||||
React.useLayoutEffect(() => {
|
||||
focusTextNode()
|
||||
}, [])
|
||||
|
||||
// handle focus between steps
|
||||
const innerHandleNext = () => {
|
||||
handleNext?.()
|
||||
setTimeout(() => focusTextNode(), 200)
|
||||
}
|
||||
|
||||
return (
|
||||
<FocusScope loop enabled trapped>
|
||||
<View
|
||||
role="alert"
|
||||
aria-role="alert"
|
||||
aria-label={_(msg`A help tooltip`)}
|
||||
accessibilityLiveRegion="polite"
|
||||
// iOS
|
||||
accessibilityViewIsModal
|
||||
// Android
|
||||
importantForAccessibility="yes"
|
||||
// @ts-ignore web only
|
||||
onClick={stopPropagation}
|
||||
onStartShouldSetResponder={_ => true}
|
||||
onTouchEnd={stopPropagation}
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
a.px_lg,
|
||||
a.py_lg,
|
||||
a.flex_col,
|
||||
a.gap_md,
|
||||
a.rounded_sm,
|
||||
a.shadow_md,
|
||||
{maxWidth: 300},
|
||||
]}>
|
||||
{screenReaderEnabled && (
|
||||
<Pressable
|
||||
style={[
|
||||
a.absolute,
|
||||
a.inset_0,
|
||||
a.z_10,
|
||||
{height: 10, bottom: 'auto'},
|
||||
]}
|
||||
accessibilityLabel={_(
|
||||
msg`Start of onboarding tour window. Do not move backward. Instead, go forward for more options, or press to skip.`,
|
||||
)}
|
||||
accessibilityHint={undefined}
|
||||
onPress={handleStop}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
|
||||
<Logo width={16} style={{position: 'relative', top: 0}} />
|
||||
<Text
|
||||
accessible={false}
|
||||
style={[a.text_sm, a.font_semibold, t.atoms.text_contrast_medium]}>
|
||||
<Trans>Quick tip</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<RNText
|
||||
ref={textRef}
|
||||
testID="stepDescription"
|
||||
accessibilityLabel={_(
|
||||
msg`Onboarding tour step ${currentStep.name}: ${currentStep.text}`,
|
||||
)}
|
||||
accessibilityHint={undefined}
|
||||
style={[
|
||||
a.text_md,
|
||||
t.atoms.text,
|
||||
a.pb_sm,
|
||||
{
|
||||
lineHeight: leading(a.text_md, a.leading_snug),
|
||||
},
|
||||
]}>
|
||||
{currentStep.text}
|
||||
</RNText>
|
||||
{!isLastStep ? (
|
||||
<Button
|
||||
ref={btnRef}
|
||||
variant="gradient"
|
||||
color="gradient_sky"
|
||||
size="medium"
|
||||
onPress={innerHandleNext}
|
||||
label={labels?.next || _(msg`Go to the next step of the tour`)}>
|
||||
<ButtonText>{labels?.next || _(msg`Next`)}</ButtonText>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="gradient"
|
||||
color="gradient_sky"
|
||||
size="medium"
|
||||
onPress={handleStop}
|
||||
label={
|
||||
labels?.finish ||
|
||||
_(msg`Finish tour and begin using the application`)
|
||||
}>
|
||||
<ButtonText>{labels?.finish || _(msg`Let's go!`)}</ButtonText>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{screenReaderEnabled && (
|
||||
<Pressable
|
||||
style={[a.absolute, a.inset_0, a.z_10, {height: 10, top: 'auto'}]}
|
||||
accessibilityLabel={_(
|
||||
msg`End of onboarding tour window. Do not move forward. Instead, go backward for more options, or press to skip.`,
|
||||
)}
|
||||
accessibilityHint={undefined}
|
||||
onPress={handleStop}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</FocusScope>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react'
|
||||
import {InteractionManager} from 'react-native'
|
||||
import {TourGuideProvider, useTourGuideController} from 'rn-tourguide'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||
import {HomeTour} from './HomeTour'
|
||||
import {TooltipComponent} from './Tooltip'
|
||||
|
||||
export enum TOURS {
|
||||
HOME = 'home',
|
||||
}
|
||||
|
||||
type StateContext = TOURS | null
|
||||
type SetContext = (v: TOURS | null) => void
|
||||
|
||||
const stateContext = React.createContext<StateContext>(null)
|
||||
const setContext = React.createContext<SetContext>((_: TOURS | null) => {})
|
||||
|
||||
export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
const theme = useColorModeTheme()
|
||||
const [state, setState] = React.useState<TOURS | null>(() => null)
|
||||
|
||||
return (
|
||||
<TourGuideProvider
|
||||
androidStatusBarVisible
|
||||
tooltipComponent={TooltipComponent}
|
||||
backdropColor={
|
||||
theme === 'light' ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.8)'
|
||||
}
|
||||
preventOutsideInteraction>
|
||||
<stateContext.Provider value={state}>
|
||||
<setContext.Provider value={setState}>
|
||||
<HomeTour />
|
||||
{children}
|
||||
</setContext.Provider>
|
||||
</stateContext.Provider>
|
||||
</TourGuideProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTriggerTourIfQueued(tour: TOURS) {
|
||||
const {start} = useTourGuideController(tour)
|
||||
const setQueuedTour = React.useContext(setContext)
|
||||
const queuedTour = React.useContext(stateContext)
|
||||
const gate = useGate()
|
||||
|
||||
return React.useCallback(() => {
|
||||
if (queuedTour === tour) {
|
||||
setQueuedTour(null)
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (gate('new_user_guided_tour')) {
|
||||
start()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [tour, queuedTour, setQueuedTour, start, gate])
|
||||
}
|
||||
|
||||
export function useSetQueuedTour() {
|
||||
return React.useContext(setContext)
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context'
|
||||
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
|
||||
export function useHeaderPosition() {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const {width} = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return {
|
||||
top: insets.top,
|
||||
left: 10,
|
||||
width: width - 20,
|
||||
height: headerHeight.value,
|
||||
borderRadiusObject: {
|
||||
topLeft: 4,
|
||||
topRight: 4,
|
||||
bottomLeft: 4,
|
||||
bottomRight: 4,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
import {useShellLayout} from '#/state/shell/shell-layout'
|
||||
|
||||
export function useHeaderPosition() {
|
||||
const {headerHeight} = useShellLayout()
|
||||
const winDim = useWindowDimensions()
|
||||
const {isMobile} = useWebMediaQueries()
|
||||
|
||||
let left = 0
|
||||
let width = winDim.width
|
||||
if (width > 590 && !isMobile) {
|
||||
left = winDim.width / 2 - 295
|
||||
width = 590
|
||||
}
|
||||
|
||||
let offset = isMobile ? 45 : 0
|
||||
|
||||
return {
|
||||
top: headerHeight.value - offset,
|
||||
left,
|
||||
width,
|
||||
height: 45,
|
||||
borderRadiusObject: undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -178,7 +177,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
clearVideo,
|
||||
state: videoUploadState,
|
||||
} = useUploadVideo({
|
||||
setStatus: (status: string) => setProcessingState(status),
|
||||
setStatus: setProcessingState,
|
||||
onSuccess: () => {
|
||||
if (publishOnUpload) {
|
||||
onPressPublish(true)
|
||||
@@ -348,6 +347,7 @@ export const ComposePost = observer(function ComposePost({
|
||||
postgate,
|
||||
onStateChange: setProcessingState,
|
||||
langs: toPostLanguages(langPrefs.postLanguage),
|
||||
video: videoUploadState.blobRef,
|
||||
})
|
||||
).uri
|
||||
try {
|
||||
@@ -699,15 +699,10 @@ export const ComposePost = observer(function ComposePost({
|
||||
<VideoTranscodeProgress
|
||||
asset={videoUploadState.asset}
|
||||
progress={videoUploadState.progress}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
) : videoUploadState.video ? (
|
||||
// remove suspense when we get rid of lazy
|
||||
<Suspense fallback={null}>
|
||||
<VideoPreview
|
||||
video={videoUploadState.video}
|
||||
clear={clearVideo}
|
||||
/>
|
||||
</Suspense>
|
||||
<VideoPreview video={videoUploadState.video} clear={clearVideo} />
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.ScrollView>
|
||||
|
||||
@@ -25,8 +25,8 @@ export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) {
|
||||
}}
|
||||
onPress={onRemove}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={_(msg`Remove image preview`)}
|
||||
accessibilityHint={_(msg`Removes the image preview`)}
|
||||
accessibilityLabel={_(msg`Remove attachment`)}
|
||||
accessibilityHint={_(msg`Removes the attachment`)}
|
||||
onAccessibilityEscape={onRemove}>
|
||||
<FontAwesomeIcon size={18} icon="xmark" style={s.white} />
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -3,18 +3,19 @@ import {View} from 'react-native'
|
||||
// @ts-expect-error no type definition
|
||||
import ProgressPie from 'react-native-progress/Pie'
|
||||
import {ImagePickerAsset} from 'expo-image-picker'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
|
||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||
|
||||
export function VideoTranscodeProgress({
|
||||
asset,
|
||||
progress,
|
||||
clear,
|
||||
}: {
|
||||
asset: ImagePickerAsset
|
||||
progress: number
|
||||
clear: () => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -41,16 +42,14 @@ export function VideoTranscodeProgress({
|
||||
a.inset_0,
|
||||
]}>
|
||||
<ProgressPie
|
||||
size={64}
|
||||
borderWidth={4}
|
||||
size={48}
|
||||
borderWidth={3}
|
||||
borderColor={t.atoms.text.color}
|
||||
color={t.atoms.text.color}
|
||||
progress={progress}
|
||||
/>
|
||||
<Text>
|
||||
<Trans>Compressing...</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -428,6 +428,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
|
||||
(item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
|
||||
const hasUnrevealedParents =
|
||||
index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {logger} from '#/logger'
|
||||
import {track} from 'lib/analytics/analytics'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
@@ -48,7 +47,6 @@ function PostThreadFollowBtnLoaded({
|
||||
'PostThreadItem',
|
||||
)
|
||||
const requireAuth = useRequireAuth()
|
||||
const gate = useGate()
|
||||
|
||||
const isFollowing = !!profile.viewer?.following
|
||||
const isFollowedBy = !!profile.viewer?.followedBy
|
||||
@@ -140,7 +138,7 @@ function PostThreadFollowBtnLoaded({
|
||||
style={[!isFollowing ? palInverted.text : pal.text, s.bold]}
|
||||
numberOfLines={1}>
|
||||
{!isFollowing ? (
|
||||
isFollowedBy && gate('show_follow_back_label_v2') ? (
|
||||
isFollowedBy ? (
|
||||
<Trans>Follow Back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
|
||||
@@ -398,7 +398,9 @@ let PostThreadItemLoaded = ({
|
||||
</Text>
|
||||
</Link>
|
||||
) : null}
|
||||
{post.quoteCount != null && post.quoteCount !== 0 ? (
|
||||
{post.quoteCount != null &&
|
||||
post.quoteCount !== 0 &&
|
||||
!post.viewer?.embeddingDisabled ? (
|
||||
<Link
|
||||
style={styles.expandedInfoItem}
|
||||
href={quotesHref}
|
||||
|
||||
@@ -161,6 +161,7 @@ let Feed = ({
|
||||
ListHeaderComponent,
|
||||
extraData,
|
||||
savedFeedConfig,
|
||||
initialNumToRender: initialNumToRenderOverride,
|
||||
}: {
|
||||
feed: FeedDescriptor
|
||||
feedParams?: FeedParams
|
||||
@@ -180,7 +181,7 @@ let Feed = ({
|
||||
ListHeaderComponent?: () => JSX.Element
|
||||
extraData?: any
|
||||
savedFeedConfig?: AppBskyActorDefs.SavedFeed
|
||||
outsideHeaderOffset?: number
|
||||
initialNumToRender?: number
|
||||
}): React.ReactNode => {
|
||||
const theme = useTheme()
|
||||
const {track} = useAnalytics()
|
||||
@@ -545,7 +546,7 @@ let Feed = ({
|
||||
desktopFixedHeight={
|
||||
desktopFixedHeightOffset ? desktopFixedHeightOffset : true
|
||||
}
|
||||
initialNumToRender={initialNumToRender}
|
||||
initialNumToRender={initialNumToRenderOverride ?? initialNumToRender}
|
||||
windowSize={11}
|
||||
onItemSeen={feedFeedback.onItemSeen}
|
||||
/>
|
||||
|
||||
@@ -17,37 +17,37 @@ import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useQueryClient} from '@tanstack/react-query'
|
||||
|
||||
import {isReasonFeedSource, ReasonFeedSource} from '#/lib/api/feed/types'
|
||||
import {MAX_POST_LINES} from '#/lib/constants'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {makeProfileLink} from '#/lib/routes/links'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {countLines} from '#/lib/strings/helpers'
|
||||
import {s} from '#/lib/styles'
|
||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||
import {precacheProfile} from '#/state/queries/profile'
|
||||
import {useSession} from '#/state/session'
|
||||
import {useComposerControls} from '#/state/shell/composer'
|
||||
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
|
||||
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
||||
import {MAX_POST_LINES} from 'lib/constants'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {makeProfileLink} from 'lib/routes/links'
|
||||
import {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||
import {sanitizeHandle} from 'lib/strings/handles'
|
||||
import {countLines} from 'lib/strings/helpers'
|
||||
import {s} from 'lib/styles'
|
||||
import {precacheProfile} from 'state/queries/profile'
|
||||
import {FeedNameText} from '#/view/com/util/FeedInfoText'
|
||||
import {PostCtrls} from '#/view/com/util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '#/view/com/util/post-embeds'
|
||||
import {PostMeta} from '#/view/com/util/PostMeta'
|
||||
import {Text} from '#/view/com/util/text/Text'
|
||||
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '#/components/moderation/PostAlerts'
|
||||
import {AppModerationCause} from '#/components/Pills'
|
||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||
import {RichText} from '#/components/RichText'
|
||||
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
|
||||
import {PostAlerts} from '../../../components/moderation/PostAlerts'
|
||||
import {FeedNameText} from '../util/FeedInfoText'
|
||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||
import {PostEmbeds} from '../util/post-embeds'
|
||||
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
||||
import {PostMeta} from '../util/PostMeta'
|
||||
import {Text} from '../util/text/Text'
|
||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||
import {AviFollowButton} from './AviFollowButton'
|
||||
|
||||
interface FeedItemProps {
|
||||
@@ -571,7 +571,11 @@ function VideoDebug() {
|
||||
|
||||
return (
|
||||
<VideoEmbed
|
||||
source={`https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`}
|
||||
embed={{
|
||||
playlist: `https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8?ignore_me_just_testing_frontend_stuff=${id}`,
|
||||
cid: 'Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ',
|
||||
aspectRatio: {height: 9, width: 16},
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import {useVideoPlayer, VideoPlayer} from 'expo-video'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
|
||||
const Context = React.createContext<{
|
||||
activeSource: string | null
|
||||
setActiveSource: (src: string) => 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 player = useVideoPlayer(activeSource, p => {
|
||||
p.muted = true
|
||||
p.loop = true
|
||||
p.play()
|
||||
})
|
||||
|
||||
return (
|
||||
<Context.Provider value={{activeSource, setActiveSource, 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
|
||||
}
|
||||
+23
-28
@@ -8,19 +8,21 @@ import React, {
|
||||
} from 'react'
|
||||
import {useWindowDimensions} from 'react-native'
|
||||
|
||||
import {isNative} from '#/platform/detection'
|
||||
import {VideoPlayerProvider} from './VideoPlayerContext'
|
||||
import {isNative, isWeb} from '#/platform/detection'
|
||||
|
||||
const ActiveVideoContext = React.createContext<{
|
||||
const Context = React.createContext<{
|
||||
activeViewId: string | null
|
||||
setActiveView: (viewId: string, src: string) => void
|
||||
setActiveView: (viewId: string) => void
|
||||
sendViewPosition: (viewId: string, y: number) => void
|
||||
} | null>(null)
|
||||
|
||||
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
export function Provider({children}: {children: React.ReactNode}) {
|
||||
if (!isWeb) {
|
||||
throw new Error('ActiveVideoWebContext may onl be used on web.')
|
||||
}
|
||||
|
||||
const [activeViewId, setActiveViewId] = useState<string | null>(null)
|
||||
const activeViewLocationRef = useRef(Infinity)
|
||||
const [source, setSource] = useState<string | null>(null)
|
||||
const {height: windowHeight} = useWindowDimensions()
|
||||
|
||||
// minimising re-renders by using refs
|
||||
@@ -31,9 +33,8 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
}, [activeViewId])
|
||||
|
||||
const setActiveView = useCallback(
|
||||
(viewId: string, src: string) => {
|
||||
(viewId: string) => {
|
||||
setActiveViewId(viewId)
|
||||
setSource(src)
|
||||
manuallySetRef.current = true
|
||||
// we don't know the exact position, but it's definitely on screen
|
||||
// so just guess that it's in the middle. Any value is fine
|
||||
@@ -88,32 +89,26 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
|
||||
[activeViewId, setActiveView, sendViewPosition],
|
||||
)
|
||||
|
||||
return (
|
||||
<ActiveVideoContext.Provider value={value}>
|
||||
<VideoPlayerProvider source={source ?? ''}>
|
||||
{children}
|
||||
</VideoPlayerProvider>
|
||||
</ActiveVideoContext.Provider>
|
||||
)
|
||||
return <Context.Provider value={value}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useActiveVideoView({source}: {source: string}) {
|
||||
const context = React.useContext(ActiveVideoContext)
|
||||
export function useActiveVideoWeb() {
|
||||
const context = React.useContext(Context)
|
||||
if (!context) {
|
||||
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
|
||||
throw new Error(
|
||||
'useActiveVideoWeb must be used within a ActiveVideoWebProvider',
|
||||
)
|
||||
}
|
||||
|
||||
const {activeViewId, setActiveView, sendViewPosition} = context
|
||||
const id = useId()
|
||||
|
||||
return {
|
||||
active: context.activeViewId === id,
|
||||
setActive: useCallback(
|
||||
() => context.setActiveView(id, source),
|
||||
[context, id, source],
|
||||
),
|
||||
currentActiveView: context.activeViewId,
|
||||
sendPosition: useCallback(
|
||||
(y: number) => context.sendViewPosition(id, y),
|
||||
[context, id],
|
||||
),
|
||||
active: activeViewId === id,
|
||||
setActive: () => {
|
||||
setActiveView(id)
|
||||
},
|
||||
currentActiveView: activeViewId,
|
||||
sendPosition: (y: number) => sendViewPosition(id, y),
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,25 @@
|
||||
import React, {useCallback, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {Image} from 'expo-image'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {VideoEmbedInnerNative} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import {Button} from '#/components/Button'
|
||||
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
|
||||
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {useActiveVideoNative} from './ActiveVideoNativeContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const {active, setActive} = useActiveVideoView({source})
|
||||
const {activeSource, setActiveSource} = useActiveVideoNative()
|
||||
const isActive = embed.playlist === activeSource
|
||||
const {_} = useLingui()
|
||||
|
||||
const [key, setKey] = useState(0)
|
||||
@@ -24,37 +29,61 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
),
|
||||
[key],
|
||||
)
|
||||
const gate = useGate()
|
||||
|
||||
if (!gate('videos')) {
|
||||
return null
|
||||
}
|
||||
|
||||
let aspectRatio = 16 / 9
|
||||
|
||||
if (embed.aspectRatio) {
|
||||
const {width, height} = embed.aspectRatio
|
||||
aspectRatio = width / height
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
a.my_xs,
|
||||
]}>
|
||||
<ErrorBoundary renderError={renderError} key={key}>
|
||||
<VisibilityView
|
||||
enabled={true}
|
||||
onChangeStatus={isActive => {
|
||||
if (isActive) {
|
||||
setActive()
|
||||
onChangeStatus={isVisible => {
|
||||
if (isVisible) {
|
||||
setActiveSource(embed.playlist)
|
||||
}
|
||||
}}>
|
||||
{active ? (
|
||||
<VideoEmbedInnerNative />
|
||||
{isActive ? (
|
||||
<VideoEmbedInnerNative embed={embed} />
|
||||
) : (
|
||||
<Button
|
||||
style={[a.flex_1, t.atoms.bg_contrast_25]}
|
||||
onPress={setActive}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<ButtonIcon icon={PlayIcon} />
|
||||
</Button>
|
||||
<>
|
||||
<Image
|
||||
source={{uri: embed.thumbnail}}
|
||||
alt={embed.alt}
|
||||
style={a.flex_1}
|
||||
contentFit="contain"
|
||||
accessibilityIgnoresInvertColors
|
||||
/>
|
||||
<Button
|
||||
style={[a.absolute, a.inset_0]}
|
||||
onPress={() => {
|
||||
setActiveSource(embed.playlist)
|
||||
}}
|
||||
label={_(msg`Play video`)}
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="large">
|
||||
<PlayIcon width={48} fill={t.palette.white} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</VisibilityView>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {Trans} from '@lingui/macro'
|
||||
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {
|
||||
HLSUnsupportedError,
|
||||
VideoEmbedInnerWeb,
|
||||
} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {ErrorBoundary} from '../ErrorBoundary'
|
||||
import {useActiveVideoView} from './ActiveVideoContext'
|
||||
import {useActiveVideoWeb} from './ActiveVideoWebContext'
|
||||
import * as VideoFallback from './VideoEmbedInner/VideoFallback'
|
||||
|
||||
export function VideoEmbed({source}: {source: string}) {
|
||||
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
|
||||
const t = useTheme()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const gate = useGate()
|
||||
const {active, setActive, sendPosition, currentActiveView} =
|
||||
useActiveVideoView({source})
|
||||
useActiveVideoWeb()
|
||||
const [onScreen, setOnScreen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,12 +47,25 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
[key],
|
||||
)
|
||||
|
||||
if (!gate('videos')) {
|
||||
return null
|
||||
}
|
||||
|
||||
let aspectRatio = 16 / 9
|
||||
|
||||
if (embed.aspectRatio) {
|
||||
const {width, height} = embed.aspectRatio
|
||||
// min: 3/1, max: square
|
||||
aspectRatio = clamp(width / height, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
{aspectRatio: 16 / 9},
|
||||
t.atoms.bg_contrast_25,
|
||||
{aspectRatio},
|
||||
{backgroundColor: t.palette.black},
|
||||
a.relative,
|
||||
a.rounded_sm,
|
||||
a.my_xs,
|
||||
]}>
|
||||
@@ -61,7 +78,7 @@ export function VideoEmbed({source}: {source: string}) {
|
||||
sendPosition={sendPosition}
|
||||
isAnyViewActive={currentActiveView !== null}>
|
||||
<VideoEmbedInnerWeb
|
||||
source={source}
|
||||
embed={embed}
|
||||
active={active}
|
||||
setActive={setActive}
|
||||
onScreen={onScreen}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react'
|
||||
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
||||
|
||||
import {atoms as a, native, useTheme} from '#/alf'
|
||||
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}) {
|
||||
const t = useTheme()
|
||||
|
||||
if (isNaN(time)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const minutes = Math.floor(time / 60)
|
||||
const seconds = String(time % 60).padStart(2, '0')
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={native(FadeInDown.duration(300))}
|
||||
exiting={native(FadeOutDown.duration(500))}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.white, fontSize: 12},
|
||||
a.font_bold,
|
||||
{lineHeight: 1.25},
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,33 @@
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
|
||||
import Animated, {FadeInDown} from 'react-native-reanimated'
|
||||
import {VideoPlayer, VideoView} from 'expo-video'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import {msg} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {useIsFocused} from '@react-navigation/native'
|
||||
|
||||
import {HITSLOP_30} from '#/lib/constants'
|
||||
import {useAppState} from '#/lib/hooks/useAppState'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {logger} from '#/logger'
|
||||
import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext'
|
||||
import {android, atoms as a, useTheme} from '#/alf'
|
||||
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute'
|
||||
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../../modules/expo-bluesky-swiss-army'
|
||||
import {TimeIndicator} from './TimeIndicator'
|
||||
|
||||
export function VideoEmbedInnerNative() {
|
||||
const player = useVideoPlayer()
|
||||
export function VideoEmbedInnerNative({
|
||||
embed,
|
||||
}: {
|
||||
embed: AppBskyEmbedVideo.View
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const {player} = useActiveVideoNative()
|
||||
const ref = useRef<VideoView>(null)
|
||||
const isScreenFocused = useIsFocused()
|
||||
const isAppFocused = useAppState()
|
||||
@@ -47,13 +54,23 @@ export function VideoEmbedInnerNative() {
|
||||
ref.current?.enterFullscreen()
|
||||
}, [])
|
||||
|
||||
let aspectRatio = 16 / 9
|
||||
|
||||
if (embed.aspectRatio) {
|
||||
const {width, height} = embed.aspectRatio
|
||||
aspectRatio = width / height
|
||||
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[a.flex_1, a.relative]}>
|
||||
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
|
||||
<VideoView
|
||||
ref={ref}
|
||||
player={player}
|
||||
style={[a.flex_1, a.rounded_sm]}
|
||||
contentFit="contain"
|
||||
nativeControls={true}
|
||||
accessibilityIgnoresInvertColors
|
||||
onEnterFullscreen={() => {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Playback)
|
||||
PlatformInfo.setAudioActive(true)
|
||||
@@ -65,13 +82,17 @@ export function VideoEmbedInnerNative() {
|
||||
player.muted = true
|
||||
if (!player.playing) player.play()
|
||||
}}
|
||||
accessibilityLabel={
|
||||
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
|
||||
}
|
||||
accessibilityHint=""
|
||||
/>
|
||||
<Controls player={player} enterFullscreen={enterFullscreen} />
|
||||
<VideoControls player={player} enterFullscreen={enterFullscreen} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Controls({
|
||||
function VideoControls({
|
||||
player,
|
||||
enterFullscreen,
|
||||
}: {
|
||||
@@ -81,33 +102,22 @@ function Controls({
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
const [isMuted, setIsMuted] = useState(player.muted)
|
||||
const [duration, setDuration] = useState(() => Math.floor(player.duration))
|
||||
const [currentTime, setCurrentTime] = useState(() =>
|
||||
Math.floor(player.currentTime),
|
||||
)
|
||||
|
||||
const timeRemaining = duration - currentTime
|
||||
const minutes = Math.floor(timeRemaining / 60)
|
||||
const seconds = String(timeRemaining % 60).padStart(2, '0')
|
||||
const [timeRemaining, setTimeRemaining] = React.useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// duration gets reset to 0 on loop
|
||||
if (player.duration) setDuration(Math.floor(player.duration))
|
||||
setCurrentTime(Math.floor(player.currentTime))
|
||||
|
||||
// how often should we update the time?
|
||||
// 1000 gets out of sync with the video time
|
||||
}, 250)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const sub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
const volumeSub = player.addListener('volumeChange', ({isMuted}) => {
|
||||
setIsMuted(isMuted)
|
||||
})
|
||||
|
||||
const timeSub = player.addListener(
|
||||
'timeRemainingChange',
|
||||
secondsRemaining => {
|
||||
setTimeRemaining(secondsRemaining)
|
||||
},
|
||||
)
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
sub.remove()
|
||||
volumeSub.remove()
|
||||
timeSub.remove()
|
||||
}
|
||||
}, [player])
|
||||
|
||||
@@ -143,37 +153,11 @@ function Controls({
|
||||
// 1. timeRemaining is a number - was seeing NaNs
|
||||
// 2. duration is greater than 0 - means metadata has loaded
|
||||
// 3. we're less than 5 second into the video
|
||||
const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5
|
||||
const showTime = !isNaN(timeRemaining)
|
||||
|
||||
return (
|
||||
<View style={[a.absolute, a.inset_0]}>
|
||||
{showTime && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
exiting={FadeOutDown.duration(500)}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
left: 5,
|
||||
bottom: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.white, fontSize: 12},
|
||||
a.font_bold,
|
||||
android({lineHeight: 1.25}),
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
{showTime && <TimeIndicator time={timeRemaining} />}
|
||||
<Pressable
|
||||
onPress={onPressFullscreen}
|
||||
style={a.flex_1}
|
||||
@@ -181,35 +165,33 @@ function Controls({
|
||||
accessibilityHint={_(msg`Tap to enter full screen`)}
|
||||
accessibilityRole="button"
|
||||
/>
|
||||
{duration > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<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={14} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
)}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(300)}
|
||||
style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
right: 5,
|
||||
minHeight: 20,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<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={14} fill={t.palette.white} />
|
||||
) : (
|
||||
<UnmuteIcon width={14} fill={t.palette.white} />
|
||||
)}
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
import React, {useEffect, useRef, useState} from 'react'
|
||||
import React, {useEffect, useId, useRef, useState} from 'react'
|
||||
import {View} from 'react-native'
|
||||
import {AppBskyEmbedVideo} from '@atproto/api'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Controls} from './VideoWebControls'
|
||||
|
||||
export function VideoEmbedInnerWeb({
|
||||
source,
|
||||
embed,
|
||||
active,
|
||||
setActive,
|
||||
onScreen,
|
||||
}: {
|
||||
source: string
|
||||
active?: boolean
|
||||
setActive?: () => void
|
||||
onScreen?: boolean
|
||||
embed: AppBskyEmbedVideo.View
|
||||
active: boolean
|
||||
setActive: () => void
|
||||
onScreen: boolean
|
||||
}) {
|
||||
if (active == null || setActive == null || onScreen == null) {
|
||||
throw new Error(
|
||||
'active, setActive, and onScreen are required VideoEmbedInner props on web.',
|
||||
)
|
||||
}
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLVideoElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false)
|
||||
const figId = useId()
|
||||
|
||||
const hlsRef = useRef<Hls | undefined>(undefined)
|
||||
|
||||
@@ -37,7 +33,7 @@ export function VideoEmbedInnerWeb({
|
||||
hlsRef.current = hls
|
||||
|
||||
hls.attachMedia(ref.current)
|
||||
hls.loadSource(source)
|
||||
hls.loadSource(embed.playlist)
|
||||
|
||||
// initial value, later on it's managed by Controls
|
||||
hls.autoLevelCapping = 0
|
||||
@@ -53,29 +49,40 @@ export function VideoEmbedInnerWeb({
|
||||
hls.detachMedia()
|
||||
hls.destroy()
|
||||
}
|
||||
}, [source])
|
||||
}, [embed.playlist])
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_sm,
|
||||
// TODO: get from embed metadata
|
||||
// max should be 1 / 1
|
||||
{aspectRatio: 16 / 9},
|
||||
a.overflow_hidden,
|
||||
]}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{width: '100%', height: '100%', display: 'flex'}}>
|
||||
<video
|
||||
ref={ref}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
/>
|
||||
<View style={[a.flex_1, a.rounded_sm, a.overflow_hidden]}>
|
||||
<div ref={containerRef} style={{height: '100%', width: '100%'}}>
|
||||
<figure style={{margin: 0, position: 'absolute', inset: 0}}>
|
||||
<video
|
||||
ref={ref}
|
||||
poster={embed.thumbnail}
|
||||
style={{width: '100%', height: '100%', objectFit: 'contain'}}
|
||||
playsInline
|
||||
preload="none"
|
||||
loop
|
||||
muted={!focused}
|
||||
aria-labelledby={embed.alt ? figId : undefined}
|
||||
/>
|
||||
{embed.alt && (
|
||||
<figcaption
|
||||
id={figId}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderWidth: 0,
|
||||
}}>
|
||||
{embed.alt}
|
||||
</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
<Controls
|
||||
videoRef={ref}
|
||||
hlsRef={hlsRef}
|
||||
|
||||
@@ -6,17 +6,19 @@ import React, {
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Pressable, View} from 'react-native'
|
||||
import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
|
||||
import {SvgProps} from 'react-native-svg'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import type Hls from 'hls.js'
|
||||
|
||||
import {isIPhoneWeb} from 'platform/detection'
|
||||
import {isFirefox} from '#/lib/browser'
|
||||
import {clamp} from '#/lib/numbers'
|
||||
import {isIPhoneWeb} from '#/platform/detection'
|
||||
import {
|
||||
useAutoplayDisabled,
|
||||
useSetSubtitlesEnabled,
|
||||
useSubtitlesEnabled,
|
||||
} from 'state/preferences'
|
||||
} from '#/state/preferences'
|
||||
import {atoms as a, useTheme, web} from '#/alf'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
@@ -34,6 +36,7 @@ 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,
|
||||
@@ -173,6 +176,50 @@ export function Controls({
|
||||
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 showControls =
|
||||
(focused && !playing) || (interactingViaKeypress ? hasFocus : hovered)
|
||||
|
||||
@@ -197,7 +244,7 @@ export function Controls({
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityHint={_(
|
||||
focused
|
||||
!focused
|
||||
? msg`Unmute video`
|
||||
: playing
|
||||
? msg`Pause video`
|
||||
@@ -206,107 +253,87 @@ export function Controls({
|
||||
style={a.flex_1}
|
||||
onPress={onPressEmptySpace}
|
||||
/>
|
||||
{active && !showControls && !focused && (
|
||||
<TimeIndicator time={Math.floor(duration - currentTime)} />
|
||||
)}
|
||||
<View
|
||||
style={[
|
||||
a.flex_shrink_0,
|
||||
a.w_full,
|
||||
a.px_sm,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.px_xs,
|
||||
web({
|
||||
background:
|
||||
'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))',
|
||||
}),
|
||||
showControls ? {opacity: 1} : {opacity: 0},
|
||||
{opacity: showControls ? 1 : 0},
|
||||
{transition: 'opacity 0.2s ease-in-out'},
|
||||
]}>
|
||||
<Button
|
||||
label={_(playing ? msg`Pause` : msg`Play`)}
|
||||
onPress={onPressPlayPause}
|
||||
{...btnProps}>
|
||||
{playing ? (
|
||||
<PauseIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<PlayIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<Button
|
||||
label={_(
|
||||
subtitlesEnabled ? msg`Disable subtitles` : msg`Enable subtitles`,
|
||||
)}
|
||||
onPress={onPressSubtitles}
|
||||
{...btnProps}>
|
||||
{subtitlesEnabled ? (
|
||||
<CCActiveIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<CCInactiveIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressMute}
|
||||
{...btnProps}>
|
||||
{muted ? (
|
||||
<MuteIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<UnmuteIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
{!isIPhoneWeb && (
|
||||
<Button
|
||||
label={_(muted ? msg`Unmute` : msg`Mute`)}
|
||||
onPress={onPressFullscreen}
|
||||
{...btnProps}>
|
||||
{isFullscreen ? (
|
||||
<ArrowsInIcon fill={t.palette.white} width={20} />
|
||||
) : (
|
||||
<ArrowsOutIcon fill={t.palette.white} width={20} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
{(showControls || !focused) && (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
<Scrubber
|
||||
duration={duration}
|
||||
currentTime={currentTime}
|
||||
onSeek={onSeek}
|
||||
onSeekStart={onSeekStart}
|
||||
onSeekEnd={onSeekEnd}
|
||||
seekLeft={seekLeft}
|
||||
seekRight={seekRight}
|
||||
togglePlayPause={togglePlayPause}
|
||||
drawFocus={drawFocus}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.absolute,
|
||||
{
|
||||
height: 5,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
},
|
||||
a.flex_1,
|
||||
a.px_xs,
|
||||
a.pt_sm,
|
||||
a.pb_md,
|
||||
a.gap_md,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
]}>
|
||||
{duration > 0 && (
|
||||
<View
|
||||
style={[
|
||||
a.h_full,
|
||||
a.mr_auto,
|
||||
{
|
||||
backgroundColor: t.palette.white,
|
||||
width: `${(currentTime / duration) * 100}%`,
|
||||
opacity: 0.8,
|
||||
},
|
||||
]}
|
||||
<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}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
<ControlButton
|
||||
active={subtitlesEnabled}
|
||||
activeLabel={_(msg`Disable subtitles`)}
|
||||
inactiveLabel={_(msg`Enable subtitles`)}
|
||||
activeIcon={CCActiveIcon}
|
||||
inactiveIcon={CCInactiveIcon}
|
||||
onPress={onPressSubtitles}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
)}
|
||||
<ControlButton
|
||||
active={muted}
|
||||
activeLabel={_(msg`Unmute`)}
|
||||
inactiveLabel={_(msg`Mute`)}
|
||||
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) && (
|
||||
<Animated.View
|
||||
<View
|
||||
pointerEvents="none"
|
||||
entering={FadeIn.delay(1000).duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
|
||||
{buffering && <Loader fill={t.palette.white} size="lg" />}
|
||||
{error && (
|
||||
@@ -314,19 +341,278 @@ export function Controls({
|
||||
<Trans>An error occurred</Trans>
|
||||
</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const btnProps = {
|
||||
variant: 'ghost',
|
||||
shape: 'round',
|
||||
size: 'medium',
|
||||
style: a.p_2xs,
|
||||
hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'},
|
||||
} as const
|
||||
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: onMouseEnter,
|
||||
onOut: onMouseLeave,
|
||||
} = 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')
|
||||
|
||||
const abortController = new AbortController()
|
||||
const {signal} = abortController
|
||||
document.documentElement.addEventListener(
|
||||
'mouseleave',
|
||||
() => {
|
||||
isSeekingRef.current = false
|
||||
onSeekEnd()
|
||||
setScrubberActive(false)
|
||||
},
|
||||
{signal},
|
||||
)
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('force-no-clicks')
|
||||
abortController.abort()
|
||||
}
|
||||
}
|
||||
}, [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: 10, width: '100%'}, a.flex_shrink_0, a.px_xs]}
|
||||
// @ts-expect-error web only -sfn
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}>
|
||||
<div
|
||||
ref={barRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
cursor: scrubberActive ? 'grabbing' : 'grab',
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}>
|
||||
<View
|
||||
style={[
|
||||
a.w_full,
|
||||
a.rounded_full,
|
||||
a.overflow_hidden,
|
||||
{backgroundColor: 'rgba(255, 255, 255, 0.4)'},
|
||||
{height: hovered || scrubberActive ? 6 : 3},
|
||||
]}>
|
||||
{currentTime > 0 && 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)) {
|
||||
@@ -421,14 +707,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
setError(false)
|
||||
}
|
||||
|
||||
const handleSeeking = () => {
|
||||
setBuffering(true)
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
setBuffering(false)
|
||||
}
|
||||
|
||||
const handleStalled = () => {
|
||||
if (bufferingTimeout) clearTimeout(bufferingTimeout)
|
||||
bufferingTimeout = setTimeout(() => {
|
||||
@@ -474,12 +752,6 @@ function useVideoUtils(ref: React.RefObject<HTMLVideoElement>) {
|
||||
ref.current.addEventListener('playing', handlePlaying, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeking', handleSeeking, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('seeked', handleSeeked, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
ref.current.addEventListener('stalled', handleStalled, {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, {useContext} from 'react'
|
||||
import type {VideoPlayer} from 'expo-video'
|
||||
import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import {
|
||||
AudioCategory,
|
||||
PlatformInfo,
|
||||
} from '../../../../../modules/expo-bluesky-swiss-army'
|
||||
|
||||
const VideoPlayerContext = React.createContext<VideoPlayer | null>(null)
|
||||
|
||||
export function VideoPlayerProvider({
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
source: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-shadow
|
||||
const player = useExpoVideoPlayer(source, player => {
|
||||
try {
|
||||
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
|
||||
PlatformInfo.setAudioActive(false)
|
||||
|
||||
player.loop = true
|
||||
player.muted = true
|
||||
player.play()
|
||||
} catch (err) {
|
||||
logger.error('Failed to init video player', {safeMessage: err})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<VideoPlayerContext.Provider value={player}>
|
||||
{children}
|
||||
</VideoPlayerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useVideoPlayer() {
|
||||
const context = useContext(VideoPlayerContext)
|
||||
if (!context) {
|
||||
throw new Error('useVideoPlayer must be used within a VideoPlayerProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export function VideoPlayerProvider({children}: {children: React.ReactNode}) {
|
||||
return children
|
||||
}
|
||||
|
||||
export function useVideoPlayer() {
|
||||
throw new Error('useVideoPlayer must not be used on web')
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
AppBskyEmbedImages,
|
||||
AppBskyEmbedRecord,
|
||||
AppBskyEmbedRecordWithMedia,
|
||||
AppBskyEmbedVideo,
|
||||
AppBskyFeedDefs,
|
||||
AppBskyGraphDefs,
|
||||
moderateFeedGenerator,
|
||||
@@ -33,10 +34,12 @@ import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||
import {ExternalLinkEmbed} from './ExternalLinkEmbed'
|
||||
import {MaybeQuoteEmbed} from './QuoteEmbed'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
|
||||
type Embed =
|
||||
| AppBskyEmbedRecord.View
|
||||
| AppBskyEmbedImages.View
|
||||
| AppBskyEmbedVideo.View
|
||||
| AppBskyEmbedExternal.View
|
||||
| AppBskyEmbedRecordWithMedia.View
|
||||
| {$type: string; [k: string]: unknown}
|
||||
@@ -175,6 +178,14 @@ export function PostEmbeds({
|
||||
)
|
||||
}
|
||||
|
||||
if (AppBskyEmbedVideo.isView(embed)) {
|
||||
return (
|
||||
<ContentHider modui={moderation?.ui('contentMedia')}>
|
||||
<VideoEmbed embed={embed} />
|
||||
</ContentHider>
|
||||
)
|
||||
}
|
||||
|
||||
return <View />
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {logEvent, LogEvents} from '#/lib/statsig/statsig'
|
||||
import {useGate} from '#/lib/statsig/statsig'
|
||||
import {emitSoftReset} from '#/state/events'
|
||||
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
|
||||
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
|
||||
import {FeedParams} from '#/state/queries/post-feed'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {UsePreferencesQueryResponse} from '#/state/queries/preferences/types'
|
||||
import {useSession} from '#/state/session'
|
||||
@@ -29,7 +29,6 @@ import {CustomFeedEmptyState} from 'view/com/posts/CustomFeedEmptyState'
|
||||
import {FollowingEmptyState} from 'view/com/posts/FollowingEmptyState'
|
||||
import {FollowingEndOfFeed} from 'view/com/posts/FollowingEndOfFeed'
|
||||
import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned'
|
||||
import {TOURS, useTriggerTourIfQueued} from '#/tours'
|
||||
import {HomeHeader} from '../com/home/HomeHeader'
|
||||
|
||||
type Props = NativeStackScreenProps<HomeTabNavigatorParams, 'Home' | 'Start'>
|
||||
@@ -88,7 +87,6 @@ function HomeScreenReady({
|
||||
const selectedIndex = Math.max(0, maybeFoundIndex)
|
||||
const selectedFeed = allFeeds[selectedIndex]
|
||||
const requestNotificationsPermission = useRequestNotificationsPermission()
|
||||
const triggerTourIfQueued = useTriggerTourIfQueued(TOURS.HOME)
|
||||
const gate = useGate()
|
||||
|
||||
useSetTitle(pinnedFeedInfos[selectedIndex]?.displayName)
|
||||
@@ -110,30 +108,6 @@ function HomeScreenReady({
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
// Temporary, remove when finished debugging
|
||||
const debugHasLoggedFollowingPrefs = React.useRef(false)
|
||||
const debugLogFollowingPrefs = React.useCallback(
|
||||
(feed: FeedDescriptor) => {
|
||||
if (debugHasLoggedFollowingPrefs.current) return
|
||||
if (feed !== 'following') return
|
||||
logEvent('debug:followingPrefs', {
|
||||
followingShowRepliesFromPref: preferences.feedViewPrefs.hideReplies
|
||||
? 'off'
|
||||
: preferences.feedViewPrefs.hideRepliesByUnfollowed
|
||||
? 'following'
|
||||
: 'all',
|
||||
followingRepliesMinLikePref:
|
||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
||||
})
|
||||
debugHasLoggedFollowingPrefs.current = true
|
||||
},
|
||||
[
|
||||
preferences.feedViewPrefs.hideReplies,
|
||||
preferences.feedViewPrefs.hideRepliesByLikeCount,
|
||||
preferences.feedViewPrefs.hideRepliesByUnfollowed,
|
||||
],
|
||||
)
|
||||
|
||||
const {hasSession} = useSession()
|
||||
const setMinimalShellMode = useSetMinimalShellMode()
|
||||
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
|
||||
@@ -141,16 +115,10 @@ function HomeScreenReady({
|
||||
React.useCallback(() => {
|
||||
setMinimalShellMode(false)
|
||||
setDrawerSwipeDisabled(selectedIndex > 0)
|
||||
triggerTourIfQueued()
|
||||
return () => {
|
||||
setDrawerSwipeDisabled(false)
|
||||
}
|
||||
}, [
|
||||
setDrawerSwipeDisabled,
|
||||
selectedIndex,
|
||||
setMinimalShellMode,
|
||||
triggerTourIfQueued,
|
||||
]),
|
||||
}, [setDrawerSwipeDisabled, selectedIndex, setMinimalShellMode]),
|
||||
)
|
||||
|
||||
useFocusEffect(
|
||||
@@ -162,7 +130,6 @@ function HomeScreenReady({
|
||||
feedUrl: selectedFeed,
|
||||
reason: 'focus',
|
||||
})
|
||||
debugLogFollowingPrefs(selectedFeed)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -213,9 +180,8 @@ function HomeScreenReady({
|
||||
feedUrl: feed,
|
||||
reason,
|
||||
})
|
||||
debugLogFollowingPrefs(feed)
|
||||
},
|
||||
[allFeeds, debugLogFollowingPrefs],
|
||||
[allFeeds],
|
||||
)
|
||||
|
||||
const onPressSelected = React.useCallback(() => {
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
Message_Stroke2_Corner0_Rounded as Message,
|
||||
Message_Stroke2_Corner0_Rounded_Filled as MessageFilled,
|
||||
} from '#/components/icons/Message'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
type TabOptions =
|
||||
@@ -163,19 +162,17 @@ export function BottomBar({navigation}: BottomTabBarProps) {
|
||||
<Btn
|
||||
testID="bottomBarSearchBtn"
|
||||
icon={
|
||||
<HomeTourExploreWrapper>
|
||||
{isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)}
|
||||
</HomeTourExploreWrapper>
|
||||
isAtSearch ? (
|
||||
<MagnifyingGlassFilled
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
) : (
|
||||
<MagnifyingGlass
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onPress={onPressSearch}
|
||||
accessibilityRole="search"
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {styles} from './BottomBarStyles'
|
||||
|
||||
export function BottomBarWeb() {
|
||||
@@ -95,12 +94,10 @@ export function BottomBarWeb() {
|
||||
{({isActive}) => {
|
||||
const Icon = isActive ? MagnifyingGlassFilled : MagnifyingGlass
|
||||
return (
|
||||
<HomeTourExploreWrapper>
|
||||
<Icon
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
</HomeTourExploreWrapper>
|
||||
<Icon
|
||||
width={iconWidth + 2}
|
||||
style={[styles.ctrlIcon, pal.text, styles.searchIcon]}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
</NavItem>
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
UserCircle_Filled_Corner0_Rounded as UserCircleFilled,
|
||||
UserCircle_Stroke2_Corner0_Rounded as UserCircle,
|
||||
} from '#/components/icons/UserCircle'
|
||||
import {HomeTourExploreWrapper} from '#/tours/HomeTour'
|
||||
import {router} from '../../../routes'
|
||||
|
||||
const NAV_ICON_WIDTH = 28
|
||||
@@ -341,19 +340,14 @@ export function DesktopLeftNav() {
|
||||
iconFilled={<HomeFilled width={NAV_ICON_WIDTH} style={pal.text} />}
|
||||
label={_(msg`Home`)}
|
||||
/>
|
||||
<HomeTourExploreWrapper>
|
||||
<NavItem
|
||||
href="/search"
|
||||
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
||||
iconFilled={
|
||||
<MagnifyingGlassFilled
|
||||
style={pal.text}
|
||||
width={NAV_ICON_WIDTH}
|
||||
/>
|
||||
}
|
||||
label={_(msg`Search`)}
|
||||
/>
|
||||
</HomeTourExploreWrapper>
|
||||
<NavItem
|
||||
href="/search"
|
||||
icon={<MagnifyingGlass style={pal.text} width={NAV_ICON_WIDTH} />}
|
||||
iconFilled={
|
||||
<MagnifyingGlassFilled style={pal.text} width={NAV_ICON_WIDTH} />
|
||||
}
|
||||
label={_(msg`Search`)}
|
||||
/>
|
||||
<NavItem
|
||||
href="/notifications"
|
||||
count={numUnreadNotifications}
|
||||
|
||||
Reference in New Issue
Block a user