Merge remote-tracking branch 'origin/main' into moar-labels

* origin/main: (30 commits)
  Only show "followed you back" when appropriate (#4849)
  [Web] Retrigger onEndReached if needed when content height changes (#4859)
  Remove unused NoopFeedTuner (#4856)
  useDedupe callback (#4855)
  [Video] Uploads (#4754)
  Make label required in link components (#4844)
  Boolean filter improvement alternative: TS upgrade (#4840)
  Add label to profile card (#4843)
  Improve a11y on noty feed (#4842)
  Add labels in feed card (#4836)
  Add labels to mod details dialog (#4839)
  Add labels to a few missing places (#4838)
  Add labels in list card (#4837)
  Refactor feed slices (#4834)
  `true` (#4833)
  Replace `import hairlineWidth =` with const (#4831)
  [Videos] Video player - PR #1 - basic player (#4731)
  Bump 1.90 (#4832)
  Fix sloppy filter(Boolean) types (#4830)
  Fuggedaboudit (#4829)
  ...
This commit is contained in:
Eric Bailey
2024-07-31 16:29:10 -05:00
121 changed files with 13047 additions and 10842 deletions
+3 -2
View File
@@ -132,7 +132,8 @@ appId: xyz.blueskyweb.app
id: "feedItem-by-bob.test"
- tapOn:
id: "e2eGotoFeeds"
- tapOn: "Good Ppl"
- tapOn:
id: "saved-feed-Good Ppl"
- assertVisible:
id: "feedItem-by-bob.test"
- tapOn:
@@ -168,7 +169,7 @@ appId: xyz.blueskyweb.app
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-5"
id: "profilePager-selector-6"
- tapOn: "Good Ppl"
- tapOn:
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>

After

Width:  |  Height:  |  Size: 239 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 374 B

+1
View File
@@ -197,6 +197,7 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric)
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "bsky.app",
"version": "1.89.0",
"version": "1.90.0",
"private": true,
"engines": {
"node": ">=18"
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
"@atproto/api": "^0.12.23",
"@atproto/api": "0.12.25",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -143,6 +143,7 @@
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
"hls.js": "^1.5.11",
"js-sha256": "^0.9.0",
"jwt-decode": "^4.0.0",
"lande": "^1.0.10",
@@ -269,7 +270,7 @@
"react-scripts": "^5.0.1",
"react-test-renderer": "18.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.3.3",
"typescript": "^5.5.4",
"url-loader": "^4.1.1",
"webpack": "^5.75.0",
"webpack-bundle-analyzer": "^4.10.1",
+12
View File
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
Map<String, Object> constants = new HashMap<>(3);
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
diff --git a/node_modules/expo-modules-core/build/uuid/uuid.js b/node_modules/expo-modules-core/build/uuid/uuid.js
index 109d3fe..c7fce9e 100644
--- a/node_modules/expo-modules-core/build/uuid/uuid.js
+++ b/node_modules/expo-modules-core/build/uuid/uuid.js
@@ -1,5 +1,7 @@
import bytesToUuid from './lib/bytesToUuid';
import { Uuidv5Namespace } from './uuid.types';
+import { ensureNativeModulesAreInstalled } from '../ensureNativeModulesAreInstalled';
+ensureNativeModulesAreInstalled();
const nativeUuidv4 = globalThis?.expo?.uuidv4;
const nativeUuidv5 = globalThis?.expo?.uuidv5;
function uuidv4() {
+20
View File
@@ -0,0 +1,20 @@
--- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
+++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt
@@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole
setShowPreviousButton(!requireLinearPlayback)
setShowNextButton(!requireLinearPlayback)
setTimeBarInteractive(requireLinearPlayback)
+ setShowSubtitleButton(true)
}
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
@@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) {
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) {
- val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
+ val fullscreenButton =
+ findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen)
fullscreenButton?.visibility = if (visible) {
android.view.View.VISIBLE
} else {
+41 -38
View File
@@ -23,10 +23,12 @@ import {
} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -49,6 +51,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 {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
@@ -58,8 +61,6 @@ 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 I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
SplashScreen.preventAutoHideAsync()
@@ -107,42 +108,44 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
<ActiveVideoProvider>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<TourProvider>
<ProgressGuideProvider>
<GestureHandlerRootView
style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</ProgressGuideProvider>
</TourProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
</ActiveVideoProvider>
</Splash>
</ThemeProvider>
</Alf>
+38 -35
View File
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger} from '#/logger'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
import {listenSessionDropped} from '#/state/events'
import {Provider as InvitesStateProvider} from '#/state/invites'
import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
@@ -37,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index'
@@ -46,8 +49,6 @@ 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'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
function InnerApp() {
const [isReady, setIsReady] = React.useState(false)
@@ -92,39 +93,41 @@ function InnerApp() {
<Alf theme={theme}>
<ThemeProvider theme={theme}>
<RootSiblingParent>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
<ActiveVideoProvider>
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<StatsigProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<TourProvider>
<ProgressGuideProvider>
<Shell />
</ProgressGuideProvider>
</TourProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
</ActiveVideoProvider>
</RootSiblingParent>
</ThemeProvider>
</Alf>
+6
View File
@@ -76,6 +76,7 @@ import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread'
@@ -324,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}}
/>
<Stack.Screen
name="NotificationsSettings"
getComponent={() => NotificationsSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen
name="Feeds"
getComponent={() => FeedsScreen}
+5 -6
View File
@@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native'
import * as tokens from '#/alf/tokens'
import {native, web} from '#/alf/util/platform'
import hairlineWidth = StyleSheet.hairlineWidth
export const atoms = {
/*
@@ -284,19 +283,19 @@ export const atoms = {
borderWidth: 0,
},
border: {
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
},
border_t: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
border_b: {
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
border_l: {
borderLeftWidth: hairlineWidth,
borderLeftWidth: StyleSheet.hairlineWidth,
},
border_r: {
borderRightWidth: hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
},
/*
+3
View File
@@ -70,6 +70,9 @@ export type ButtonProps = Pick<
AccessibilityProps &
VariantProps & {
testID?: string
/**
* For a11y, try to make this descriptive and clear
*/
label: string
style?: StyleProp<ViewStyle>
hoverStyle?: StyleProp<ViewStyle>
+7 -3
View File
@@ -40,7 +40,7 @@ type Props = {
export function Default(props: Props) {
const {view} = props
return (
<Link label={view.displayName} {...props}>
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
@@ -58,7 +58,7 @@ export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
@@ -70,7 +70,11 @@ export function Link({
}, [view, queryClient])
return (
<InternalLink to={href} style={[a.flex_col]} {...props}>
<InternalLink
label={view.displayName}
to={href}
style={[a.flex_col]}
{...props}>
{children}
</InternalLink>
)
+9 -3
View File
@@ -190,7 +190,7 @@ export function SuggestedFollows() {
{profiles.slice(0, maxLength).map(profile => (
<ProfileCard.Link
key={profile.did}
did={profile.handle}
profile={profile}
onPress={() => {
logEvent('feed:interstitial:profileCard:press', {})
}}
@@ -266,7 +266,10 @@ export function SuggestedFollows() {
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
@@ -396,7 +399,10 @@ export function SuggestedFeeds() {
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText to="/search" style={[t.atoms.text_contrast_medium]}>
<InlineLinkText
label={_(msg`Browse more suggestions`)}
to="/search"
style={[t.atoms.text_contrast_medium]}>
<Trans>Browse more suggestions</Trans>
</InlineLinkText>
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
+4 -9
View File
@@ -40,11 +40,6 @@ type BaseLinkProps = Pick<
> & {
testID?: string
/**
* Label for a11y. Defaults to the href.
*/
label?: string
/**
* The React Navigation `StackAction` to perform when the link is pressed.
*/
@@ -197,7 +192,7 @@ export function useLink({
}
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
Omit<ButtonProps, 'onPress' | 'disabled' | 'label'>
Omit<ButtonProps, 'onPress' | 'disabled'>
/**
* A interactive element that renders as a `<a>` tag on the web. On mobile it
@@ -224,7 +219,6 @@ export function Link({
return (
<Button
label={href}
{...rest}
style={[a.justify_start, flatten(rest.style)]}
role="link"
@@ -249,7 +243,8 @@ export function Link({
export type InlineLinkProps = React.PropsWithChildren<
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
>
> &
Pick<ButtonProps, 'label'>
export function InlineLinkText({
children,
@@ -291,7 +286,7 @@ export function InlineLinkText({
<Text
selectable={selectable}
accessibilityHint=""
accessibilityLabel={label || href}
accessibilityLabel={label}
{...rest}
style={[
{color: t.palette.primary_500},
+3 -3
View File
@@ -44,7 +44,7 @@ type Props = {
export function Default(props: Props) {
const {view, showPinButton} = props
return (
<Link label={view.name} {...props}>
<Link {...props}>
<Outer>
<Header>
<Avatar src={view.avatar} />
@@ -67,7 +67,7 @@ export function Link({
view,
children,
...props
}: Props & Omit<LinkProps, 'to'>) {
}: Props & Omit<LinkProps, 'to' | 'label'>) {
const queryClient = useQueryClient()
const href = React.useMemo(() => {
@@ -79,7 +79,7 @@ export function Link({
}, [view, queryClient])
return (
<InternalLink to={href} {...props}>
<InternalLink label={view.name} to={href} {...props}>
{children}
</InternalLink>
)
+1 -1
View File
@@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
return (
<Error
title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(`Something went wrong!`)}
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
sideBorders={sideBorders}
+12 -4
View File
@@ -36,7 +36,7 @@ export function Default({
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
}) {
return (
<Link did={profile.did}>
<Link profile={profile}>
<Card
profile={profile}
moderationOpts={moderationOpts}
@@ -96,16 +96,24 @@ export function Header({
}
export function Link({
did,
profile,
children,
style,
...rest
}: {did: string} & Omit<LinkProps, 'to'>) {
}: {
profile: AppBskyActorDefs.ProfileViewDetailed
} & Omit<LinkProps, 'to' | 'label'>) {
const {_} = useLingui()
return (
<InternalLink
label={_(
msg`View ${
profile.displayName || sanitizeHandle(profile.handle)
}'s profile`,
)}
to={{
screen: 'Profile',
params: {name: did},
params: {name: profile.did},
}}
style={[a.flex_col, style]}
{...rest}>
+3 -1
View File
@@ -74,7 +74,9 @@ function DialogContent({
const onPressAudience = (setting: ThreadgateSetting) => {
// remove nobody
let newSelected = draft.filter(v => v.type !== 'nobody')
let newSelected: ThreadgateSetting[] = draft.filter(
v => v.type !== 'nobody',
)
// toggle
const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) {
@@ -42,6 +42,7 @@ export function BlockedByListDialog({
<React.Fragment key={block.source.list.uri}>
{i === 0 ? null : ', '}
<InlineLinkText
label={block.source.list.name}
to={listUriToHref(block.source.list.uri)}
style={[a.text_md, a.leading_snug]}>
{block.source.list.name}
@@ -140,6 +140,7 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const t = useTheme()
const convoState = useConvo()
const profile = useProfileShadow(profileUnshadowed)
@@ -156,6 +157,7 @@ function HeaderReady({
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<View style={[a.pt_2xs]}>
+3
View File
@@ -2,6 +2,7 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants'
import {
atoms as a,
@@ -459,3 +460,5 @@ export function Radio() {
</View>
)
}
export const Platform = isNative ? Switch : Checkbox
-5
View File
@@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({
path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z',
})
export const Play_Filled_Corner2_Rounded = createSinglePathSVG({
path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z',
})
@@ -174,7 +174,7 @@ export function LabelerLabelPreference({
disabled?: boolean
labelerDid?: string
}) {
const {i18n} = useLingui()
const {_, i18n} = useLingui()
const t = useTheme()
const {gtPhone} = useBreakpoints()
@@ -243,7 +243,10 @@ export function LabelerLabelPreference({
) : isGlobalLabel ? (
<Trans>
Configured in{' '}
<InlineLinkText to="/moderation" style={a.text_sm}>
<InlineLinkText
label={_(msg`moderation settings`)}
to="/moderation"
style={a.text_sm}>
moderation settings
</InlineLinkText>
.
+10 -4
View File
@@ -128,6 +128,9 @@ function Label({
const t = useTheme()
const {_} = useLingui()
const {labeler, strings} = useLabelInfo(label)
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
return (
<View
style={[
@@ -169,13 +172,12 @@ function Label({
<Trans>
Source:{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}>
{labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src}
{sourceName}
</InlineLinkText>
</Trans>
)}
@@ -203,6 +205,9 @@ function AppealForm({
const isAccountReport = 'did' in subject
const agent = useAgent()
const gate = useGate()
const sourceName = labeler
? sanitizeHandle(labeler.creator.handle, '@')
: label.src
const {mutate, isPending} = useMutation({
mutationFn: async () => {
@@ -260,12 +265,13 @@ function AppealForm({
<Trans>
This appeal will be sent to{' '}
<InlineLinkText
label={sourceName}
to={makeProfileLink(
labeler ? labeler.creator : {did: label.src, handle: ''},
)}
onPress={() => control.close()}
style={[a.text_md, a.leading_snug]}>
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
{sourceName}
</InlineLinkText>
.
</Trans>
@@ -54,7 +54,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have blocked.
@@ -83,7 +86,10 @@ function ModerationDetailsDialogInner({
description = (
<Trans>
This user is included in the{' '}
<InlineLinkText to={listUriToHref(list.uri)} style={[a.text_sm]}>
<InlineLinkText
label={list.name}
to={listUriToHref(list.uri)}
style={[a.text_sm]}>
{list.name}
</InlineLinkText>{' '}
list which you have muted.
@@ -127,10 +133,11 @@ function ModerationDetailsDialogInner({
<Trans>
This label was applied by{' '}
<InlineLinkText
label={desc.source || _(msg`an unknown labeler`)}
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source}
{desc.source || _(msg`an unknown labeler`)}
</InlineLinkText>
.
</Trans>
+69 -70
View File
@@ -14,29 +14,33 @@ export type FeedTunerFn = (
slices: FeedViewPostsSlice[],
) => FeedViewPostsSlice[]
type FeedSliceItem = {
post: AppBskyFeedDefs.PostView
reply?: AppBskyFeedDefs.ReplyRef
}
function toSliceItem(feedViewPost: FeedViewPost): FeedSliceItem {
return {
post: feedViewPost.post,
reply: feedViewPost.reply,
}
}
export class FeedViewPostsSlice {
_reactKey: string
isFlattenedReply = false
_feedPost: FeedViewPost
items: FeedSliceItem[]
constructor(public items: FeedViewPost[]) {
const item = items[0]
this._reactKey = `slice-${item.post.uri}-${
item.reason?.indexedAt || item.post.indexedAt
constructor(feedPost: FeedViewPost) {
this._feedPost = feedPost
this._reactKey = `slice-${feedPost.post.uri}-${
feedPost.reason?.indexedAt || feedPost.post.indexedAt
}`
this.items = [toSliceItem(feedPost)]
}
get uri() {
if (this.isFlattenedReply) {
return this.items[1].post.uri
}
return this.items[0].post.uri
}
get ts() {
if (this.items[0].reason?.indexedAt) {
return this.items[0].reason.indexedAt as string
}
return this.items[0].post.indexedAt
return this._feedPost.post.uri
}
get isThread() {
@@ -48,31 +52,42 @@ export class FeedViewPostsSlice {
)
}
get isFullThread() {
return this.isThread && !this.items[0].reply
}
get rootItem() {
if (this.isFlattenedReply) {
return this.items[1]
}
return this.items[0]
get isQuotePost() {
const embed = this._feedPost.post.embed
return (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
)
}
get isReply() {
return (
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
!!this.rootItem.post.record.reply
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
!!this._feedPost.post.record.reply
)
}
get source(): ReasonFeedSource | undefined {
return this.items.find(item => '__source' in item && !!item.__source)
?.__source as ReasonFeedSource
get reason() {
return '__source' in this._feedPost
? (this._feedPost.__source as ReasonFeedSource)
: this._feedPost.reason
}
get feedContext() {
return this.items.find(item => item.feedContext)?.feedContext
return this._feedPost.feedContext
}
get isRepost() {
const reason = this._feedPost.reason
return AppBskyFeedDefs.isReasonRepost(reason)
}
get includesThreadRoot() {
return !this.items[0].reply
}
get likeCount() {
return this._feedPost.post.likeCount ?? 0
}
containsUri(uri: string) {
@@ -97,40 +112,30 @@ export class FeedViewPostsSlice {
if (this.items[0].reply) {
const reply = this.items[0].reply
if (AppBskyFeedDefs.isPostView(reply.parent)) {
this.isFlattenedReply = true
this.items.splice(0, 0, {post: reply.parent})
}
}
}
isFollowingAllAuthors(userDid: string) {
const item = this.rootItem
if (item.post.author.did === userDid) {
const feedPost = this._feedPost
if (feedPost.post.author.did === userDid) {
return true
}
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
const parent = item.reply?.parent
if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
const parent = feedPost.reply?.parent
if (parent?.author.did === userDid) {
return true
}
return (
parent?.author.viewer?.following && item.post.author.viewer?.following
parent?.author.viewer?.following &&
feedPost.post.author.viewer?.following
)
}
return false
}
}
export class NoopFeedTuner {
reset() {}
tune(
feed: FeedViewPost[],
_opts?: {dryRun: boolean; maintainOrder: boolean},
): FeedViewPostsSlice[] {
return feed.map(item => new FeedViewPostsSlice([item]))
}
}
export class FeedTuner {
seenKeys: Set<string> = new Set()
seenUris: Set<string> = new Set()
@@ -165,7 +170,7 @@ export class FeedTuner {
})
if (maintainOrder) {
slices = feed.map(item => new FeedViewPostsSlice([item]))
slices = feed.map(item => new FeedViewPostsSlice(item))
} else {
// arrange the posts into thread slices
for (let i = feed.length - 1; i >= 0; i--) {
@@ -192,7 +197,7 @@ export class FeedTuner {
}
}
slices.unshift(new FeedViewPostsSlice([item]))
slices.unshift(new FeedViewPostsSlice(item))
}
}
@@ -215,7 +220,7 @@ export class FeedTuner {
// turn non-threads with reply parents into threads
for (const slice of slices) {
if (!slice.isThread && !slice.items[0].reason && slice.items[0].reply) {
if (!slice.isThread && !slice.reason && slice.items[0].reply) {
const reply = slice.items[0].reply
if (
AppBskyFeedDefs.isPostView(reply.parent) &&
@@ -256,8 +261,7 @@ export class FeedTuner {
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const reason = slices[i].rootItem.reason
if (AppBskyFeedDefs.isReasonRepost(reason)) {
if (slices[i].isRepost) {
slices.splice(i, 1)
}
}
@@ -266,11 +270,7 @@ export class FeedTuner {
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
for (let i = slices.length - 1; i >= 0; i--) {
const embed = slices[i].rootItem.post.embed
if (
AppBskyEmbedRecord.isView(embed) ||
AppBskyEmbedRecordWithMedia.isView(embed)
) {
if (slices[i].isQuotePost) {
slices.splice(i, 1)
}
}
@@ -315,19 +315,18 @@ export class FeedTuner {
// remove any replies without at least minLikes likes
for (let i = slices.length - 1; i >= 0; i--) {
const slice = slices[i]
if (slice.isFullThread || !slice.isReply) {
continue
}
const item = slice.rootItem
const isRepost = Boolean(item.reason)
if (isRepost) {
continue
}
if ((item.post.likeCount || 0) < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
if (slice.isReply) {
if (slice.isThread && slice.includesThreadRoot) {
continue
}
if (slice.isRepost) {
continue
}
if (slice.likeCount < minLikes) {
slices.splice(i, 1)
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
slices.splice(i, 1)
}
}
}
return slices
+1 -1
View File
@@ -251,7 +251,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
dryRun: false,
maintainOrder: true,
})
res.data.feed = slices.map(slice => slice.rootItem)
res.data.feed = slices.map(slice => slice._feedPost)
return res
}
}
+4
View File
@@ -54,6 +54,10 @@ interface PostOpts {
uri: string
cid: string
}
video?: {
uri: string
cid: string
}
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
+6 -4
View File
@@ -1,10 +1,10 @@
import {timeout} from './timeout'
export async function until(
export async function until<T>(
retries: number,
delay: number,
cond: (v: any, err: any) => boolean,
fn: () => Promise<any>,
cond: (v: T, err: any) => boolean,
fn: () => Promise<T>,
): Promise<boolean> {
while (retries > 0) {
try {
@@ -13,7 +13,9 @@ export async function until(
return true
}
} catch (e: any) {
if (cond(undefined, e)) {
// TODO: change the type signature of cond to accept undefined
// however this breaks every existing usage of until -sfn
if (cond(undefined as unknown as T, e)) {
return true
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ import React from 'react'
export const useDedupe = () => {
const canDo = React.useRef(true)
return React.useRef((cb: () => unknown) => {
return React.useCallback((cb: () => unknown) => {
if (canDo.current) {
canDo.current = false
setTimeout(() => {
@@ -13,5 +13,5 @@ export const useDedupe = () => {
return true
}
return false
}).current
}, [])
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 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
}
+6 -9
View File
@@ -42,14 +42,13 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined
NotificationsSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {
rkey?: string
}
StarterPackEdit: {rkey?: string}
}
export type BottomTabNavigatorParams = CommonNavigatorParams & {
@@ -69,7 +68,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
}
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined
Notifications: {show?: 'all'}
}
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
@@ -84,7 +83,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string}
Feeds: undefined
Notifications: undefined
Notifications: {show?: 'all'}
Hashtag: {tag: string; author?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
@@ -96,7 +95,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Search: {q?: string}
Feeds: undefined
NotificationsTab: undefined
Notifications: undefined
Notifications: {show?: 'all'}
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
@@ -105,9 +104,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string}
StarterPackWizard: undefined
StarterPackEdit: {
rkey?: string
}
StarterPackEdit: {rkey?: string}
}
// NOTE
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@ export const router = new Router({
Search: '/search',
Feeds: '/feeds',
Notifications: '/notifications',
NotificationsSettings: '/notifications/settings',
Settings: '/settings',
LanguageSettings: '/settings/language',
Lists: '/lists',
@@ -106,6 +106,7 @@ function Inner() {
title={_(msg`Something went wrong`)}
message={_(msg`We couldn't load this conversation`)}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
</CenteredView>
)
+1 -1
View File
@@ -309,7 +309,7 @@ function DesktopHeader({
a.gap_lg,
a.px_lg,
a.pr_md,
a.py_md,
a.py_sm,
a.border_b,
t.atoms.border_contrast_low,
]}>
+4 -4
View File
@@ -11,7 +11,7 @@ import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {ScrollView} from '#/view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {Divider} from '#/components/Divider'
import * as Toggle from '#/components/forms/Toggle'
@@ -55,7 +55,7 @@ export function MessagesSettingsScreen({}: Props) {
)
return (
<CenteredView sideBorders style={a.h_full_vh}>
<ScrollView stickyHeaderIndices={[0]}>
<ViewHeader title={_(msg`Chat Settings`)} showOnDesktop showBorder />
<View style={[a.p_lg, a.gap_md]}>
<Text style={[a.text_lg, a.font_bold]}>
@@ -107,7 +107,7 @@ export function MessagesSettingsScreen({}: Props) {
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[t.atoms.text_contrast_high]}>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
You can continue ongoing conversations regardless of which setting
you choose.
@@ -149,6 +149,6 @@ export function MessagesSettingsScreen({}: Props) {
</>
)}
</View>
</CenteredView>
</ScrollView>
)
}
+16 -4
View File
@@ -240,7 +240,10 @@ export function ModerationScreenInner({
)}
</Button>
<Divider />
<Link testID="moderationlistsBtn" to="/moderation/modlists">
<Link
label={_(msg`View your moderation lists`)}
testID="moderationlistsBtn"
to="/moderation/modlists">
{state => (
<SubItem
title={_(msg`Moderation lists`)}
@@ -252,7 +255,10 @@ export function ModerationScreenInner({
)}
</Link>
<Divider />
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
<Link
label={_(msg`View your muted accounts`)}
testID="mutedAccountsBtn"
to="/moderation/muted-accounts">
{state => (
<SubItem
title={_(msg`Muted accounts`)}
@@ -264,7 +270,10 @@ export function ModerationScreenInner({
)}
</Link>
<Divider />
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
<Link
label={_(msg`View your blocked accounts`)}
testID="blockedAccountsBtn"
to="/moderation/blocked-accounts">
{state => (
<SubItem
title={_(msg`Blocked accounts`)}
@@ -356,6 +365,7 @@ export function ModerationScreenInner({
<Trans>
Adult content can only be enabled via the Web at{' '}
<InlineLinkText
label={_(msg`The Bluesky web application`)}
to=""
onPress={evt => {
evt.preventDefault()
@@ -569,7 +579,9 @@ function PwiOptOut() {
</Trans>
</Text>
<InlineLinkText to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<InlineLinkText
label={_(msg`Learn more about what is public on Bluesky.`)}
to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
<Trans>Learn more about what is public on Bluesky.</Trans>
</InlineLinkText>
</View>
+8 -2
View File
@@ -45,14 +45,20 @@ export const Policies = ({
const els = []
if (tos) {
els.push(
<InlineLinkText key="tos" to={tos}>
<InlineLinkText
label={_(msg`Read the Bluesky Terms of Service`)}
key="tos"
to={tos}>
{_(msg`Terms of Service`)}
</InlineLinkText>,
)
}
if (pp) {
els.push(
<InlineLinkText key="pp" to={pp}>
<InlineLinkText
label={_(msg`Read the Bluesky Privacy Policy`)}
key="pp"
to={pp}>
{_(msg`Privacy Policy`)}
</InlineLinkText>,
)
+1
View File
@@ -166,6 +166,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
<Trans>Having trouble?</Trans>{' '}
<InlineLinkText
label={_(msg`Contact support`)}
to={FEEDBACK_FORM_URL({email: state.email})}
style={[!gtMobile && a.text_md]}>
<Trans>Contact support</Trans>
+22 -15
View File
@@ -46,11 +46,14 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined
const RQKEY_ROOT = 'notification-feed'
export function RQKEY() {
return [RQKEY_ROOT]
export function RQKEY(priority?: false) {
return [RQKEY_ROOT, priority]
}
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
export function useNotificationFeedQuery(opts?: {
enabled?: boolean
overridePriorityNotifications?: boolean
}) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
@@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const lastPageCountRef = useRef(0)
const gate = useGate()
// false: force showing all notifications
// undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined
const query = useInfiniteQuery<
FeedPage,
Error,
@@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
RQPageParam
>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(),
queryKey: RQKEY(priority),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
let page
if (!pageParam) {
@@ -75,17 +82,17 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
page = unreads.getCachedUnreadPage()
}
if (!page) {
page = (
await fetchPage({
agent,
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
moderationOpts,
fetchAdditionalData: true,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
})
).page
const {page: fetchedPage} = await fetchPage({
agent,
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
moderationOpts,
fetchAdditionalData: true,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
priority,
})
page = fetchedPage
}
// if the first page has an unread, mark all read
@@ -0,0 +1,69 @@
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from '#/state/queries/notifications/unread'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export function useNotificationsSettingsMutation() {
const {_} = useLingui()
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (keys: string[]) => {
const enabled = keys[0] === 'enabled'
await agent.api.app.bsky.notification.putPreferences({
priority: enabled,
})
await until(
5, // 5 tries
1e3, // 1s delay between tries
res => res.data.priority === enabled,
() => agent.api.app.bsky.notification.listNotifications({limit: 1}),
)
eagerlySetCachedPriority(queryClient, enabled)
},
onError: err => {
logger.error('Failed to save notification preferences', {
safeMessage: err,
})
Toast.show(
_(msg`Failed to save notification preferences, please try again`),
'xmark',
)
},
onSuccess: () => {
Toast.show(_(msg`Preference saved`))
},
onSettled: () => {
invalidateCachedUnreadPage()
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
},
})
}
function eagerlySetCachedPriority(
queryClient: ReturnType<typeof useQueryClient>,
enabled: boolean,
) {
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page: any) => {
return {
...page,
priority: enabled,
}
}),
}
})
}
+1
View File
@@ -22,6 +22,7 @@ export interface FeedPage {
cursor: string | undefined
seenAt: Date
items: FeedNotification[]
priority: boolean
}
export interface CachedFeedPage {
+7 -1
View File
@@ -39,10 +39,15 @@ export async function fetchPage({
moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean
shouldUngroupFollowBacks?: () => boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
priority?: boolean
}): Promise<{
page: FeedPage
indexedAt: string | undefined
}> {
const res = await agent.listNotifications({
limit,
cursor,
// priority,
})
const indexedAt = res.data.notifications[0]?.indexedAt
@@ -88,6 +93,7 @@ export async function fetchPage({
cursor: res.data.cursor,
seenAt,
items: notifsGrouped,
priority: res.data.priority ?? false,
},
indexedAt,
}
+31 -36
View File
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
import {ListFeedAPI} from 'lib/api/feed/list'
import {MergeFeedAPI} from 'lib/api/feed/merge'
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
import {FeedTuner, FeedTunerFn, NoopFeedTuner} from 'lib/api/feed-manip'
import {FeedTuner, FeedTunerFn} from 'lib/api/feed-manip'
import {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
import {useFeedTuners} from '../preferences/feed-tuners'
@@ -61,7 +61,6 @@ export type FeedDescriptor =
| `list|${ListUri}`
| `list|${ListUri}|${ListFilter}`
export interface FeedParams {
disableTuner?: boolean
mergeFeedEnabled?: boolean
mergeFeedSources?: string[]
}
@@ -78,7 +77,10 @@ export interface FeedPostSliceItem {
uri: string
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
reason?:
| AppBskyFeedDefs.ReasonRepost
| ReasonFeedSource
| {[k: string]: unknown; $type: string}
feedContext: string | undefined
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
@@ -102,7 +104,7 @@ export interface FeedPageUnselected {
export interface FeedPage {
api: FeedAPI
tuner: FeedTuner | NoopFeedTuner
tuner: FeedTuner
cursor: string | undefined
slices: FeedPostSlice[]
fetchedAt: number
@@ -139,18 +141,11 @@ export function usePostFeedQuery(
const selectArgs = React.useMemo(
() => ({
feedTuners,
disableTuner: params?.disableTuner,
moderationOpts,
ignoreFilterFor: opts?.ignoreFilterFor,
isDiscover,
}),
[
feedTuners,
params?.disableTuner,
moderationOpts,
opts?.ignoreFilterFor,
isDiscover,
],
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
)
const query = useInfiniteQuery<
@@ -229,17 +224,10 @@ export function usePostFeedQuery(
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
// If the selection depends on some data, that data should
// be included in the selectArgs object and read here.
const {
feedTuners,
disableTuner,
moderationOpts,
ignoreFilterFor,
isDiscover,
} = selectArgs
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
selectArgs
const tuner = disableTuner
? new NoopFeedTuner()
: new FeedTuner(feedTuners)
const tuner = new FeedTuner(feedTuners)
// Keep track of the last run and whether we can reuse
// some already selected pages from there.
@@ -311,7 +299,7 @@ export function usePostFeedQuery(
if (isDiscover) {
userActionHistory.seen(
slice.items.map(item => ({
feedContext: item.feedContext,
feedContext: slice.feedContext,
likeCount: item.post.likeCount ?? 0,
repostCount: item.post.repostCount ?? 0,
replyCount: item.post.replyCount ?? 0,
@@ -323,10 +311,10 @@ export function usePostFeedQuery(
)
}
return {
const feedPostSlice: FeedPostSlice = {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
rootUri: slice.rootItem.post.uri,
rootUri: slice.uri,
isThread:
slice.items.length > 1 &&
slice.items.every(
@@ -341,35 +329,42 @@ export function usePostFeedQuery(
AppBskyFeedPost.validateRecord(item.post.record)
.success
) {
const parentAuthor =
item.reply?.parent?.author ??
slice.items[i + 1]?.reply?.grandparentAuthor
const parent = item.reply?.parent
let parentAuthor:
| AppBskyActorDefs.ProfileViewBasic
| undefined
if (AppBskyFeedDefs.isPostView(parent)) {
parentAuthor = parent.author
}
if (!parentAuthor) {
parentAuthor =
slice.items[i + 1]?.reply?.grandparentAuthor
}
const replyRef = item.reply
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
replyRef?.parent,
)
return {
const feedPostSliceItem: FeedPostSliceItem = {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
uri: item.post.uri,
post: item.post,
record: item.post.record,
reason:
i === 0 && slice.source
? slice.source
: item.reason,
feedContext: item.feedContext || slice.feedContext,
reason: slice.reason,
feedContext: slice.feedContext,
moderation: moderations[i],
parentAuthor,
isParentBlocked,
}
return feedPostSliceItem
}
return undefined
})
.filter(Boolean) as FeedPostSliceItem[],
.filter(n => !!n),
}
return feedPostSlice
})
.filter(Boolean) as FeedPostSlice[],
.filter(n => !!n),
})),
],
}
+11 -11
View File
@@ -4,7 +4,7 @@ export type ThreadgateSetting =
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {type: 'list'; list: string}
| {type: 'list'; list: unknown}
export function threadgateViewToSettings(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
@@ -21,18 +21,18 @@ export function threadgateViewToSettings(
if (!record.allow?.length) {
return [{type: 'nobody'}]
}
return record.allow
const settings: ThreadgateSetting[] = record.allow
.map(allow => {
let setting: ThreadgateSetting | undefined
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
return {type: 'mention'}
setting = {type: 'mention'}
} else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
setting = {type: 'following'}
} else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
setting = {type: 'list', list: allow.list}
}
if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
return {type: 'following'}
}
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
return {type: 'list', list: allow.list}
}
return undefined
return setting
})
.filter(Boolean) as ThreadgateSetting[]
.filter(n => !!n)
return settings
}
+31
View File
@@ -0,0 +1,31 @@
import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query'
import {CompressedVideo, compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({
onProgress,
onSuccess,
onError,
}: {
onProgress: (progress: number) => void
onError: (e: any) => void
onSuccess: (video: CompressedVideo) => void
}) {
return useMutation({
mutationFn: async (asset: ImagePickerAsset) => {
return await compressVideo(asset.uri, {
onProgress: num => onProgress(trunc2dp(num)),
})
},
onError,
onSuccess,
onMutate: () => {
onProgress(0)
},
})
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
+15
View File
@@ -0,0 +1,15 @@
const UPLOAD_ENDPOINT = process.env.EXPO_PUBLIC_VIDEO_ROOT_ENDPOINT ?? ''
export const createVideoEndpointUrl = (
route: string,
params?: Record<string, string>,
) => {
const url = new URL(`${UPLOAD_ENDPOINT}`)
url.pathname = route
if (params) {
for (const key in params) {
url.searchParams.set(key, params[key])
}
}
return url.href
}
+59
View File
@@ -0,0 +1,59 @@
import {createUploadTask, FileSystemUploadType} from 'expo-file-system'
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {CompressedVideo} from 'lib/media/video/compress'
import {UploadVideoResponse} from 'lib/media/video/types'
import {createVideoEndpointUrl} from 'state/queries/video/util'
import {useSession} from 'state/session'
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
}: {
onSuccess: (response: UploadVideoResponse) => void
onError: (e: any) => void
setProgress: (progress: number) => void
}) => {
const {currentAccount} = useSession()
return useMutation({
mutationFn: async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/upload', {
did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
})
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?
},
httpMethod: 'POST',
uploadType: FileSystemUploadType.BINARY_CONTENT,
},
p => {
setProgress(p.totalBytesSent / p.totalBytesExpectedToSend)
},
)
const res = await uploadTask.uploadAsync()
if (!res?.body) {
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)
return responseBody
},
onError,
onSuccess,
})
}
@@ -0,0 +1,66 @@
import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure'
import {CompressedVideo} from 'lib/media/video/compress'
import {UploadVideoResponse} from 'lib/media/video/types'
import {createVideoEndpointUrl} from 'state/queries/video/util'
import {useSession} from 'state/session'
const UPLOAD_HEADER = process.env.EXPO_PUBLIC_VIDEO_HEADER ?? ''
export const useUploadVideoMutation = ({
onSuccess,
onError,
setProgress,
}: {
onSuccess: (response: UploadVideoResponse) => void
onError: (e: any) => void
setProgress: (progress: number) => void
}) => {
const {currentAccount} = useSession()
return useMutation({
mutationFn: async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/upload', {
did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to?
})
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 {
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.send(bytes)
})) as UploadVideoResponse
// @TODO rm for prod
console.log('[VIDEO]', res)
return res
},
onError,
onSuccess,
})
}
+212
View File
@@ -0,0 +1,212 @@
import React from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQuery} 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 {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: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset}
| {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: JobStatus}
export interface State {
status: Status
progress: number
asset?: ImagePickerAsset
video: CompressedVideo | null
jobStatus?: JobStatus
error?: string
}
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,
}
} 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
}
export function useUploadVideo({
setStatus,
onSuccess,
}: {
setStatus: (status: string) => void
onSuccess: () => void
}) {
const {_} = useLingui()
const [state, dispatch] = React.useReducer(reducer, {
status: 'idle',
progress: 0,
video: null,
})
const {setJobId} = useUploadStatusQuery({
onStatusChange: (status: 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({
type: 'SetJobStatus',
jobStatus: status,
})
setStatus(status.state.toString())
},
onSuccess: () => {
dispatch({
type: 'SetStatus',
status: 'idle',
})
onSuccess()
},
})
const {mutate: onVideoCompressed} = useUploadVideoMutation({
onSuccess: response => {
dispatch({
type: 'SetStatus',
status: 'processing',
})
setJobId(response.job_id)
},
onError: e => {
dispatch({
type: 'SetError',
error: _(msg`An error occurred while uploading the video.`),
})
logger.error('Error uploading video', {safeMessage: e})
},
setProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
})
const {mutate: onSelectVideo} = useCompressVideoMutation({
onProgress: p => {
dispatch({type: 'SetProgress', progress: p})
},
onError: e => {
if (e instanceof VideoTooLargeError) {
dispatch({
type: 'SetError',
error: _(msg`The selected video is larger than 100MB.`),
})
} else {
dispatch({
type: 'SetError',
// @TODO better error message from server, left untranslated on purpose
error: 'An error occurred while compressing the video.',
})
logger.error('Error compressing video', {safeMessage: e})
}
},
onSuccess: (video: CompressedVideo) => {
dispatch({
type: 'SetVideo',
video,
})
dispatch({
type: 'SetStatus',
status: 'uploading',
})
onVideoCompressed(video)
},
})
const selectVideo = (asset: ImagePickerAsset) => {
dispatch({
type: 'SetAsset',
asset,
})
dispatch({
type: 'SetStatus',
status: 'compressing',
})
onSelectVideo(asset)
}
const clearVideo = () => {
// @TODO cancel any running jobs
dispatch({type: 'Reset'})
}
return {
state,
dispatch,
selectVideo,
clearVideo,
}
}
const useUploadStatusQuery = ({
onStatusChange,
onSuccess,
}: {
onStatusChange: (status: JobStatus) => void
onSuccess: () => void
}) => {
const [enabled, setEnabled] = React.useState(true)
const [jobId, setJobId] = React.useState<string>()
const {isLoading, isError} = useQuery({
queryKey: ['video-upload'],
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) {
setEnabled(false)
onSuccess()
}
onStatusChange(status)
return status
},
enabled: Boolean(jobId && enabled),
refetchInterval: 1500,
})
return {
isLoading,
isError,
setJobId: (_jobId: string) => {
setJobId(_jobId)
},
}
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react'
interface PostProgressState {
progress: number
status: 'pending' | 'success' | 'error' | 'idle'
error?: string
}
const PostProgressContext = React.createContext<PostProgressState>({
progress: 0,
status: 'idle',
})
export function Provider() {}
export function usePostProgress() {
return React.useContext(PostProgressContext)
}
+10 -3
View File
@@ -132,6 +132,7 @@ export const SplashScreen = ({
function Footer() {
const t = useTheme()
const {_} = useLingui()
return (
<View
@@ -147,13 +148,19 @@ function Footer() {
a.flex_1,
t.atoms.border_contrast_medium,
]}>
<InlineLinkText to="https://bsky.social">
<InlineLinkText
label={_(msg`Learn more about Bluesky`)}
to="https://bsky.social">
<Trans>Business</Trans>
</InlineLinkText>
<InlineLinkText to="https://bsky.social/about/blog">
<InlineLinkText
label={_(msg`Read the Bluesky blog`)}
to="https://bsky.social/about/blog">
<Trans>Blog</Trans>
</InlineLinkText>
<InlineLinkText to="https://bsky.social/about/join">
<InlineLinkText
label={_(msg`See jobs at Bluesky`)}
to="https://bsky.social/about/join">
<Trans>Jobs</Trans>
</InlineLinkText>
+140 -62
View File
@@ -13,10 +13,16 @@ import {
Keyboard,
KeyboardAvoidingView,
LayoutChangeEvent,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
// @ts-expect-error no type definition
import ProgressCircle from 'react-native-progress/Circle'
import Animated, {
FadeIn,
FadeOut,
interpolateColor,
useAnimatedStyle,
useSharedValue,
@@ -55,6 +61,7 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {useUploadVideo} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics'
@@ -70,6 +77,7 @@ import {colors, s} from 'lib/styles'
import {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
import {useDialogStateControlContext} from 'state/dialogs'
import {GalleryModel} from 'state/models/media/gallery'
import {State as VideoUploadState} from 'state/queries/video/video'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a, useTheme} from '#/alf'
@@ -96,10 +104,8 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch'
import {SelectVideoBtn} from './videos/SelectVideoBtn'
import {useVideoState} from './videos/state'
import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
import hairlineWidth = StyleSheet.hairlineWidth
type CancelRef = {
onPressCancel: () => void
@@ -160,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
initQuote,
)
const {
video,
onSelectVideo,
videoPending,
videoProcessingData,
selectVideo,
clearVideo,
videoProcessingProgress,
} = useVideoState({setError})
state: videoUploadState,
} = useUploadVideo({
setStatus: (status: string) => setProcessingState(status),
onSuccess: () => {
if (publishOnUpload) {
onPressPublish(true)
}
},
})
const [publishOnUpload, setPublishOnUpload] = useState(false)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
@@ -275,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
const onPressPublish = async () => {
const onPressPublish = async (finishedUploading?: boolean) => {
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
@@ -284,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
return
}
if (
!finishedUploading &&
videoUploadState.status !== 'idle' &&
videoUploadState.asset
) {
setPublishOnUpload(true)
return
}
setError('')
if (
@@ -388,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
: _(msg`What's up?`)
const canSelectImages =
gallery.size < 4 && !extLink && !video && !videoPending
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
gallery.size < 4 &&
!extLink &&
videoUploadState.status === 'idle' &&
!videoUploadState.video
const hasMedia =
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
const onEmojiButtonPress = useCallback(() => {
openPicker?.(textInput.current?.getCursorPosition())
@@ -501,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
shape="default"
size="small"
style={[a.rounded_full, a.py_sm]}
onPress={onPressPublish}>
onPress={() => onPressPublish()}
disabled={
videoUploadState.status !== 'idle' && publishOnUpload
}>
<ButtonText style={[a.text_md]}>
{replyTo ? (
<Trans context="action">Reply</Trans>
@@ -573,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
autoFocus
setRichText={setRichText}
onPhotoPasted={onPhotoPasted}
onPressPublish={onPressPublish}
onPressPublish={() => onPressPublish()}
onNewLink={onNewLink}
onError={setError}
accessible={true}
@@ -603,29 +632,33 @@ export const ComposePost = observer(function ComposePost({
</View>
)}
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
<View style={[a.mt_md]}>
{quote ? (
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
<View style={{pointerEvents: 'none'}}>
<QuoteEmbed quote={quote} />
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
{quote.uri !== initQuote?.uri && (
<QuoteX onRemove={() => setQuote(undefined)} />
)}
</View>
) : null}
{videoPending && videoProcessingData ? (
<VideoTranscodeProgress
input={videoProcessingData}
progress={videoProcessingProgress}
/>
) : (
video && (
) : null}
{videoUploadState.status === 'compressing' &&
videoUploadState.asset ? (
<VideoTranscodeProgress
asset={videoUploadState.asset}
progress={videoUploadState.progress}
/>
) : videoUploadState.video ? (
// remove suspense when we get rid of lazy
<Suspense fallback={null}>
<VideoPreview video={video} clear={clearVideo} />
<VideoPreview
video={videoUploadState.video}
clear={clearVideo}
/>
</Suspense>
)
)}
) : null}
</View>
</Animated.ScrollView>
<SuggestedLanguage text={richtext.text} />
@@ -642,33 +675,37 @@ export const ComposePost = observer(function ComposePost({
t.atoms.border_contrast_medium,
styles.bottomBar,
]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && (
<SelectVideoBtn
onSelectVideo={onSelectVideo}
disabled={!canSelectImages}
{videoUploadState.status !== 'idle' ? (
<VideoUploadToolbar state={videoUploadState} />
) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && (
<SelectVideoBtn
onSelectVideo={selectVideo}
disabled={!canSelectImages}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn
onClose={focusTextInput}
onSelectGif={onSelectGif}
disabled={hasMedia}
/>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</View>
{!isMobile ? (
<Button
onPress={onEmojiButtonPress}
style={a.p_sm}
label={_(msg`Open emoji picker`)}
accessibilityHint={_(msg`Open emoji picker`)}
variant="ghost"
shape="round"
color="primary">
<EmojiSmile size="lg" />
</Button>
) : null}
</ToolbarWrapper>
)}
<View style={a.flex_1} />
<SelectLangBtn />
<CharProgress count={graphemeLength} />
@@ -763,7 +800,7 @@ function useAnimatedBorders() {
const topBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: interpolateColor(
hasScrolledTop.value,
[0, 1],
@@ -773,7 +810,7 @@ function useAnimatedBorders() {
})
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
return {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
borderColor: interpolateColor(
hasScrolledBottom.value,
[0, 1],
@@ -855,7 +892,7 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
errorIcon: {
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.red4,
color: colors.red4,
borderRadius: 30,
@@ -891,6 +928,47 @@ const styles = StyleSheet.create({
paddingLeft: 7,
paddingRight: 16,
alignItems: 'center',
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
})
function ToolbarWrapper({
style,
children,
}: {
style: StyleProp<ViewStyle>
children: React.ReactNode
}) {
if (isWeb) return children
return (
<Animated.View
style={style}
entering={FadeIn.duration(400)}
exiting={FadeOut.duration(400)}>
{children}
</Animated.View>
)
}
function VideoUploadToolbar({state}: {state: VideoUploadState}) {
const t = useTheme()
const progress =
state.status === 'compressing' || state.status === 'uploading'
? state.progress
: state.jobStatus?.progress ?? 100
return (
<ToolbarWrapper
style={[a.gap_sm, a.flex_row, a.align_center, {paddingVertical: 5}]}>
<ProgressCircle
size={30}
borderWidth={1}
borderColor={t.atoms.border_contrast_low.borderColor}
color={t.palette.primary_500}
progress={progress}
/>
<Text>{state.status}</Text>
</ToolbarWrapper>
)
}
+1 -2
View File
@@ -9,7 +9,6 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
const {currentAccount} = useSession()
@@ -49,7 +48,7 @@ const styles = StyleSheet.create({
paddingBottom: 10,
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
labelMobile: {
paddingLeft: 12,
@@ -17,6 +17,7 @@ export function VideoPreview({
const player = useVideoPlayer(video.uri, player => {
player.loop = true
player.play()
player.volume = 0
})
return (
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
export function VideoTranscodeProgress({
input,
asset,
progress,
}: {
input: ImagePickerAsset
asset: ImagePickerAsset
progress: number
}) {
const t = useTheme()
const aspectRatio = input.width / input.height
const aspectRatio = asset.width / asset.height
return (
<View
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
a.overflow_hidden,
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
]}>
<VideoTranscodeBackdrop uri={input.uri} />
<VideoTranscodeBackdrop uri={asset.uri} />
<View
style={[
a.flex_1,
-51
View File
@@ -1,51 +0,0 @@
import {useState} from 'react'
import {ImagePickerAsset} from 'expo-image-picker'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation} from '@tanstack/react-query'
import {compressVideo} from '#/lib/media/video/compress'
import {logger} from '#/logger'
import {VideoTooLargeError} from 'lib/media/video/errors'
import * as Toast from 'view/com/util/Toast'
export function useVideoState({setError}: {setError: (error: string) => void}) {
const {_} = useLingui()
const [progress, setProgress] = useState(0)
const {mutate, data, isPending, isError, reset, variables} = useMutation({
mutationFn: async (asset: ImagePickerAsset) => {
const compressed = await compressVideo(asset.uri, {
onProgress: num => setProgress(trunc2dp(num)),
})
return compressed
},
onError: (e: any) => {
// Don't log these errors in sentry, just let the user know
if (e instanceof VideoTooLargeError) {
Toast.show(_(msg`Videos cannot be larger than 100MB`), 'xmark')
return
}
logger.error('Failed to compress video', {safeError: e})
setError(_(msg`Could not compress video`))
},
onMutate: () => {
setProgress(0)
},
})
return {
video: data,
onSelectVideo: mutate,
videoPending: isPending,
videoProcessingData: variables,
videoError: isError,
clearVideo: reset,
videoProcessingProgress: progress,
}
}
function trunc2dp(num: number) {
return Math.trunc(num * 100) / 100
}
+3 -4
View File
@@ -13,6 +13,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {shouldClickOpenNewTab} from '#/platform/urls'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {
useAddSavedFeedsMutation,
@@ -32,8 +33,6 @@ import * as Prompt from '#/components/Prompt'
import {RichText} from '#/components/RichText'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
import {shouldClickOpenNewTab} from '#/platform/urls'
export function FeedSourceCard({
feedUri,
@@ -209,7 +208,7 @@ export function FeedSourceCardLoaded({
styles.container,
pal.border,
style,
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
]}
onPress={e => {
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
@@ -330,7 +329,7 @@ const styles = StyleSheet.create({
gap: 14,
},
border: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
headerContainer: {
flexDirection: 'row',
+1 -1
View File
@@ -73,7 +73,7 @@ export function HomeHeaderLayoutMobile({
]}>
{IS_DEV && (
<>
<Link to="/sys/debug">
<Link label="View storybook" to="/sys/debug">
<ColorPalette size="md" />
</Link>
</>
+1 -2
View File
@@ -14,7 +14,6 @@ import {RichText as RichTextCom} from '#/components/RichText'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export const ListCard = ({
testID,
@@ -134,7 +133,7 @@ export const ListCard = ({
const styles = StyleSheet.create({
outer: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 6,
},
outerNoBorder: {
+3 -4
View File
@@ -29,7 +29,6 @@ import {Button} from '../util/forms/Button'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
import {UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export const snapPoints = ['fullscreen']
@@ -63,7 +62,7 @@ export function Component({
return [pal.border, {height: screenHeight / 1.5}]
}
return [pal.border, {flex: 1, borderTopWidth: hairlineWidth}]
return [pal.border, {flex: 1, borderTopWidth: StyleSheet.hairlineWidth}]
}, [pal.border, screenHeight])
return (
@@ -188,7 +187,7 @@ function ListItem({
style={[
styles.listItem,
pal.border,
index !== 0 && {borderTopWidth: hairlineWidth},
index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
]}>
<View style={styles.listItemAvi}>
<UserAvatar size={40} avatar={list.avatar} type="list" />
@@ -248,7 +247,7 @@ const styles = StyleSheet.create({
gap: 10,
paddingTop: 10,
paddingBottom: isAndroid ? 10 : 0,
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
},
footerBtn: {
paddingHorizontal: 24,
+7 -3
View File
@@ -24,7 +24,6 @@ import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceho
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
import {CenteredView} from '#/view/com/util/Views'
import {FeedItem} from './FeedItem'
import hairlineWidth = StyleSheet.hairlineWidth
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
@@ -35,11 +34,13 @@ export function Feed({
onPressTryAgain,
onScrolledDownChange,
ListHeaderComponent,
overridePriorityNotifications,
}: {
scrollElRef?: ListRef
onPressTryAgain?: () => void
onScrolledDownChange: (isScrolledDown: boolean) => void
ListHeaderComponent?: () => JSX.Element
overridePriorityNotifications?: boolean
}) {
const initialNumToRender = useInitialNumToRender()
@@ -59,7 +60,10 @@ export function Feed({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useNotificationFeedQuery({enabled: !!moderationOpts})
} = useNotificationFeedQuery({
enabled: !!moderationOpts,
overridePriorityNotifications,
})
const isEmpty = !isFetching && !data?.pages[0]?.items.length
const items = React.useMemo(() => {
@@ -132,7 +136,7 @@ export function Feed({
<View
style={[
pal.border,
!isTabletOrMobile && {borderTopWidth: hairlineWidth},
!isTabletOrMobile && {borderTopWidth: StyleSheet.hairlineWidth},
]}>
<NotificationFeedLoadingPlaceholder />
</View>
+57 -28
View File
@@ -13,27 +13,37 @@ import {
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyGraphFollow,
moderateProfile,
ModerationDecision,
ModerationOpts,
} from '@atproto/api'
import {AtUri} from '@atproto/api'
import {TID} from '@atproto/common-web'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger'
import {FeedNotification} from '#/state/queries/notifications/feed'
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {forceLTR} from 'lib/strings/bidi'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time'
import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
import {precacheProfile} from 'state/queries/profile'
import {useAgent} from 'state/session'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
@@ -41,8 +51,10 @@ import {
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
import {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
import {StarterPack} from '#/components/icons/StarterPack'
import {Link as NewLink} from '#/components/Link'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
import {FeedSourceCard} from '../feeds/FeedSourceCard'
import {Post} from '../post/Post'
import {ImageHorzList} from '../util/images/ImageHorzList'
@@ -52,19 +64,6 @@ import {Text} from '../util/text/Text'
import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
import {useNavigation} from '@react-navigation/native'
import {parseTenorGif} from '#/lib/strings/embed-player'
import {logger} from '#/logger'
import {NavigationProp} from 'lib/routes/types'
import {forceLTR} from 'lib/strings/bidi'
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
import {useAgent} from 'state/session'
import {Button, ButtonText} from '#/components/Button'
import {StarterPack} from '#/components/icons/StarterPack'
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
const MAX_AUTHORS = 5
const EXPANDED_AUTHOR_EL_HEIGHT = 35
@@ -171,7 +170,6 @@ let FeedItem = ({
)
}
let isFollowBack = false
let action = ''
let icon = (
<HeartIconFilled
@@ -188,11 +186,28 @@ let FeedItem = ({
action = _(msg`reposted your post`)
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
} else if (item.type === 'follow') {
let isFollowBack = false
if (
item.notification.author.viewer?.following &&
gate('ungroup_follow_backs')
AppBskyGraphFollow.isRecord(item.notification.record)
) {
isFollowBack = true
let followingTimestamp
try {
const rkey = new AtUri(item.notification.author.viewer.following).rkey
followingTimestamp = TID.fromStr(rkey).timestamp()
} catch (e) {
// For some reason the following URI was invalid. Default to it not being a follow back.
console.error('Invalid following URI')
}
if (followingTimestamp) {
const followedTimestamp =
new Date(item.notification.record.createdAt).getTime() * 1000
isFollowBack = followedTimestamp > followingTimestamp
}
}
if (isFollowBack && gate('ungroup_follow_backs')) {
action = _(msg`followed you back`)
} else {
action = _(msg`followed you`)
@@ -211,7 +226,22 @@ let FeedItem = ({
return null
}
let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : ''
const formattedCount =
authors.length > 1 ? formatCount(authors.length - 1) : ''
const firstAuthorName = sanitizeDisplayName(
authors[0].profile.displayName || authors[0].profile.handle,
)
const niceTimestamp = niceDate(item.notification.indexedAt)
const a11yLabelUsers =
authors.length > 1
? _(msg` and `) +
plural(authors.length - 1, {
one: `${formattedCount} other`,
other: `${formattedCount} others`,
})
: ''
const a11yLabel = `${firstAuthorName}${a11yLabelUsers} ${action} ${niceTimestamp}`
return (
<Link
testID={`feedItem-by-${item.notification.author.handle}`}
@@ -224,10 +254,12 @@ let FeedItem = ({
backgroundColor: pal.colors.unreadNotifBg,
borderColor: pal.colors.unreadNotifBorder,
},
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
]}
href={itemHref}
noFeedback
accessibilityHint=""
accessibilityLabel={a11yLabel}
accessible={!isAuthorsExpanded}
accessibilityActions={
authors.length > 1
@@ -272,19 +304,18 @@ let FeedItem = ({
visible={!isAuthorsExpanded}
authors={authors}
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
showDmButton={item.type === 'starterpack-joined' || isFollowBack}
showDmButton={item.type === 'starterpack-joined'}
/>
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
<Text style={[styles.meta, a.self_start]}>
<Text
style={[styles.meta, a.self_start]}
accessibilityHint=""
accessibilityLabel={a11yLabel}>
<TextLink
key={authors[0].href}
style={[pal.text, s.bold]}
href={authors[0].href}
text={forceLTR(
sanitizeDisplayName(
authors[0].profile.displayName || authors[0].profile.handle,
),
)}
text={forceLTR(firstAuthorName)}
disableMismatchWarning
/>
{authors.length > 1 ? (
@@ -306,7 +337,7 @@ let FeedItem = ({
{({timeElapsed}) => (
<Text
style={[pal.textLight, styles.pointer]}
title={niceDate(item.notification.indexedAt)}>
title={niceTimestamp}>
{' ' + timeElapsed}
</Text>
)}
@@ -458,7 +489,6 @@ function CondensedAuthorsList({
profile={authors[0].profile}
moderation={authors[0].moderation.ui('avatar')}
type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
accessible={false}
/>
{showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
</View>
@@ -476,7 +506,6 @@ function CondensedAuthorsList({
profile={author.profile}
moderation={author.moderation.ui('avatar')}
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
accessible={false}
/>
</View>
))}
+1 -2
View File
@@ -7,7 +7,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {PressableWithHover} from '../util/PressableWithHover'
import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
import hairlineWidth = StyleSheet.hairlineWidth
export interface TabBarProps {
testID?: string
@@ -208,6 +207,6 @@ const mobileStyles = StyleSheet.create({
left: 0,
right: 0,
top: '100%',
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
})
+5 -6
View File
@@ -44,7 +44,6 @@ import {PostEmbeds} from '../util/post-embeds'
import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
export function PostThreadItem({
post,
@@ -604,7 +603,7 @@ function PostOuterWrapper({
{
flexDirection: 'row',
paddingHorizontal: isMobile ? 10 : 6,
borderTopWidth: depth === 1 ? hairlineWidth : 0,
borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0,
},
]}>
{Array.from(Array(depth - 1)).map((_, n: number) => (
@@ -699,7 +698,7 @@ function getThreadAuthor(
const styles = StyleSheet.create({
outer: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
paddingLeft: 8,
},
outerHighlighted: {
@@ -709,7 +708,7 @@ const styles = StyleSheet.create({
paddingRight: 8,
},
outerHighlightedRoot: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
paddingTop: 16,
},
noTopBorder: {
@@ -761,8 +760,8 @@ const styles = StyleSheet.create({
expandedInfo: {
flexDirection: 'row',
padding: 10,
borderTopWidth: hairlineWidth,
borderBottomWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
marginTop: 5,
marginBottom: 10,
},
+1 -2
View File
@@ -37,7 +37,6 @@ import {PostMeta} from '../util/PostMeta'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {UserInfoText} from '../util/UserInfoText'
import hairlineWidth = StyleSheet.hairlineWidth
export function Post({
post,
@@ -155,7 +154,7 @@ function PostInner({
style={[
styles.outer,
pal.border,
!hideTopBorder && {borderTopWidth: hairlineWidth},
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
style,
]}
onBeforePress={onBeforePress}>
+58 -33
View File
@@ -16,8 +16,10 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useGate} from '#/lib/statsig/statsig'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
import {MAX_POST_LINES} from 'lib/constants'
@@ -29,6 +31,7 @@ import {countLines} from 'lib/strings/helpers'
import {s} from 'lib/styles'
import {precacheProfile} from 'state/queries/profile'
import {atoms as a} from '#/alf'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {ContentHider} from '#/components/moderation/ContentHider'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
@@ -38,17 +41,19 @@ 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'
import hairlineWidth = StyleSheet.hairlineWidth
import {useSession} from '#/state/session'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
interface FeedItemProps {
record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
reason:
| AppBskyFeedDefs.ReasonRepost
| ReasonFeedSource
| {[k: string]: unknown; $type: string}
| undefined
moderation: ModerationDecision
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
showReplyTo: boolean
@@ -132,6 +137,8 @@ let FeedItemInner = ({
const {openComposer} = useComposerControls()
const pal = usePalette('default')
const {_} = useLingui()
const gate = useGate()
const href = useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
@@ -197,7 +204,8 @@ let FeedItemInner = ({
isThreadLastChild || (!isThreadChild && !isThreadParent)
? 8
: undefined,
borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth,
borderTopWidth:
hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
},
]
@@ -337,9 +345,11 @@ let FeedItemInner = ({
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
{!isThreadChild && showReplyTo && parentAuthor && (
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
)}
{!isThreadChild &&
showReplyTo &&
(parentAuthor || isParentBlocked) && (
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
)}
<LabelsOnMyPost post={post} />
<PostContent
moderation={moderation}
@@ -348,6 +358,9 @@ let FeedItemInner = ({
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
/>
{__DEV__ && gate('videos') && (
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
)}
<PostCtrls
post={post}
record={record}
@@ -431,12 +444,46 @@ function ReplyToLabel({
profile,
blocked,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
profile: AppBskyActorDefs.ProfileViewBasic | undefined
blocked?: boolean
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
const isMe = profile.did === currentAccount?.did
let label
if (blocked) {
label = <Trans context="description">Reply to a blocked post</Trans>
} else if (profile != null) {
const isMe = profile.did === currentAccount?.did
if (isMe) {
label = <Trans context="description">Reply to you</Trans>
} else {
label = (
<Trans context="description">
Reply to{' '}
<ProfileHoverCard inline did={profile.did}>
<TextLinkOnWebOnly
type="md"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
href={makeProfileLink(profile)}
text={
profile.displayName
? sanitizeDisplayName(profile.displayName)
: sanitizeHandle(profile.handle)
}
/>
</ProfileHoverCard>
</Trans>
)
}
}
if (!label) {
// Should not happen.
return null
}
return (
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -450,29 +497,7 @@ function ReplyToLabel({
style={[pal.textLight, s.mr2]}
lineHeight={1.2}
numberOfLines={1}>
{isMe ? (
<Trans context="description">Reply to you</Trans>
) : blocked ? (
<Trans context="description">Reply to a blocked post</Trans>
) : (
<Trans context="description">
Reply to{' '}
<ProfileHoverCard inline did={profile.did}>
<TextLinkOnWebOnly
type="md"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
href={makeProfileLink(profile)}
text={
profile.displayName
? sanitizeDisplayName(profile.displayName)
: sanitizeHandle(profile.handle)
}
/>
</ProfileHoverCard>
</Trans>
)}
{label}
</Text>
</View>
)
+1
View File
@@ -99,6 +99,7 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
<Trans>
This feed is no longer online. We are showing{' '}
<InlineLinkText
label={_(msg`The Discover feed`)}
to="/profile/bsky.app/feed/whats-hot"
style={[a.text_md]}>
Discover
+2 -3
View File
@@ -23,12 +23,11 @@ import {
KnownFollowers,
shouldShowKnownFollowers,
} from '#/components/KnownFollowers'
import * as Pills from '#/components/Pills'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import {PreviewableUserAvatar} from '../util/UserAvatar'
import {FollowButton} from './FollowButton'
import hairlineWidth = StyleSheet.hairlineWidth
import * as Pills from '#/components/Pills'
export function ProfileCard({
testID,
@@ -217,7 +216,7 @@ export function ProfileCardWithFollowBtn({
const styles = StyleSheet.create({
outer: {
borderTopWidth: hairlineWidth,
borderTopWidth: StyleSheet.hairlineWidth,
paddingHorizontal: 6,
paddingVertical: 4,
},
@@ -15,15 +15,13 @@ import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeHandle} from 'lib/strings/handles'
import {isNative} from 'platform/detection'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {StarterPack} from '#/components/icons/StarterPack'
import {TextLink} from '../util/Link'
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
import {Text} from '../util/text/Text'
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
import {CenteredView} from '../util/Views'
import hairlineWidth = StyleSheet.hairlineWidth
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {StarterPack} from '#/components/icons/StarterPack'
export function ProfileSubpageHeader({
isLoading,
@@ -84,7 +82,7 @@ export function ProfileSubpageHeader({
{
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingTop: isNative ? 0 : 8,
paddingBottom: 8,
paddingHorizontal: isMobile ? 12 : 14,
+32 -3
View File
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{onStartReached && !isEmpty && (
<Visibility
<EdgeVisibility
root={disableFullWindowScroll ? nativeRef : null}
onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
containerRef={containerRef}
/>
)}
{headerComponent}
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
)
})}
{onEndReached && !isEmpty && (
<Visibility
<EdgeVisibility
root={disableFullWindowScroll ? nativeRef : null}
onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
key={data?.length}
containerRef={containerRef}
/>
)}
{footerComponent}
@@ -381,6 +382,34 @@ function ListImpl<ItemT>(
)
}
function EdgeVisibility({
root,
topMargin,
bottomMargin,
containerRef,
onVisibleChange,
}: {
root?: React.RefObject<HTMLDivElement> | null
topMargin?: string
bottomMargin?: string
containerRef: React.RefObject<Element>
onVisibleChange: (isVisible: boolean) => void
}) {
const [containerHeight, setContainerHeight] = React.useState(0)
useResizeObserver(containerRef, (w, h) => {
setContainerHeight(h)
})
return (
<Visibility
key={containerHeight}
root={root}
topMargin={topMargin}
bottomMargin={bottomMargin}
onVisibleChange={onVisibleChange}
/>
)
}
function useResizeObserver(
ref: React.RefObject<Element>,
onResize: undefined | ((w: number, h: number) => void),
+1 -2
View File
@@ -17,7 +17,6 @@ import {
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import hairlineWidth = StyleSheet.hairlineWidth
export function LoadingPlaceholder({
width,
@@ -236,7 +235,7 @@ export function FeedLoadingPlaceholder({
{
paddingHorizontal: 12,
paddingVertical: 18,
borderTopWidth: showTopBorder ? hairlineWidth : 0,
borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0,
},
pal.border,
style,
+1 -1
View File
@@ -12,7 +12,7 @@ import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'
import {IS_TEST} from '#/env'
const TIMEOUT = 3.7e3
const TIMEOUT = 2e3
export function show(
message: string,
+2 -8
View File
@@ -55,7 +55,6 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
profile: AppBskyActorDefs.ProfileViewBasic
disableHoverCard?: boolean
onBeforePress?: () => void
accessible?: boolean
}
const BLUR_AMOUNT = isWeb ? 5 : 100
@@ -412,7 +411,6 @@ let PreviewableUserAvatar = ({
profile,
disableHoverCard,
onBeforePress,
accessible = true,
...rest
}: PreviewableUserAvatarProps): React.ReactNode => {
const {_} = useLingui()
@@ -426,12 +424,8 @@ let PreviewableUserAvatar = ({
return (
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
<Link
label={
accessible
? _(msg`${profile.displayName || profile.handle}'s avatar`)
: undefined
}
accessibilityHint={accessible ? _(msg`Opens this profile`) : undefined}
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
accessibilityHint={_(msg`Opens this profile`)}
to={makeProfileLink({
did: profile.did,
handle: profile.handle,
+3 -4
View File
@@ -13,10 +13,9 @@ import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {NavigationProp} from 'lib/routes/types'
import {useTheme} from '#/alf'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
import {Text} from './text/Text'
import {CenteredView} from './Views'
import hairlineWidth = StyleSheet.hairlineWidth
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
@@ -154,7 +153,7 @@ function DesktopWebHeader({
styles.desktopHeader,
pal.border,
{
borderBottomWidth: showBorder ? hairlineWidth : 0,
borderBottomWidth: showBorder ? StyleSheet.hairlineWidth : 0,
},
{display: 'flex', flexDirection: 'column'},
]}>
@@ -243,7 +242,7 @@ const styles = StyleSheet.create({
marginRight: 'auto',
},
border: {
borderBottomWidth: hairlineWidth,
borderBottomWidth: StyleSheet.hairlineWidth,
},
titleContainer: {
marginLeft: 'auto',
+4 -5
View File
@@ -26,7 +26,6 @@ import Animated from 'react-native-reanimated'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {addStyle} from 'lib/styles'
import hairlineWidth = StyleSheet.hairlineWidth
interface AddedProps {
desktopFixedHeight?: boolean | number
@@ -50,8 +49,8 @@ export const CenteredView = React.forwardRef(function CenteredView(
}
if (sideBorders) {
style = addStyle(style, {
borderLeftWidth: hairlineWidth,
borderRightWidth: hairlineWidth,
borderLeftWidth: StyleSheet.hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
})
style = addStyle(style, pal.border)
}
@@ -163,8 +162,8 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl(
const styles = StyleSheet.create({
contentContainer: {
borderLeftWidth: hairlineWidth,
borderRightWidth: hairlineWidth,
borderLeftWidth: StyleSheet.hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth,
// @ts-ignore web only
minHeight: '100vh',
},
@@ -13,7 +13,6 @@ import {clamp} from '#/lib/numbers'
import {colors} from '#/lib/styles'
import {isWeb} from '#/platform/detection'
import {useSession} from '#/state/session'
import hairlineWidth = StyleSheet.hairlineWidth
const AnimatedTouchableOpacity =
Animated.createAnimatedComponent(TouchableOpacity)
@@ -74,7 +73,7 @@ const styles = StyleSheet.create({
// @ts-ignore 'fixed' is web only -prf
position: isWeb ? 'fixed' : 'absolute',
left: 18,
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
width: 52,
height: 52,
borderRadius: 26,
@@ -0,0 +1,48 @@
import React, {useCallback, useId, useMemo, useState} from 'react'
import {VideoPlayerProvider} from './VideoPlayerContext'
const ActiveVideoContext = React.createContext<{
activeViewId: string | null
setActiveView: (viewId: string, src: string) => void
} | null>(null)
export function ActiveVideoProvider({children}: {children: React.ReactNode}) {
const [activeViewId, setActiveViewId] = useState<string | null>(null)
const [source, setSource] = useState<string | null>(null)
const value = useMemo(
() => ({
activeViewId,
setActiveView: (viewId: string, src: string) => {
setActiveViewId(viewId)
setSource(src)
},
}),
[activeViewId],
)
return (
<ActiveVideoContext.Provider value={value}>
<VideoPlayerProvider source={source ?? ''} viewId={activeViewId}>
{children}
</VideoPlayerProvider>
</ActiveVideoContext.Provider>
)
}
export function useActiveVideoView() {
const context = React.useContext(ActiveVideoContext)
if (!context) {
throw new Error('useActiveVideo must be used within a ActiveVideoProvider')
}
const id = useId()
return {
active: context.activeViewId === id,
setActive: useCallback(
(source: string) => context.setActiveView(id, source),
[context, id],
),
}
}
+2 -3
View File
@@ -39,7 +39,6 @@ import {Link} from '../Link'
import {PostMeta} from '../PostMeta'
import {Text} from '../text/Text'
import {PostEmbeds} from '.'
import hairlineWidth = StyleSheet.hairlineWidth
export function MaybeQuoteEmbed({
embed,
@@ -262,7 +261,7 @@ const styles = StyleSheet.create({
marginTop: 8,
paddingVertical: 12,
paddingHorizontal: 12,
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
},
errorContainer: {
flexDirection: 'row',
@@ -272,7 +271,7 @@ const styles = StyleSheet.create({
marginTop: 8,
paddingVertical: 14,
paddingHorizontal: 14,
borderWidth: hairlineWidth,
borderWidth: StyleSheet.hairlineWidth,
},
alert: {
marginBottom: 6,
@@ -0,0 +1,44 @@
import React, {useCallback} from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play'
import {useActiveVideoView} from './ActiveVideoContext'
import {VideoEmbedInner} from './VideoEmbedInner'
export function VideoEmbed({source}: {source: string}) {
const t = useTheme()
const {active, setActive} = useActiveVideoView()
const {_} = useLingui()
const onPress = useCallback(() => setActive(source), [setActive, source])
return (
<View
style={[
a.w_full,
a.rounded_sm,
{aspectRatio: 16 / 9},
a.overflow_hidden,
t.atoms.bg_contrast_25,
a.my_xs,
]}>
{active ? (
<VideoEmbedInner source={source} />
) : (
<Button
style={[a.flex_1, t.atoms.bg_contrast_25]}
onPress={onPress}
label={_(msg`Play video`)}
variant="ghost"
color="secondary"
size="large">
<ButtonIcon icon={PlayIcon} />
</Button>
)}
</View>
)
}
@@ -0,0 +1,138 @@
import React, {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native'
import Animated, {
measure,
runOnJS,
useAnimatedRef,
useFrameCallback,
useSharedValue,
} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
import {useVideoPlayer} from './VideoPlayerContext'
export const VideoEmbedInner = ({}: {source: string}) => {
const player = useVideoPlayer()
const aref = useAnimatedRef<Animated.View>()
const {height: windowHeight} = useWindowDimensions()
const hasLeftView = useSharedValue(false)
const ref = useRef<VideoView>(null)
const onEnterView = useCallback(() => {
if (player.status === 'readyToPlay') {
player.play()
}
}, [player])
const onLeaveView = useCallback(() => {
player.pause()
}, [player])
const enterFullscreen = useCallback(() => {
if (ref.current) {
ref.current.enterFullscreen()
}
}, [])
useFrameCallback(() => {
const measurement = measure(aref)
if (measurement) {
if (hasLeftView.value) {
// Check if the video is in view
if (
measurement.pageY >= 0 &&
measurement.pageY + measurement.height <= windowHeight
) {
runOnJS(onEnterView)()
hasLeftView.value = false
}
} else {
// Check if the video is out of view
if (
measurement.pageY + measurement.height < 0 ||
measurement.pageY > windowHeight
) {
runOnJS(onLeaveView)()
hasLeftView.value = true
}
}
}
})
return (
<Animated.View
style={[a.flex_1, a.relative]}
ref={aref}
collapsable={false}>
<VideoView
ref={ref}
player={player}
style={a.flex_1}
nativeControls={true}
/>
<VideoControls player={player} enterFullscreen={enterFullscreen} />
</Animated.View>
)
}
function VideoControls({
player,
enterFullscreen,
}: {
player: VideoPlayer
enterFullscreen: () => void
}) {
const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime))
useEffect(() => {
const interval = setInterval(() => {
setCurrentTime(Math.floor(player.duration - player.currentTime))
// how often should we update the time?
// 1000 gets out of sync with the video time
}, 250)
return () => {
clearInterval(interval)
}
}, [player])
const minutes = Math.floor(currentTime / 60)
const seconds = String(currentTime % 60).padStart(2, '0')
return (
<View style={[a.absolute, a.inset_0]}>
<View style={styles.timeContainer} pointerEvents="none">
<Text style={styles.timeElapsed}>
{minutes}:{seconds}
</Text>
</View>
<Pressable
onPress={enterFullscreen}
style={a.flex_1}
accessibilityLabel="Video"
accessibilityHint="Tap to enter full screen"
accessibilityRole="button"
/>
</View>
)
}
const styles = StyleSheet.create({
timeContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
left: 5,
bottom: 5,
},
timeElapsed: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
},
})

Some files were not shown because too many files have changed in this diff Show More