From 14e39c4aecb8373b692c706bf27e49b0b72918b2 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 4 Aug 2025 11:49:35 +0100 Subject: [PATCH 1/6] Tweak accessibility label (#8767) * tweak accessibility label * change `alert` to `message` and add comment instead of context * tweak comment --- src/components/Toast/index.web.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx index f6ceda568e..f2517e28da 100644 --- a/src/components/Toast/index.web.tsx +++ b/src/components/Toast/index.web.tsx @@ -78,7 +78,12 @@ export const ToastContainer: React.FC = ({}) => { setActiveToast(undefined)} /> From 9aa35e9fbb6136a88a66388ff5e4644ad25c9e4b Mon Sep 17 00:00:00 2001 From: Anastasiya Uraleva Date: Mon, 4 Aug 2025 06:17:50 -0700 Subject: [PATCH 2/6] [APP-1083] bug fix: videos not accurately autoplaying on web (#8692) * update: auto play video on web with intersection position * place back the threshold: 0.5 * update: optimize the intersection observer with a throttled scroll listener --------- Co-authored-by: Anastasiya Co-authored-by: Anastasiya --- .../Post/Embed/VideoEmbed/index.web.tsx | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 7f601af47b..900bc21889 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -138,22 +138,53 @@ function ViewportObserver({ useEffect(() => { if (!ref.current) return if (isFullscreen && !isFirefox) return + + let scrollTimeout: NodeJS.Timeout | null = null + let lastObserverEntry: IntersectionObserverEntry | null = null + + const updatePositionFromEntry = () => { + if (!lastObserverEntry) return + const rect = lastObserverEntry.boundingClientRect + const position = rect.y + rect.height / 2 + sendPosition(position) + } + + const handleScroll = () => { + if (scrollTimeout) { + clearTimeout(scrollTimeout) + } + scrollTimeout = setTimeout(updatePositionFromEntry, 4) // ~240fps + } + const observer = new IntersectionObserver( entries => { const entry = entries[0] if (!entry) return - const position = - entry.boundingClientRect.y + entry.boundingClientRect.height / 2 - sendPosition(position) + lastObserverEntry = entry setNearScreen(entry.isIntersecting) + const rect = entry.boundingClientRect + const position = rect.y + rect.height / 2 + sendPosition(position) }, - {threshold: Array.from({length: 101}, (_, i) => i / 100)}, + {threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0]}, ) - observer.observe(ref.current) - return () => observer.disconnect() - }, [sendPosition, isFullscreen]) - // In case scrolling hasn't started yet, send up the position + observer.observe(ref.current) + + if (nearScreen) { + window.addEventListener('scroll', handleScroll, {passive: true}) + } + + return () => { + observer.disconnect() + if (scrollTimeout) { + clearTimeout(scrollTimeout) + } + window.removeEventListener('scroll', handleScroll) + } + }, [sendPosition, isFullscreen, nearScreen]) + + // In case scrolling hasn't started yet, send the original position useEffect(() => { if (ref.current && !isAnyViewActive) { const rect = ref.current.getBoundingClientRect() From b32568260f98ea879468fd1bdedacf85d1e6ae8c Mon Sep 17 00:00:00 2001 From: kindgracekind <155408223+kindgracekind@users.noreply.github.com> Date: Mon, 4 Aug 2025 10:27:27 -0500 Subject: [PATCH 3/6] Add feed tuner to remove muted threads (#8773) --- src/lib/api/feed-manip.ts | 16 ++++++++++++++++ src/state/preferences/feed-tuners.tsx | 8 ++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/lib/api/feed-manip.ts b/src/lib/api/feed-manip.ts index 3309191f34..335bf28c84 100644 --- a/src/lib/api/feed-manip.ts +++ b/src/lib/api/feed-manip.ts @@ -41,6 +41,7 @@ export class FeedViewPostsSlice { isIncompleteThread: boolean isFallbackMarker: boolean isOrphan: boolean + isThreadMuted: boolean rootUri: string feedPostUri: string @@ -50,6 +51,7 @@ export class FeedViewPostsSlice { this.isIncompleteThread = false this.isFallbackMarker = false this.isOrphan = false + this.isThreadMuted = post.viewer?.threadMuted ?? false this.feedPostUri = post.uri if (AppBskyFeedDefs.isPostView(reply?.root)) { this.rootUri = reply.root.uri @@ -361,6 +363,20 @@ export class FeedTuner { return slices } + static removeMutedThreads( + tuner: FeedTuner, + slices: FeedViewPostsSlice[], + _dryRun: boolean, + ) { + for (let i = 0; i < slices.length; i++) { + if (slices[i].isThreadMuted) { + slices.splice(i, 1) + i-- + } + } + return slices + } + static dedupThreads( tuner: FeedTuner, slices: FeedViewPostsSlice[], diff --git a/src/state/preferences/feed-tuners.tsx b/src/state/preferences/feed-tuners.tsx index 3ed60e5988..feeab6f9af 100644 --- a/src/state/preferences/feed-tuners.tsx +++ b/src/state/preferences/feed-tuners.tsx @@ -1,7 +1,7 @@ import {useMemo} from 'react' import {FeedTuner} from '#/lib/api/feed-manip' -import {FeedDescriptor} from '../queries/post-feed' +import {type FeedDescriptor} from '../queries/post-feed' import {usePreferencesQuery} from '../queries/preferences' import {useSession} from '../session' import {useLanguagePrefs} from './languages' @@ -19,7 +19,10 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { } } if (feedDesc.startsWith('feedgen')) { - return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)] + return [ + FeedTuner.preferredLangOnly(langPrefs.contentLanguages), + FeedTuner.removeMutedThreads, + ] } if (feedDesc === 'following' || feedDesc.startsWith('list')) { const feedTuners = [FeedTuner.removeOrphans] @@ -40,6 +43,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) { feedTuners.push(FeedTuner.removeQuotePosts) } feedTuners.push(FeedTuner.dedupThreads) + feedTuners.push(FeedTuner.removeMutedThreads) return feedTuners } From 7b5d11eb80f247173a11c37a6f1bb08824912a54 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 4 Aug 2025 18:42:55 +0300 Subject: [PATCH 4/6] Fix video thumbnails on native (#8774) --- package.json | 2 +- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 163 +++++++++--------- .../Post/Embed/VideoEmbed/index.tsx | 21 +-- yarn.lock | 8 +- 4 files changed, 94 insertions(+), 100 deletions(-) diff --git a/package.json b/package.json index 77908a1615..f89c7089c5 100644 --- a/package.json +++ b/package.json @@ -142,7 +142,7 @@ "expo-file-system": "~18.1.10", "expo-font": "~13.3.1", "expo-haptics": "~14.1.4", - "expo-image": "~2.2.1", + "expo-image": "^2.4.0", "expo-image-crop-tool": "^0.1.8", "expo-image-manipulator": "~13.1.7", "expo-image-picker": "~16.1.4", diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx index 351e9f3056..ecc36dc33b 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,4 +1,4 @@ -import React, {useRef} from 'react' +import {useImperativeHandle, useRef, useState} from 'react' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {BlueskyVideoView} from '@haileyok/bluesky-video' @@ -17,91 +17,88 @@ import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' import {TimeIndicator} from './TimeIndicator' -export const VideoEmbedInnerNative = React.forwardRef( - function VideoEmbedInnerNative( - { - embed, - setStatus, - setIsLoading, - setIsActive, - }: { - embed: AppBskyEmbedVideo.View - setStatus: (status: 'playing' | 'paused') => void - setIsLoading: (isLoading: boolean) => void - setIsActive: (isActive: boolean) => void +export function VideoEmbedInnerNative({ + ref, + embed, + setStatus, + setIsLoading, + setIsActive, +}: { + ref: React.Ref<{togglePlayback: () => void}> + embed: AppBskyEmbedVideo.View + setStatus: (status: 'playing' | 'paused') => void + setIsLoading: (isLoading: boolean) => void + setIsActive: (isActive: boolean) => void +}) { + const {_} = useLingui() + const videoRef = useRef(null) + const autoplayDisabled = useAutoplayDisabled() + const isWithinMessage = useIsWithinMessage() + const [muted, setMuted] = useVideoMuteState() + + const [isPlaying, setIsPlaying] = useState(false) + const [timeRemaining, setTimeRemaining] = useState(0) + const [error, setError] = useState() + + useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() }, - ref: React.Ref<{togglePlayback: () => void}>, - ) { - const {_} = useLingui() - const videoRef = useRef(null) - const autoplayDisabled = useAutoplayDisabled() - const isWithinMessage = useIsWithinMessage() - const [muted, setMuted] = useVideoMuteState() + })) - const [isPlaying, setIsPlaying] = React.useState(false) - const [timeRemaining, setTimeRemaining] = React.useState(0) - const [error, setError] = React.useState() + if (error) { + throw new Error(error) + } - React.useImperativeHandle(ref, () => ({ - togglePlayback: () => { - videoRef.current?.togglePlayback() - }, - })) - - if (error) { - throw new Error(error) - } - - return ( - - { - setIsActive(e.nativeEvent.isActive) - }} - onLoadingChange={e => { - setIsLoading(e.nativeEvent.isLoading) - }} - onMutedChange={e => { - setMuted(e.nativeEvent.isMuted) - }} - onStatusChange={e => { - setStatus(e.nativeEvent.status) - setIsPlaying(e.nativeEvent.status === 'playing') - }} - onTimeRemainingChange={e => { - setTimeRemaining(e.nativeEvent.timeRemaining) - }} - onError={e => { - setError(e.nativeEvent.error) - }} - ref={videoRef} - accessibilityLabel={ - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) - } - accessibilityHint="" - /> - { - videoRef.current?.enterFullscreen(true) - }} - toggleMuted={() => { - videoRef.current?.toggleMuted() - }} - togglePlayback={() => { - videoRef.current?.togglePlayback() - }} - isPlaying={isPlaying} - timeRemaining={timeRemaining} - /> - - - ) - }, -) + return ( + + { + setIsActive(e.nativeEvent.isActive) + }} + onLoadingChange={e => { + setIsLoading(e.nativeEvent.isLoading) + }} + onMutedChange={e => { + setMuted(e.nativeEvent.isMuted) + }} + onStatusChange={e => { + setStatus(e.nativeEvent.status) + setIsPlaying(e.nativeEvent.status === 'playing') + }} + onTimeRemainingChange={e => { + setTimeRemaining(e.nativeEvent.timeRemaining) + }} + onError={e => { + setError(e.nativeEvent.error) + }} + ref={videoRef} + accessibilityLabel={ + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) + } + accessibilityHint="" + /> + { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + + + ) +} function VideoControls({ enterFullscreen, diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx index 8cb78ff70b..c66d1a2185 100644 --- a/src/components/Post/Embed/VideoEmbed/index.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react' +import {useCallback, useRef, useState} from 'react' import {ActivityIndicator, View} from 'react-native' import {ImageBackground} from 'expo-image' import {type AppBskyEmbedVideo} from '@atproto/api' @@ -81,13 +81,13 @@ export function VideoEmbed({embed, crop}: Props) { function InnerWrapper({embed}: Props) { const {_} = useLingui() - const ref = React.useRef<{togglePlayback: () => void}>(null) + const ref = useRef<{togglePlayback: () => void}>(null) - const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( + const [status, setStatus] = useState<'playing' | 'paused' | 'pending'>( 'pending', ) - const [isLoading, setIsLoading] = React.useState(false) - const [isActive, setIsActive] = React.useState(false) + const [isLoading, setIsLoading] = useState(false) + const [isActive, setIsActive] = useState(false) const showSpinner = useThrottledValue(isActive && isLoading, 100) const showOverlay = @@ -96,11 +96,9 @@ function InnerWrapper({embed}: Props) { (status === 'paused' && !isActive) || status === 'pending' - React.useEffect(() => { - if (!isActive && status !== 'pending') { - setStatus('pending') - } - }, [isActive, status]) + if (!isActive && status !== 'pending') { + setStatus('pending') + } return ( <> @@ -131,8 +129,7 @@ function InnerWrapper({embed}: Props) { onPress={() => { ref.current?.togglePlayback() }} - label={_(msg`Play video`)} - color="secondary"> + label={_(msg`Play video`)}> {showSpinner ? ( Date: Mon, 4 Aug 2025 19:41:09 +0300 Subject: [PATCH 5/6] Fix android starting at notif screen (#8775) * fix android launching at notif screen * rm heuristic --- src/lib/hooks/useNotificationHandler.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index ddee11fb5d..3370a72081 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -400,9 +400,7 @@ export function getNotificationPayload( } } -export function notificationToURL( - payload: NotificationPayload, -): string | undefined { +export function notificationToURL(payload: NotificationPayload): string | null { switch (payload?.reason) { case 'like': case 'repost': @@ -433,10 +431,12 @@ export function notificationToURL( } case 'chat-message': // should be handled separately - return undefined + return null case 'verified': case 'unverified': - default: return '/notifications' + default: + // do nothing if we don't know what to do with it + return null } } From a5437ebbeb9d2da84a1560d4c68d7c9ce42c5140 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 4 Aug 2025 20:24:53 +0300 Subject: [PATCH 6/6] Fix web video ViewportObserver component (#8776) * Revert "[APP-1083] bug fix: videos not accurately autoplaying on web (#8692)" This reverts commit 9aa35e9fbb6136a88a66388ff5e4644ad25c9e4b. * fix overflow hidden breaking the video viewport observer --- .../Post/Embed/VideoEmbed/index.web.tsx | 106 +++++++++--------- 1 file changed, 51 insertions(+), 55 deletions(-) diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx index 900bc21889..5bb54eef88 100644 --- a/src/components/Post/Embed/VideoEmbed/index.web.tsx +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -1,4 +1,11 @@ -import {useCallback, useEffect, useRef, useState} from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' import {View} from 'react-native' import {type AppBskyEmbedVideo} from '@atproto/api' import {msg} from '@lingui/macro' @@ -83,9 +90,7 @@ export function VideoEmbed({ style={{display: 'flex', flex: 1, cursor: 'default'}} onClick={evt => evt.stopPropagation()}> - + - + ) return ( - {cropDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} + + {cropDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + ) } +const NearScreenContext = createContext(false) + /** * Renders a 100vh tall div and watches it with an IntersectionObserver to * send the position of the div when it's near the screen. + * + * IMPORTANT: ViewportObserver _must_ not be within a `overflow: hidden` container. */ function ViewportObserver({ children, @@ -138,53 +151,22 @@ function ViewportObserver({ useEffect(() => { if (!ref.current) return if (isFullscreen && !isFirefox) return - - let scrollTimeout: NodeJS.Timeout | null = null - let lastObserverEntry: IntersectionObserverEntry | null = null - - const updatePositionFromEntry = () => { - if (!lastObserverEntry) return - const rect = lastObserverEntry.boundingClientRect - const position = rect.y + rect.height / 2 - sendPosition(position) - } - - const handleScroll = () => { - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - scrollTimeout = setTimeout(updatePositionFromEntry, 4) // ~240fps - } - const observer = new IntersectionObserver( entries => { const entry = entries[0] if (!entry) return - lastObserverEntry = entry - setNearScreen(entry.isIntersecting) - const rect = entry.boundingClientRect - const position = rect.y + rect.height / 2 + const position = + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 sendPosition(position) + setNearScreen(entry.isIntersecting) }, - {threshold: [0, 0.1, 0.25, 0.5, 0.75, 1.0]}, + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, ) - observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition, isFullscreen]) - if (nearScreen) { - window.addEventListener('scroll', handleScroll, {passive: true}) - } - - return () => { - observer.disconnect() - if (scrollTimeout) { - clearTimeout(scrollTimeout) - } - window.removeEventListener('scroll', handleScroll) - } - }, [sendPosition, isFullscreen, nearScreen]) - - // In case scrolling hasn't started yet, send the original position + // In case scrolling hasn't started yet, send up the position useEffect(() => { if (ref.current && !isAnyViewActive) { const rect = ref.current.getBoundingClientRect() @@ -195,7 +177,9 @@ function ViewportObserver({ return ( - {nearScreen && children} + + {children} +
{ + const nearScreen = useContext(NearScreenContext) + + return nearScreen ? children : null +} + function VideoError({error, retry}: {error: unknown; retry: () => void}) { const {_} = useLingui()