Merge remote-tracking branch 'origin/main' into app-1310/design

* origin/main:
  [LEG-203] Add onboarding text for policy updates (#8783)
  [APP-1356] Policy update dialog (#8782)
  Fix quality of first seconds of videos on web (#8761) (#8778)
  Nightly source-language update
  Fix web video ViewportObserver component (#8776)
  Fix android starting at notif screen (#8775)
  Fix video thumbnails on native (#8774)
  Add feed tuner to remove muted threads (#8773)
  [APP-1083] bug fix: videos not accurately autoplaying on web (#8692)
  Tweak accessibility label (#8767)
  Nightly source-language update
  fix(ui): small bug where on small web views the final card was not full height
  fix(ui): adjust padding for ProfileGrid layout consistency
  README: fix verb form of CC
  README: fix brand capitalization ("Github" -> "GitHub")
  README: improve phrasing of packages mention
  README: Use alert for the contributions note
  Don't animate images that will be posted as JPGs (#8760)
This commit is contained in:
Eric Bailey
2025-08-06 15:18:14 -05:00
46 changed files with 1493 additions and 272 deletions
+4 -3
View File
@@ -10,7 +10,7 @@ Get the app itself:
## Development Resources
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), code for which is also open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
This is a [React Native](https://reactnative.dev/) application, written in the TypeScript programming language. It builds on the `atproto` TypeScript packages (like [`@atproto/api`](https://www.npmjs.com/package/@atproto/api)), which are also open source, but in [a different git repository](https://github.com/bluesky-social/atproto).
There is a small amount of Go language source code (in `./bskyweb/`), for a web service that returns the React Native Web application.
@@ -19,7 +19,7 @@ The [Build Instructions](./docs/build.md) are a good place to get started with t
The Authenticated Transfer Protocol ("AT Protocol" or "atproto") is a decentralized social media protocol. You don't *need* to understand AT Protocol to work with this application, but it can help. Learn more at:
- [Overview and Guides](https://atproto.com/guides/overview)
- [Github Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [GitHub Discussions](https://github.com/bluesky-social/atproto/discussions) 👈 Great place to ask questions
- [Protocol Specifications](https://atproto.com/specs/atp)
- [Blogpost on self-authenticating data structures](https://bsky.social/about/blog/3-6-2022-a-self-authenticating-social-protocol)
@@ -27,6 +27,7 @@ The Bluesky Social application encompasses a set of schemas and APIs built in th
## Contributions
> [!NOTE]
> While we do accept contributions, we prioritize high quality issues and pull requests. Adhering to the below guidelines will ensure a more timely review.
**Rules:**
@@ -59,7 +60,7 @@ Please be sure to:
## Security disclosures
If you discover any security issues, please send an email to security@bsky.app. The email is automatically CCed to the entire team and we'll respond promptly.
If you discover any security issues, please send an email to security@bsky.app. The email is automatically CC'd to the entire team and we'll respond promptly.
## Are you a developer interested in building on atproto?
+1 -1
View File
@@ -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",
+46 -43
View File
@@ -70,6 +70,7 @@ import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {Splash} from '#/Splash'
@@ -137,49 +138,51 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</React.Fragment>
</VideoVolumeProvider>
+42 -39
View File
@@ -57,6 +57,7 @@ import {Provider as ContextMenuProvider} from '#/components/ContextMenu'
import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialogs'
import {Provider as PolicyUpdateOverlayProvider} from '#/components/PolicyUpdateOverlay'
import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
@@ -117,45 +118,47 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<AgeAssuranceProvider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<NuxDialogs />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<ToastContainer />
</React.Fragment>
+6 -6
View File
@@ -378,8 +378,8 @@ export function ProfileGrid({
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View
style={[
a.p_lg,
a.py_md,
a.px_lg,
a.pt_md,
a.flex_row,
a.align_center,
a.justify_between,
@@ -399,7 +399,7 @@ export function ProfileGrid({
</View>
{gtMobile ? (
<View style={[a.px_lg, a.pb_lg]}>
<View style={[a.p_lg, a.pt_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}>
{content}
</View>
@@ -411,9 +411,8 @@ export function ProfileGrid({
horizontal
showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast"
style={[a.overflow_visible]}>
<View style={[a.px_lg, a.pb_lg, a.flex_row, a.gap_md]}>
decelerationRate="fast">
<View style={[a.p_lg, a.pt_md, a.flex_row, a.gap_md]}>
{content}
<SeeMoreSuggestedProfilesCard />
@@ -434,6 +433,7 @@ function SeeMoreSuggestedProfilesCard() {
return (
<Button
label={_(msg`Browse more accounts on the Explore page`)}
style={[a.flex_col]}
onPress={() => {
navigation.navigate('SearchTab')
}}>
+144
View File
@@ -0,0 +1,144 @@
import {
Children,
cloneElement,
isValidElement,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import {
AccessibilityInfo,
findNodeHandle,
Pressable,
Text,
View,
} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useA11y} from '#/state/a11y'
/**
* Conditionally wraps children in a `FocusTrap` component based on whether
* screen reader support is enabled. THIS SHOULD BE USED SPARINGLY, only when
* no better option is available.
*/
export function FocusScope({children}: {children: ReactNode}) {
const {screenReaderEnabled} = useA11y()
return screenReaderEnabled ? <FocusTrap>{children}</FocusTrap> : children
}
/**
* `FocusTrap` is intended as a last-ditch effort to ensure that users keep
* focus within a certain section of the app, like an overlay.
*
* It works by placing "guards" at the start and end of the active content.
* Then when the user reaches either of those guards, it will announce that
* they have reached the start or end of the content and tell them how to
* remain within the active content section.
*/
function FocusTrap({children}: {children: ReactNode}) {
const {_} = useLingui()
const child = useRef<View>(null)
/*
* Here we add a ref to the first child of this component. This currently
* overrides any ref already on that first child, so we throw an error here
* to prevent us from ever accidentally doing this.
*/
const decoratedChildren = useMemo(() => {
return Children.toArray(children).map((node, i) => {
if (i === 0 && isValidElement(node)) {
const n = node as ReactElement<any>
if (n.props.ref !== undefined) {
throw new Error(
'FocusScope needs to override the ref on its first child.',
)
}
return cloneElement(n, {
...n.props,
ref: child,
})
}
return node
})
}, [children])
const focusNode = useCallback((ref: View | null) => {
if (!ref) return
const node = findNodeHandle(ref)
if (node) {
AccessibilityInfo.setAccessibilityFocus(node)
}
}, [])
useEffect(() => {
setTimeout(() => {
focusNode(child.current)
}, 1e3)
}, [focusNode])
return (
<>
<Pressable
accessible
accessibilityLabel={_(
msg`You've reached the start of the active content.`,
)}
accessibilityHint={_(
msg`Please go back, or activate this element to return to the start of the active content.`,
)}
accessibilityActions={[{name: 'activate', label: 'activate'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'activate': {
focusNode(child.current)
}
}
}}>
<Noop />
</Pressable>
<View
/**
* This property traps focus effectively on iOS, but not on Android.
*/
accessibilityViewIsModal>
{decoratedChildren}
</View>
<Pressable
accessibilityLabel={_(
msg`You've reached the end of the active content.`,
)}
accessibilityHint={_(
msg`Please go back, or activate this element to return to the start of the active content.`,
)}
accessibilityActions={[{name: 'activate', label: 'activate'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'activate': {
focusNode(child.current)
}
}
}}>
<Noop />
</Pressable>
</>
)
}
function Noop() {
return (
<Text
accessible={false}
style={{
height: 1,
opacity: 0,
}}>
{' '}
</Text>
)
}
+15
View File
@@ -0,0 +1,15 @@
import {type ReactNode} from 'react'
import {FocusScope as RadixFocusScope} from 'radix-ui/internal'
/*
* The web version of the FocusScope component is a proper implementation, we
* use this in Dialogs and such already. It's here as a convenient counterpart
* to the hacky native solution.
*/
export function FocusScope({children}: {children: ReactNode}) {
return (
<RadixFocusScope.FocusScope loop asChild trapped>
{children}
</RadixFocusScope.FocusScope>
)
}
+3
View File
@@ -0,0 +1,3 @@
export function LockScroll() {
return null
}
+3
View File
@@ -0,0 +1,3 @@
import {RemoveScrollBar} from 'react-remove-scroll-bar'
export const LockScroll = RemoveScrollBar
@@ -0,0 +1,38 @@
import {View} from 'react-native'
import {Trans} from '@lingui/macro'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
export function Badge() {
const t = useTheme()
return (
<View style={[a.align_start]}>
<View
style={[
a.pl_md,
a.pr_lg,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_25,
},
]}>
<Logo fill={t.palette.primary_600} width={14} />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_600,
},
]}>
<Trans>Announcement</Trans>
</Text>
</View>
</View>
)
}
@@ -0,0 +1,139 @@
import {type ReactNode} from 'react'
import {ScrollView, View} from 'react-native'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {LinearGradient} from 'expo-linear-gradient'
import {isAndroid, isNative} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
import {transparentifyColor} from '#/alf/util/colorGeneration'
import {FocusScope} from '#/components/FocusScope'
import {LockScroll} from '#/components/LockScroll'
const GUTTER = 24
export function Overlay({
children,
label,
}: {
children: ReactNode
label: string
}) {
const t = useTheme()
const {gtPhone} = useBreakpoints()
const {reduceMotionEnabled} = useA11y()
const insets = useSafeAreaInsets()
const frame = useSafeAreaFrame()
return (
<>
<LockScroll />
<View style={[a.fixed, a.inset_0, !reduceMotionEnabled && a.fade_in]}>
{gtPhone ? (
<View style={[a.absolute, a.inset_0, {opacity: 0.8}]}>
<View
style={[
a.fixed,
a.inset_0,
{backgroundColor: t.palette.black},
!reduceMotionEnabled && a.fade_in,
]}
/>
</View>
) : (
<LinearGradient
colors={[
transparentifyColor(t.atoms.bg.backgroundColor, 0),
t.atoms.bg.backgroundColor,
t.atoms.bg.backgroundColor,
]}
start={[0.5, 0]}
end={[0.5, 1]}
style={[a.absolute, a.inset_0]}
/>
)}
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={[
a.z_10,
gtPhone &&
web({
paddingHorizontal: GUTTER,
paddingVertical: '10vh',
}),
]}
contentContainerStyle={[a.align_center]}>
{/**
* This is needed to prevent centered dialogs from overflowing
* above the screen, and provides a "natural" centering so that
* stacked dialogs appear relatively aligned.
*/}
<View
style={[
a.w_full,
a.z_20,
a.align_center,
!gtPhone && [a.justify_end, {minHeight: frame.height}],
isNative && [
{
paddingBottom: Math.max(insets.bottom, a.p_2xl.padding),
},
],
]}>
{!gtPhone && (
<View
style={[
a.flex_1,
a.w_full,
{
minHeight: Math.max(insets.top, a.p_2xl.padding),
},
]}>
<LinearGradient
colors={[
transparentifyColor(t.atoms.bg.backgroundColor, 0),
t.atoms.bg.backgroundColor,
]}
start={[0.5, 0]}
end={[0.5, 1]}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<FocusScope>
<View
accessible={isAndroid}
role="dialog"
aria-role="dialog"
aria-label={label}
style={flatten([
a.relative,
a.w_full,
a.p_2xl,
t.atoms.bg,
!reduceMotionEnabled && a.zoom_fade_in,
gtPhone && [
a.rounded_md,
a.border,
t.atoms.shadow_lg,
t.atoms.border_contrast_low,
web({
maxWidth: 420,
}),
],
])}>
{children}
</View>
</FocusScope>
</View>
</ScrollView>
</>
)
}
@@ -0,0 +1,7 @@
import {createPortalGroup} from '#/components/Portal'
const portalGroup = createPortalGroup()
export const Provider = portalGroup.Provider
export const Portal = portalGroup.Portal
export const Outlet = portalGroup.Outlet
@@ -0,0 +1,195 @@
import {describe, test} from '@jest/globals'
import {
computeCompletedState,
syncCompletedState,
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
jest.mock('../../../state/queries/nuxs')
describe('computeCompletedState', () => {
test(`initial state`, () => {
const completed = computeCompletedState({
nuxIsReady: false,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: undefined,
})
expect(completed).toBe(true)
})
test(`nux loaded state`, () => {
const completed = computeCompletedState({
nuxIsReady: true,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: undefined,
})
expect(completed).toBe(false)
})
test(`nux saving state`, () => {
const completed = computeCompletedState({
nuxIsReady: true,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: true,
completedForDevice: undefined,
})
expect(completed).toBe(true)
})
test(`nux is completed`, () => {
const completed = computeCompletedState({
nuxIsReady: true,
nuxIsCompleted: true,
nuxIsOptimisticallyCompleted: false,
completedForDevice: undefined,
})
expect(completed).toBe(true)
})
test(`initial state, but already completed for device`, () => {
const completed = computeCompletedState({
nuxIsReady: false,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: true,
})
expect(completed).toBe(true)
})
})
describe('syncCompletedState', () => {
describe('!nuxIsReady', () => {
test(`!completedForDevice, no-op`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: false,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: false,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
test(`completedForDevice, no-op`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: false,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: true,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
})
describe('nuxIsReady', () => {
describe(`!nuxIsCompleted`, () => {
describe(`!nuxIsOptimisticallyCompleted`, () => {
test(`!completedForDevice, no-op`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: true,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: false,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
test(`completedForDevice, syncs to server`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: true,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: false,
completedForDevice: true,
save,
setCompletedForDevice,
})
expect(save).toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
})
/**
* Catches the case where we already called `save` to sync device state
* to server, thus `nuxIsOptimisticallyCompleted` is true.
*/
describe(`nuxIsOptimisticallyCompleted`, () => {
test(`completedForDevice, no-op`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: true,
nuxIsCompleted: false,
nuxIsOptimisticallyCompleted: true,
completedForDevice: true,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
})
})
describe(`nuxIsCompleted`, () => {
test(`!completedForDevice, syncs to device`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: true,
nuxIsCompleted: true,
nuxIsOptimisticallyCompleted: false,
completedForDevice: false,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).toHaveBeenCalled()
})
test(`completedForDevice, no-op`, () => {
const save = jest.fn()
const setCompletedForDevice = jest.fn()
syncCompletedState({
nuxIsReady: true,
nuxIsCompleted: true,
nuxIsOptimisticallyCompleted: false,
completedForDevice: true,
save,
setCompletedForDevice,
})
expect(save).not.toHaveBeenCalled()
expect(setCompletedForDevice).not.toHaveBeenCalled()
})
})
})
})
@@ -0,0 +1,7 @@
import {ID} from '#/components/PolicyUpdateOverlay/updates/202508/config'
/**
* The singulary active update ID. This is configured here to ensure that
* the relationship is clear.
*/
export const ACTIVE_UPDATE_ID = ID
@@ -0,0 +1,32 @@
import {createContext, type ReactNode, useContext} from 'react'
import {Provider as PortalProvider} from '#/components/PolicyUpdateOverlay/Portal'
import {
type PolicyUpdateState,
usePolicyUpdateState,
} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
const Context = createContext<PolicyUpdateState>({
completed: true,
complete: () => {},
})
export function usePolicyUpdateStateContext() {
const context = useContext(Context)
if (!context) {
throw new Error(
'usePolicyUpdateStateContext must be used within a PolicyUpdateProvider',
)
}
return context
}
export function Provider({children}: {children?: ReactNode}) {
const state = usePolicyUpdateState()
return (
<PortalProvider>
<Context.Provider value={state}>{children}</Context.Provider>
</PortalProvider>
)
}
@@ -0,0 +1,41 @@
import {View} from 'react-native'
import {isIOS} from '#/platform/detection'
import {atoms as a} from '#/alf'
import {FullWindowOverlay} from '#/components/FullWindowOverlay'
import {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context'
import {Portal} from '#/components/PolicyUpdateOverlay/Portal'
import {Content} from '#/components/PolicyUpdateOverlay/updates/202508'
export {Provider} from '#/components/PolicyUpdateOverlay/context'
export {usePolicyUpdateStateContext} from '#/components/PolicyUpdateOverlay/context'
export {Outlet} from '#/components/PolicyUpdateOverlay/Portal'
export function PolicyUpdateOverlay() {
const state = usePolicyUpdateStateContext()
/*
* See `window.clearNux` example in `/state/queries/nuxs` for a way to clear
* NUX state for local testing and debugging.
*/
if (state.completed) return null
return (
<Portal>
<FullWindowOverlay>
<View
style={[
a.fixed,
a.inset_0,
// setting a zIndex when using FullWindowOverlay on iOS
// means the taps pass straight through to the underlying content (???)
// so don't set it on iOS. FullWindowOverlay already does the job.
!isIOS && {zIndex: 9999},
]}>
<Content state={state} />
</View>
</FullWindowOverlay>
</Portal>
)
}
@@ -0,0 +1,3 @@
import {Logger} from '#/logger'
export const logger = Logger.create(Logger.Context.PolicyUpdate)
@@ -0,0 +1,7 @@
/*
* Keep this file separate to avoid import issues.
*/
import {Nux} from '#/state/queries/nuxs'
export const ID = Nux.PolicyUpdate202508
@@ -0,0 +1,190 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isAndroid} from '#/platform/detection'
import {useA11y} from '#/state/a11y'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {InlineLinkText, Link} from '#/components/Link'
import {Badge} from '#/components/PolicyUpdateOverlay/Badge'
import {Overlay} from '#/components/PolicyUpdateOverlay/Overlay'
import {type PolicyUpdateState} from '#/components/PolicyUpdateOverlay/usePolicyUpdateState'
import {Text} from '#/components/Typography'
export function Content({state}: {state: PolicyUpdateState}) {
const t = useTheme()
const {_} = useLingui()
const {screenReaderEnabled} = useA11y()
const handleClose = useCallback(() => {
state.complete()
}, [state])
const linkStyle = [a.text_md]
const links = {
terms: {
overridePresentation: false,
to: `https://bsky.social/about/support`,
label: _(msg`Terms of Service`),
},
privacy: {
overridePresentation: false,
to: `https://bsky.social/about/support`,
label: _(msg`Privacy Policy`),
},
copyright: {
overridePresentation: false,
to: `https://bsky.social/about/support`,
label: _(msg`Copyright Policy`),
},
guidelines: {
overridePresentation: false,
to: `https://bsky.social/about/support`,
label: _(msg`Community Guidelines`),
},
blog: {
overridePresentation: false,
to: `https://bsky.social/about/support`,
label: _(msg`Our blog post`),
},
}
const linkButtonStyles = {
overridePresentation: false,
color: 'secondary',
size: 'small',
} as const
const label = isAndroid
? _(
msg`Were updating our Terms of Service, Privacy Policy, and Copyright Policy, effective September 12th, 2025. We're also updating our Community Guidelines, and we want your input! These new guidelines will take effect on October 13th, 2025. Learn more about these changes and how to share your thoughts with us by reading our blog post.`,
)
: _(msg`We're updating our policies`)
return (
<Overlay label={label}>
<View style={[a.align_start, a.gap_xl]}>
<Badge />
{screenReaderEnabled ? (
<View style={[a.gap_sm]}>
<Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}>
<Trans>Hey there 👋</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Were updating our Terms of Service, Privacy Policy, and
Copyright Policy, effective September 12th, 2025.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
We're also updating our Community Guidelines, and we want your
input! These new guidelines will take effect on October 13th,
2025.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Learn more about these changes and how to share your thoughts
with us by reading our blog post.
</Trans>
</Text>
<Link {...links.terms} {...linkButtonStyles}>
<ButtonText>
<Trans>Terms of Service</Trans>
</ButtonText>
</Link>
<Link {...links.privacy} {...linkButtonStyles}>
<ButtonText>
<Trans>Privacy Policy</Trans>
</ButtonText>
</Link>
<Link {...links.copyright} {...linkButtonStyles}>
<ButtonText>
<Trans>Copyright Policy</Trans>
</ButtonText>
</Link>
<Link {...links.blog} {...linkButtonStyles}>
<ButtonText>
<Trans>Read our blog post</Trans>
</ButtonText>
</Link>
</View>
) : (
<View style={[a.gap_sm]}>
<Text emoji style={[a.text_2xl, a.font_bold, a.leading_snug]}>
<Trans>Hey there 👋</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Were updating our{' '}
<InlineLinkText {...links.terms} style={linkStyle}>
Terms of Service
</InlineLinkText>
,{' '}
<InlineLinkText {...links.privacy} style={linkStyle}>
Privacy Policy
</InlineLinkText>
, and{' '}
<InlineLinkText {...links.copyright} style={linkStyle}>
Copyright Policy
</InlineLinkText>
, effective September 12th, 2025.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
We're also updating our{' '}
<InlineLinkText {...links.guidelines} style={linkStyle}>
Community Guidelines
</InlineLinkText>
, and we want your input! These new guidelines will take effect
on October 13th, 2025.
</Trans>
</Text>
<Text style={[a.leading_snug, a.text_md]}>
<Trans>
Learn more about these changes and how to share your thoughts
with us by{' '}
<InlineLinkText {...links.blog} style={linkStyle}>
reading our blog post.
</InlineLinkText>
</Trans>
</Text>
</View>
)}
<View style={[a.w_full, a.gap_md]}>
<Button
label={_(msg`Continue`)}
accessibilityHint={_(
msg`Tap to acknowledge that you understand and agree to these updates and continue using Bluesky`,
)}
color="primary"
size="large"
onPress={handleClose}>
<ButtonText>
<Trans>Continue</Trans>
</ButtonText>
</Button>
<Text
style={[
a.leading_snug,
a.text_sm,
a.italic,
t.atoms.text_contrast_medium,
]}>
<Trans>
By clicking "Continue" you acknowledge that you understand and
agree to these updates.
</Trans>
</Text>
</View>
</View>
</Overlay>
)
}
@@ -0,0 +1,135 @@
import {useMemo} from 'react'
import {useNux, useSaveNux} from '#/state/queries/nuxs'
import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config'
import {logger} from '#/components/PolicyUpdateOverlay/logger'
import {IS_DEV} from '#/env'
import {device, useStorage} from '#/storage'
export type PolicyUpdateState = {
completed: boolean
complete: () => void
}
export function usePolicyUpdateState() {
const nux = useNux(ACTIVE_UPDATE_ID)
const {mutate: save, variables} = useSaveNux()
const deviceStorage = useStorage(device, [ACTIVE_UPDATE_ID])
const debugOverride =
!!useStorage(device, ['policyUpdateDebugOverride'])[0] && IS_DEV
return useMemo(() => {
const nuxIsReady = nux.status === 'ready'
const nuxIsCompleted = nux.nux?.completed === true
const nuxIsOptimisticallyCompleted = !!variables?.completed
const [completedForDevice, setCompletedForDevice] = deviceStorage
const completed = computeCompletedState({
nuxIsReady,
nuxIsCompleted,
nuxIsOptimisticallyCompleted,
completedForDevice,
})
logger.debug(`state`, {
completed,
nux,
completedForDevice,
})
if (!debugOverride) {
syncCompletedState({
nuxIsReady,
nuxIsCompleted,
nuxIsOptimisticallyCompleted,
completedForDevice,
save,
setCompletedForDevice,
})
}
return {
completed,
complete() {
logger.debug(`user completed`)
save({
id: ACTIVE_UPDATE_ID,
completed: true,
data: undefined,
})
setCompletedForDevice(true)
},
}
}, [nux, save, variables, deviceStorage, debugOverride])
}
export function computeCompletedState({
nuxIsReady,
nuxIsCompleted,
nuxIsOptimisticallyCompleted,
completedForDevice,
}: {
nuxIsReady: boolean
nuxIsCompleted: boolean
nuxIsOptimisticallyCompleted: boolean
completedForDevice: boolean | undefined
}): boolean {
/**
* Assume completed to prevent flash
*/
let completed = true
/**
* Prefer server state, if available
*/
if (nuxIsReady) {
completed = nuxIsCompleted
}
/**
* Override with optimistic state or device state
*/
if (nuxIsOptimisticallyCompleted || !!completedForDevice) {
completed = true
}
return completed
}
export function syncCompletedState({
nuxIsReady,
nuxIsCompleted,
nuxIsOptimisticallyCompleted,
completedForDevice,
save,
setCompletedForDevice,
}: {
nuxIsReady: boolean
nuxIsCompleted: boolean
nuxIsOptimisticallyCompleted: boolean
completedForDevice: boolean | undefined
save: ReturnType<typeof useSaveNux>['mutate']
setCompletedForDevice: (value: boolean) => void
}) {
/*
* Sync device state to server state for this account
*/
if (
nuxIsReady &&
!nuxIsCompleted &&
!nuxIsOptimisticallyCompleted &&
!!completedForDevice
) {
logger.debug(`syncing device state to server state`)
save({
id: ACTIVE_UPDATE_ID,
completed: true,
data: undefined,
})
} else if (nuxIsReady && nuxIsCompleted && !completedForDevice) {
logger.debug(`syncing server state to device state`)
/*
* Sync server state to device state
*/
setCompletedForDevice(true)
}
}
@@ -0,0 +1,21 @@
import {useCallback} from 'react'
import {ACTIVE_UPDATE_ID} from '#/components/PolicyUpdateOverlay/config'
import {logger} from '#/components/PolicyUpdateOverlay/logger'
import {device, useStorage} from '#/storage'
/*
* Marks the active policy update as completed in device storage.
* `usePolicyUpdateState` will react to this and replicate this status in the
* server NUX state for this account.
*/
export function usePreemptivelyCompleteActivePolicyUpdate() {
const [_completedForDevice, setCompletedForDevice] = useStorage(device, [
ACTIVE_UPDATE_ID,
])
return useCallback(() => {
logger.debug(`preemptively completing active policy update`)
setCompletedForDevice(true)
}, [setCompletedForDevice])
}
@@ -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<BlueskyVideoView>(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<string>()
useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
ref: React.Ref<{togglePlayback: () => void}>,
) {
const {_} = useLingui()
const videoRef = useRef<BlueskyVideoView>(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<string>()
if (error) {
throw new Error(error)
}
React.useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
}))
if (error) {
throw new Error(error)
}
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={autoplayDisabled ? false : muted}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
}}
onLoadingChange={e => {
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(e.nativeEvent.isMuted)
}}
onStatusChange={e => {
setStatus(e.nativeEvent.status)
setIsPlaying(e.nativeEvent.status === 'playing')
}}
onTimeRemainingChange={e => {
setTimeRemaining(e.nativeEvent.timeRemaining)
}}
onError={e => {
setError(e.nativeEvent.error)
}}
ref={videoRef}
accessibilityLabel={
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
}
accessibilityHint=""
/>
<VideoControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
<MediaInsetBorder />
</View>
)
},
)
return (
<View style={[a.flex_1, a.relative]}>
<BlueskyVideoView
url={embed.playlist}
autoplay={!autoplayDisabled && !isWithinMessage}
beginMuted={autoplayDisabled ? false : muted}
style={[a.rounded_sm]}
onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
}}
onLoadingChange={e => {
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(e.nativeEvent.isMuted)
}}
onStatusChange={e => {
setStatus(e.nativeEvent.status)
setIsPlaying(e.nativeEvent.status === 'playing')
}}
onTimeRemainingChange={e => {
setTimeRemaining(e.nativeEvent.timeRemaining)
}}
onError={e => {
setError(e.nativeEvent.error)
}}
ref={videoRef}
accessibilityLabel={
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`)
}
accessibilityHint=""
/>
<VideoControls
enterFullscreen={() => {
videoRef.current?.enterFullscreen(true)
}}
toggleMuted={() => {
videoRef.current?.toggleMuted()
}}
togglePlayback={() => {
videoRef.current?.togglePlayback()
}}
isPlaying={isPlaying}
timeRemaining={timeRemaining}
/>
<MediaInsetBorder />
</View>
)
}
function VideoControls({
enterFullscreen,
@@ -224,15 +224,19 @@ function useHLS({
throw new HLSUnsupportedError()
}
const latestEstimate = BandwidthEstimate.get()
const hls = new Hls({
maxMaxBufferLength: 10, // only load 10s ahead
// note: the amount buffered is affected by both maxBufferLength and maxBufferSize
// it will buffer until it is greater than *both* of those values
// so we use maxMaxBufferLength to set the actual maximum amount of buffering instead
startLevel:
latestEstimate === undefined ? -1 : Hls.DefaultConfig.startLevel,
// the '-1' value makes a test request to estimate bandwidth and quality level
// before showing the first fragment
})
hlsRef.current = hls
const latestEstimate = BandwidthEstimate.get()
if (latestEstimate !== undefined) {
hls.bandwidthEstimate = latestEstimate
}
+9 -12
View File
@@ -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 ? (
<View
style={[
@@ -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()}>
<ErrorBoundary renderError={renderError} key={key}>
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
<OnlyNearScreen>
<VideoEmbedInnerWeb
embed={embed}
active={active}
@@ -93,31 +98,39 @@ export function VideoEmbed({
onScreen={onScreen}
lastKnownTime={lastKnownTime}
/>
</ViewportObserver>
</OnlyNearScreen>
</ErrorBoundary>
</div>
)
return (
<View style={[a.pt_xs]}>
{cropDisabled ? (
<View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}>
{contents}
</ConstrainedImage>
)}
<ViewportObserver
sendPosition={sendPosition}
isAnyViewActive={currentActiveView !== null}>
{cropDisabled ? (
<View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}>
{contents}
</View>
) : (
<ConstrainedImage
fullBleed={crop === 'square'}
aspectRatio={constrained || 1}>
{contents}
</ConstrainedImage>
)}
</ViewportObserver>
</View>
)
}
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,
@@ -164,7 +177,9 @@ function ViewportObserver({
return (
<View style={[a.flex_1, a.flex_row]}>
{nearScreen && children}
<NearScreenContext.Provider value={nearScreen}>
{children}
</NearScreenContext.Provider>
<div
ref={ref}
style={{
@@ -182,6 +197,18 @@ function ViewportObserver({
)
}
/**
* Awkward data flow here, but we need to hide the video when it's not near the screen.
* But also, ViewportObserver _must_ not be within a `overflow: hidden` container.
* So we put it at the top level of the component tree here, then hide the children of
* the auto-resizing container.
*/
export const OnlyNearScreen = ({children}: {children: React.ReactNode}) => {
const nearScreen = useContext(NearScreenContext)
return nearScreen ? children : null
}
function VideoError({error, retry}: {error: unknown; retry: () => void}) {
const {_} = useLingui()
+6 -1
View File
@@ -78,7 +78,12 @@ export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
<Toast content={activeToast.content} type={activeToast.type} />
<Pressable
style={[a.absolute, a.inset_0]}
accessibilityLabel={_(msg`Dismiss toast`)}
accessibilityLabel={_(
msg({
message: `Dismiss message`,
comment: `Accessibility label for dismissing a toast notification`,
}),
)}
accessibilityHint=""
onPress={() => setActiveToast(undefined)}
/>
+16
View File
@@ -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[],
+7
View File
@@ -207,3 +207,10 @@ export const PUBLIC_APPVIEW_DID = 'did:web:api.bsky.app'
export const PUBLIC_STAGING_APPVIEW_DID = 'did:web:api.staging.bsky.dev'
export const DEV_ENV_APPVIEW = `http://localhost:2584` // always the same
export const webLinks = {
tos: `https://bsky.social/about/support/tos`,
privacy: `https://bsky.social/about/support/privacy-policy`,
community: `https://bsky.social/about/support/community-guidelines`,
communityDeprecated: `https://bsky.social/about/support/community-guidelines-deprecated`,
}
+5 -5
View File
@@ -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
}
}
+1
View File
@@ -3,6 +3,7 @@ export type Gate =
| 'alt_share_icon'
| 'debug_show_feedcontext'
| 'debug_subscriptions'
| 'disable_onboarding_policy_update_notice'
| 'explore_show_suggested_feeds'
| 'old_postonboarding'
| 'onboarding_add_video_feed'
+38 -39
View File
@@ -657,8 +657,8 @@ msgstr ""
#: src/view/com/composer/GifAltText.tsx:76
#: src/view/com/composer/GifAltText.tsx:144
#: src/view/com/composer/GifAltText.tsx:210
#: src/view/com/composer/photos/Gallery.tsx:169
#: src/view/com/composer/photos/Gallery.tsx:216
#: src/view/com/composer/photos/Gallery.tsx:170
#: src/view/com/composer/photos/Gallery.tsx:217
#: src/view/com/composer/photos/ImageAltTextDialog.tsx:88
#: src/view/com/composer/photos/ImageAltTextDialog.tsx:93
msgid "Add alt text"
@@ -870,7 +870,7 @@ msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:186
#: src/view/com/composer/GifAltText.tsx:100
#: src/view/com/composer/photos/Gallery.tsx:187
#: src/view/com/composer/photos/Gallery.tsx:188
msgid "ALT"
msgstr ""
@@ -888,7 +888,7 @@ msgstr ""
msgid "Alt Text"
msgstr ""
#: src/view/com/composer/photos/Gallery.tsx:255
#: src/view/com/composer/photos/Gallery.tsx:260
msgid "Alt text describes images for blind and low-vision users, and helps give context to everyone."
msgstr ""
@@ -921,11 +921,11 @@ msgstr ""
msgid "An error occurred while generating your starter pack. Want to try again?"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.tsx:160
#: src/components/Post/Embed/VideoEmbed/index.tsx:157
msgid "An error occurred while loading the video. Please try again later."
msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:199
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:226
msgid "An error occurred while loading the video. Please try again."
msgstr ""
@@ -1423,7 +1423,7 @@ msgstr ""
msgid "Books"
msgstr ""
#: src/components/FeedInterstitials.tsx:436
#: src/components/FeedInterstitials.tsx:435
msgid "Browse more accounts on the Explore page"
msgstr ""
@@ -2309,12 +2309,9 @@ msgstr ""
msgid "Create Account"
msgstr ""
#: src/screens/Search/SearchResults.tsx:266
msgid "create an account"
msgstr ""
#: src/components/dialogs/Signin.tsx:86
#: src/components/dialogs/Signin.tsx:88
#: src/screens/Search/SearchResults.tsx:266
msgid "Create an account"
msgstr ""
@@ -2662,6 +2659,11 @@ msgstr ""
msgid "Dismiss interests"
msgstr ""
#. Accessibility label for dismissing a toast notification
#: src/components/Toast/index.web.tsx:82
msgid "Dismiss message"
msgstr ""
#: src/components/interstitials/TrendingVideos.tsx:89
msgid "Dismiss this section"
msgstr ""
@@ -2846,7 +2848,7 @@ msgstr ""
#: src/view/com/composer/photos/EditImageDialog.web.tsx:85
#: src/view/com/composer/photos/EditImageDialog.web.tsx:89
#: src/view/com/composer/photos/Gallery.tsx:194
#: src/view/com/composer/photos/Gallery.tsx:195
msgid "Edit image"
msgstr ""
@@ -3100,7 +3102,7 @@ msgstr ""
msgid "Enter your username and password"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:135
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:132
msgid "Enters full screen"
msgstr ""
@@ -5114,7 +5116,7 @@ msgstr ""
msgid "Music"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:156
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:153
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:96
msgctxt "video"
msgid "Mute"
@@ -5515,7 +5517,7 @@ msgstr ""
msgid "No results for \"{0}\"."
msgstr ""
#: src/components/Lists.tsx:189
#: src/components/Lists.tsx:190
msgid "No results found"
msgstr ""
@@ -5740,11 +5742,11 @@ msgstr ""
msgid "Only WebVTT (.vtt) files are supported"
msgstr ""
#: src/components/Lists.tsx:94
#: src/components/Lists.tsx:95
msgid "Oops, something went wrong!"
msgstr ""
#: src/components/Lists.tsx:173
#: src/components/Lists.tsx:174
#: src/components/StarterPack/ProfileStarterPacks.tsx:332
#: src/components/StarterPack/ProfileStarterPacks.tsx:341
#: src/screens/Settings/AppPasswords.tsx:59
@@ -5980,7 +5982,7 @@ msgstr ""
msgid "Our moderators have reviewed reports and decided to disable your access to chats on Bluesky."
msgstr ""
#: src/components/Lists.tsx:190
#: src/components/Lists.tsx:191
#: src/view/screens/NotFound.tsx:47
msgid "Page not found"
msgstr ""
@@ -6015,7 +6017,7 @@ msgid "Password updated!"
msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:43
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:140
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:137
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:369
msgid "Pause"
msgstr ""
@@ -6104,7 +6106,7 @@ msgid "Pinned to your feeds"
msgstr ""
#: src/components/Post/Embed/ExternalEmbed/Gif.tsx:43
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:140
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:137
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:370
msgid "Play"
msgstr ""
@@ -6113,7 +6115,7 @@ msgstr ""
msgid "Play {0}"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.tsx:134
#: src/components/Post/Embed/VideoEmbed/index.tsx:132
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:321
msgid "Play video"
msgstr ""
@@ -6126,7 +6128,7 @@ msgstr ""
msgid "Plays or pauses the GIF"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:141
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:138
msgid "Plays or pauses the video"
msgstr ""
@@ -6408,7 +6410,7 @@ msgid "Press to attempt reconnection"
msgstr ""
#: src/components/Error.tsx:60
#: src/components/Lists.tsx:99
#: src/components/Lists.tsx:100
#: src/screens/Messages/components/MessageListError.tsx:24
#: src/screens/Signup/BackNextButtons.tsx:47
msgid "Press to retry"
@@ -6765,7 +6767,7 @@ msgstr ""
msgid "Remove from your feeds?"
msgstr ""
#: src/view/com/composer/photos/Gallery.tsx:203
#: src/view/com/composer/photos/Gallery.tsx:204
msgid "Remove image"
msgstr ""
@@ -7151,7 +7153,7 @@ msgstr ""
#: src/components/dms/MessageItem.tsx:321
#: src/components/Error.tsx:65
#: src/components/Lists.tsx:110
#: src/components/Lists.tsx:111
#: src/components/moderation/ReportDialog/index.tsx:229
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:55
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx:57
@@ -7901,15 +7903,12 @@ msgstr ""
msgid "Shows the content"
msgstr ""
#: src/screens/Search/SearchResults.tsx:258
msgid "sign in"
msgstr ""
#: src/components/dialogs/Signin.tsx:97
#: src/components/dialogs/Signin.tsx:99
#: src/screens/Login/index.tsx:122
#: src/screens/Login/index.tsx:143
#: src/screens/Login/LoginForm.tsx:181
#: src/screens/Search/SearchResults.tsx:258
#: src/view/com/auth/SplashScreen.tsx:61
#: src/view/com/auth/SplashScreen.tsx:69
#: src/view/com/auth/SplashScreen.web.tsx:123
@@ -8051,7 +8050,7 @@ msgstr ""
msgid "Something went wrong, please try again."
msgstr ""
#: src/components/Lists.tsx:174
#: src/components/Lists.tsx:175
msgid "Something went wrong!"
msgstr ""
@@ -8941,7 +8940,7 @@ msgstr ""
msgid "Toggle to enable or disable adult content"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:158
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155
msgid "Toggles the sound"
msgstr ""
@@ -9127,7 +9126,7 @@ msgstr ""
msgid "Unlike ({0, plural, one {# like} other {# likes}})"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:155
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:152
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx:95
msgctxt "video"
msgid "Unmute"
@@ -9520,8 +9519,8 @@ msgstr ""
msgid "Version {0}"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:83
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:134
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:81
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:131
msgid "Video"
msgstr ""
@@ -9551,7 +9550,7 @@ msgstr ""
msgid "Video is playing"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:192
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:219
msgid "Video not found."
msgstr ""
@@ -9563,7 +9562,7 @@ msgstr ""
msgid "Video uploaded"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:83
#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx:81
msgid "Video: {0}"
msgstr ""
@@ -9841,7 +9840,7 @@ msgstr ""
msgid "We're sorry! The post you are replying to has been deleted."
msgstr ""
#: src/components/Lists.tsx:194
#: src/components/Lists.tsx:195
#: src/view/screens/NotFound.tsx:50
msgid "We're sorry! We can't find the page you were looking for."
msgstr ""
@@ -10194,7 +10193,7 @@ msgstr ""
msgid "You have not muted any accounts yet. To mute an account, go to their profile and select \"Mute account\" from the menu on their account."
msgstr ""
#: src/components/Lists.tsx:57
#: src/components/Lists.tsx:58
msgid "You have reached the end"
msgstr ""
@@ -10423,7 +10422,7 @@ msgstr ""
msgid "Your birth date"
msgstr ""
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:196
#: src/components/Post/Embed/VideoEmbed/index.web.tsx:223
msgid "Your browser does not support the video format. Please try a different browser."
msgstr ""
+1
View File
@@ -13,6 +13,7 @@ export enum LogContext {
FeedFeedback = 'feed-feedback',
PostSource = 'post-source',
AgeAssurance = 'age-assurance',
PolicyUpdate = 'policy-update',
/**
* METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+42
View File
@@ -25,6 +25,7 @@ import {clearStorage} from '#/state/persisted'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useDeleteActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {useProfileQuery, useProfilesQuery} from '#/state/queries/profile'
import {useAgent} from '#/state/session'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
@@ -35,6 +36,7 @@ import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
import {AvatarStackWithFetch} from '#/components/AvatarStack'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
@@ -58,6 +60,7 @@ import {Window_Stroke2_Corner2_Rounded as WindowIcon} from '#/components/icons/W
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import {ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useFullVerificationState} from '#/components/verification'
@@ -66,6 +69,7 @@ import {
VerificationCheckButton,
} from '#/components/verification/VerificationCheckButton'
import {IS_INTERNAL} from '#/env'
import {device, useStorage} from '#/storage'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
@@ -363,6 +367,10 @@ function ProfilePreview({
function DevOptions() {
const {_} = useLingui()
const agent = useAgent()
const [override, setOverride] = useStorage(device, [
'policyUpdateDebugOverride',
])
const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>()
const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
@@ -502,6 +510,40 @@ function DevOptions() {
</SettingsList.ItemText>
</SettingsList.PressableItem>
) : null}
<SettingsList.Divider />
<View style={[a.p_xl, a.gap_md]}>
<Text style={[a.text_lg, a.font_bold]}>PolicyUpdate202508 Debug</Text>
<View style={[a.flex_row, a.align_center, a.justify_between, a.gap_md]}>
<Button
onPress={() => {
setOverride(!override)
}}
label="Toggle"
color={override ? 'primary' : 'secondary'}
size="small"
style={[a.flex_1]}>
<ButtonText>
{override ? 'Disable debug mode' : 'Enable debug mode'}
</ButtonText>
</Button>
<Button
onPress={() => {
device.set([PolicyUpdate202508], false)
agent.bskyAppRemoveNuxs([PolicyUpdate202508])
Toast.show(`Done`, 'info')
}}
label="Reset policy update nux"
color="secondary"
size="small"
disabled={!override}>
<ButtonText>Reset state</ButtonText>
</Button>
</View>
</View>
<SettingsList.Divider />
</>
)
}
+42 -10
View File
@@ -4,11 +4,42 @@ import {type ComAtprotoServerDescribeServer} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {webLinks} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {atoms as a, useTheme} from '#/alf'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Admonition} from '#/components/Admonition'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
function CommunityGuidelinesNotice({}: {}) {
const {_} = useLingui()
const gate = useGate()
if (gate('disable_onboarding_policy_update_notice')) return null
return (
<View style={[a.pt_xs]}>
<Admonition type="tip">
<Trans>
You also agree to{' '}
<InlineLinkText
label={_(msg`Bluesky's Community Guidelines`)}
to={webLinks.communityDeprecated}>
Blueskys Community Guidelines
</InlineLinkText>
. An{' '}
<InlineLinkText
label={_(msg`Bluesky's Updated Community Guidelines`)}
to={webLinks.community}>
updated version of our Community Guidelines
</InlineLinkText>{' '}
will take effect on October 13th.
</Trans>
</Admonition>
</View>
)
}
export const Policies = ({
serviceDescription,
needsGuardian,
@@ -30,14 +61,13 @@ export const Policies = ({
if (!tos && !pp) {
return (
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
<Text style={[t.atoms.text_contrast_medium]}>
<View style={[a.gap_sm]}>
<Admonition type="info">
<Trans>
This service has not provided terms of service or a privacy policy.
</Trans>
</Text>
</Admonition>
<CommunityGuidelinesNotice />
</View>
)
}
@@ -102,19 +132,21 @@ export const Policies = ({
) : null}
{under13 ? (
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
<Admonition type="error">
<Trans>
You must be 13 years of age or older to create an account.
</Trans>
</Text>
</Admonition>
) : needsGuardian ? (
<Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
<Admonition type="warning">
<Trans>
If you are not yet an adult according to the laws of your country,
your parent or legal guardian must read these Terms on your behalf.
</Trans>
</Text>
</Admonition>
) : undefined}
<CommunityGuidelinesNotice />
</View>
)
}
+15 -1
View File
@@ -15,6 +15,7 @@ import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {usePreemptivelyCompleteActivePolicyUpdate} from '#/components/PolicyUpdateOverlay/usePreemptivelyCompleteActivePolicyUpdate'
export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
@@ -252,6 +253,8 @@ export function useSubmitSignup() {
const {_} = useLingui()
const {createAccount} = useSessionApi()
const onboardingDispatch = useOnboardingDispatch()
const preemptivelyCompleteActivePolicyUpdate =
usePreemptivelyCompleteActivePolicyUpdate()
return useCallback(
async (state: SignupState, dispatch: (action: SignupAction) => void) => {
@@ -325,6 +328,12 @@ export function useSubmitSignup() {
},
)
/**
* Marks any active policy update as completed, since user just agreed
* to TOS/privacy during sign up
*/
preemptivelyCompleteActivePolicyUpdate()
/*
* Must happen last so that if the user has multiple tabs open and
* createAccount fails, one tab is not stuck in onboarding — Eric
@@ -363,6 +372,11 @@ export function useSubmitSignup() {
dispatch({type: 'setIsLoading', value: false})
}
},
[_, onboardingDispatch, createAccount],
[
_,
onboardingDispatch,
createAccount,
preemptivelyCompleteActivePolicyUpdate,
],
)
}
+4 -2
View File
@@ -3,11 +3,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import {logger} from '#/logger'
import {
defaults,
Schema,
type Schema,
tryParse,
tryStringify,
} from '#/state/persisted/schema'
import {PersistedApi} from './types'
import {device} from '#/storage'
import {type PersistedApi} from './types'
import {normalizeData} from './util'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
@@ -53,6 +54,7 @@ onUpdate satisfies PersistedApi['onUpdate']
export async function clearStorage() {
try {
await AsyncStorage.removeItem(BSKY_STORAGE)
device.removeAll()
} catch (e: any) {
logger.error(`persisted store: failed to clear`, {message: e.toString()})
}
+6 -2
View File
@@ -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
}
+25
View File
@@ -0,0 +1,25 @@
import {jest} from '@jest/globals'
export {Nux} from '#/state/queries/nuxs/definitions'
export const useNuxs = jest.fn(() => {
return {
nuxs: undefined,
status: 'loading' as const,
}
})
export const useNux = jest.fn((id: string) => {
return {
nux: undefined,
status: 'loading' as const,
}
})
export const useSaveNux = jest.fn(() => {
return {}
})
export const useResetNuxs = jest.fn(() => {
return {}
})
+10
View File
@@ -9,6 +9,11 @@ export enum Nux {
ActivitySubscriptions = 'ActivitySubscriptions',
AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice',
AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner',
/*
* Blocking announcements. New IDs are required for each new announcement.
*/
PolicyUpdate202508 = 'PolicyUpdate202508',
}
export const nuxNames = new Set(Object.values(Nux))
@@ -38,6 +43,10 @@ export type AppNux = BaseNux<
id: Nux.AgeAssuranceDismissibleFeedBanner
data: undefined
}
| {
id: Nux.PolicyUpdate202508
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -47,4 +56,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.ActivitySubscriptions]: undefined,
[Nux.AgeAssuranceDismissibleNotice]: undefined,
[Nux.AgeAssuranceDismissibleFeedBanner]: undefined,
[Nux.PolicyUpdate202508]: undefined,
}
+7
View File
@@ -66,6 +66,13 @@ export class Storage<Scopes extends unknown[], Schema> {
keys.forEach(key => this.remove([...scopes, key]))
}
/**
* For debugging purposes
*/
removeAll() {
this.store.clearAll()
}
/**
* Fires a callback when the storage associated with a given key changes
*
+8
View File
@@ -1,3 +1,5 @@
import {type ID as PolicyUpdate202508} from '#/components/PolicyUpdateOverlay/updates/202508/config'
/**
* Device data that's specific to the device and does not vary based account
*/
@@ -13,6 +15,12 @@ export type Device = {
devMode: boolean
demoMode: boolean
activitySubscriptionsNudged?: boolean
/**
* Policy update overlays. New IDs are required for each new announcement.
*/
policyUpdateDebugOverride?: boolean
[PolicyUpdate202508]?: boolean
}
export type Account = {
+5
View File
@@ -21,6 +21,7 @@ import {type ComposerImage, cropImage} from '#/state/gallery'
import {Text} from '#/view/com/util/text/Text'
import {useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {type PostAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
@@ -227,8 +228,12 @@ const GalleryItem = ({
}}
accessible={true}
accessibilityIgnoresInvertColors
cachePolicy="none"
autoplay={false}
/>
<MediaInsetBorder />
<ImageAltTextDialog
control={altTextControl}
image={image}
@@ -40,6 +40,7 @@ import {Onboarding} from '#/screens/Onboarding'
import {SignupQueued} from '#/screens/SignupQueued'
import {Takendown} from '#/screens/Takendown'
import {atoms as a, useLayoutBreakpoints} from '#/alf'
import {PolicyUpdateOverlay} from '#/components/PolicyUpdateOverlay'
import {BottomBarWeb} from './bottom-bar/BottomBarWeb'
import {DesktopLeftNav} from './desktop/LeftNav'
import {DesktopRightNav} from './desktop/RightNav'
@@ -167,6 +168,9 @@ function NativeStackNavigator({
{!isMobile && <DesktopRightNav routeName={activeRoute.name} />}
</>
)}
{/* Only shown after logged in and onboaring etc are complete */}
{hasSession && <PolicyUpdateOverlay />}
</NavigationContent>
)
}
+16 -2
View File
@@ -31,6 +31,10 @@ import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsen
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {
Outlet as PolicyUpdateOverlayPortalOutlet,
usePolicyUpdateStateContext,
} from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {RoutesContainer, TabsNavigator} from '#/Navigation'
import {BottomSheetOutlet} from '../../../modules/bottom-sheet'
@@ -45,6 +49,7 @@ function ShellInner() {
const setIsDrawerOpen = useSetDrawerOpen()
const winDim = useWindowDimensions()
const insets = useSafeAreaInsets()
const policyUpdateState = usePolicyUpdateStateContext()
const renderDrawerContent = useCallback(() => <DrawerContent />, [])
const onOpenDrawer = useCallback(
@@ -151,6 +156,7 @@ function ShellInner() {
</Drawer>
</ErrorBoundary>
</View>
<Composer winHeight={winDim.height} />
<ModalsContainer />
<MutedWordsDialog />
@@ -160,8 +166,16 @@ function ShellInner() {
<InAppBrowserConsentDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />
<BottomSheetOutlet />
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
{policyUpdateState.completed && (
<>
<PortalOutlet />
<BottomSheetOutlet />
</>
)}
<PolicyUpdateOverlayPortalOutlet />
</>
)
}
+14 -1
View File
@@ -22,6 +22,10 @@ import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
import {
Outlet as PolicyUpdateOverlayPortalOutlet,
usePolicyUpdateStateContext,
} from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal'
import {FlatNavigator, RoutesContainer} from '#/Navigation'
import {Composer} from './Composer.web'
@@ -37,6 +41,7 @@ function ShellInner() {
const {_} = useLingui()
const showDrawer = !isDesktop && isDrawerOpen
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
const policyUpdateState = usePolicyUpdateStateContext()
useLayoutEffect(() => {
if (showDrawer !== showDrawerDelayedExit) {
@@ -74,7 +79,13 @@ function ShellInner() {
<AgeAssuranceRedirectDialog />
<LinkWarningDialog />
<Lightbox />
<PortalOutlet />
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
{policyUpdateState.completed && (
<>
<PortalOutlet />
</>
)}
{showDrawerDelayedExit && (
<>
@@ -113,6 +124,8 @@ function ShellInner() {
</TouchableWithoutFeedback>
</>
)}
<PolicyUpdateOverlayPortalOutlet />
</>
)
}
+4 -4
View File
@@ -11301,10 +11301,10 @@ expo-image-picker@~16.1.4:
dependencies:
expo-image-loader "~5.1.0"
expo-image@~2.2.1:
version "2.2.1"
resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-2.2.1.tgz#b4aa706a25f7e8902ac854a8da249caf4a90cd67"
integrity sha512-5ZSggMi0X2G9AN0aM+sdkCyyZ6YcWvGs9KYLYrRBVUN3ph6RBiu6mKGpaNN1TAscySRnH1eHbUE1H+Qeq7qm1g==
expo-image@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-2.4.0.tgz#02f7fd743387206914cd431a6367f5be53509e3e"
integrity sha512-TQ/LvrtJ9JBr+Tf198CAqflxcvdhuj7P24n0LQ1jHaWIVA7Z+zYKbYHnSMPSDMul/y0U46Z5bFLbiZiSidgcNw==
expo-json-utils@~0.15.0:
version "0.15.0"