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:
@@ -132,7 +132,8 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "feedItem-by-bob.test"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "e2eGotoFeeds"
|
id: "e2eGotoFeeds"
|
||||||
- tapOn: "Good Ppl"
|
- tapOn:
|
||||||
|
id: "saved-feed-Good Ppl"
|
||||||
- assertVisible:
|
- assertVisible:
|
||||||
id: "feedItem-by-bob.test"
|
id: "feedItem-by-bob.test"
|
||||||
- tapOn:
|
- tapOn:
|
||||||
@@ -168,7 +169,7 @@ appId: xyz.blueskyweb.app
|
|||||||
id: "profilePager-selector"
|
id: "profilePager-selector"
|
||||||
direction: LEFT
|
direction: LEFT
|
||||||
- tapOn:
|
- tapOn:
|
||||||
id: "profilePager-selector-5"
|
id: "profilePager-selector-6"
|
||||||
- tapOn: "Good Ppl"
|
- tapOn: "Good Ppl"
|
||||||
|
|
||||||
- tapOn:
|
- 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 |
@@ -197,6 +197,7 @@ func serve(cctx *cli.Context) error {
|
|||||||
e.GET("/search", server.WebGeneric)
|
e.GET("/search", server.WebGeneric)
|
||||||
e.GET("/feeds", server.WebGeneric)
|
e.GET("/feeds", server.WebGeneric)
|
||||||
e.GET("/notifications", server.WebGeneric)
|
e.GET("/notifications", server.WebGeneric)
|
||||||
|
e.GET("/notifications/settings", server.WebGeneric)
|
||||||
e.GET("/lists", server.WebGeneric)
|
e.GET("/lists", server.WebGeneric)
|
||||||
e.GET("/moderation", server.WebGeneric)
|
e.GET("/moderation", server.WebGeneric)
|
||||||
e.GET("/moderation/modlists", server.WebGeneric)
|
e.GET("/moderation/modlists", server.WebGeneric)
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bsky.app",
|
"name": "bsky.app",
|
||||||
"version": "1.89.0",
|
"version": "1.90.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.23",
|
"@atproto/api": "0.12.25",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
@@ -143,6 +143,7 @@
|
|||||||
"expo-web-browser": "~13.0.3",
|
"expo-web-browser": "~13.0.3",
|
||||||
"fast-text-encoding": "^1.0.6",
|
"fast-text-encoding": "^1.0.6",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
|
"hls.js": "^1.5.11",
|
||||||
"js-sha256": "^0.9.0",
|
"js-sha256": "^0.9.0",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lande": "^1.0.10",
|
"lande": "^1.0.10",
|
||||||
@@ -269,7 +270,7 @@
|
|||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"react-test-renderer": "18.2.0",
|
"react-test-renderer": "18.2.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.5.4",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^5.75.0",
|
"webpack": "^5.75.0",
|
||||||
"webpack-bundle-analyzer": "^4.10.1",
|
"webpack-bundle-analyzer": "^4.10.1",
|
||||||
|
|||||||
@@ -12,3 +12,15 @@ index bb74e80..0aa0202 100644
|
|||||||
|
|
||||||
Map<String, Object> constants = new HashMap<>(3);
|
Map<String, Object> constants = new HashMap<>(3);
|
||||||
constants.put(MODULES_CONSTANTS_KEY, new HashMap<>());
|
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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
+5
-2
@@ -23,10 +23,12 @@ import {
|
|||||||
} from '#/lib/statsig/statsig'
|
} from '#/lib/statsig/statsig'
|
||||||
import {s} from '#/lib/styles'
|
import {s} from '#/lib/styles'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
import {MessagesProvider} from '#/state/messages'
|
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 SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
||||||
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from '#/view/shell'
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
@@ -58,8 +61,6 @@ import {Provider as PortalProvider} from '#/components/Portal'
|
|||||||
import {Splash} from '#/Splash'
|
import {Splash} from '#/Splash'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
|
||||||
import {listenSessionDropped} from './state/events'
|
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync()
|
SplashScreen.preventAutoHideAsync()
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<Splash isReady={isReady && hasCheckedReferrer}>
|
<Splash isReady={isReady && hasCheckedReferrer}>
|
||||||
|
<ActiveVideoProvider>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
@@ -143,6 +145,7 @@ function InnerApp() {
|
|||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
|
</ActiveVideoProvider>
|
||||||
</Splash>
|
</Splash>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
|||||||
+5
-2
@@ -12,10 +12,12 @@ import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
|||||||
import {QueryProvider} from '#/lib/react-query'
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||||
import {ThemeProvider} from '#/lib/ThemeContext'
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
|
import I18nProvider from '#/locale/i18nProvider'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {Provider as A11yProvider} from '#/state/a11y'
|
import {Provider as A11yProvider} from '#/state/a11y'
|
||||||
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
|
||||||
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
import {Provider as DialogStateProvider} from '#/state/dialogs'
|
||||||
|
import {listenSessionDropped} from '#/state/events'
|
||||||
import {Provider as InvitesStateProvider} from '#/state/invites'
|
import {Provider as InvitesStateProvider} from '#/state/invites'
|
||||||
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
import {Provider as LightboxStateProvider} from '#/state/lightbox'
|
||||||
import {MessagesProvider} from '#/state/messages'
|
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 ProgressGuideProvider} from '#/state/shell/progress-guide'
|
||||||
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
|
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 * as Toast from '#/view/com/util/Toast'
|
||||||
import {ToastContainer} from '#/view/com/util/Toast.web'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
import {Shell} from '#/view/shell/index'
|
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 PortalProvider} from '#/components/Portal'
|
||||||
import {Provider as TourProvider} from '#/tours'
|
import {Provider as TourProvider} from '#/tours'
|
||||||
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
|
||||||
import I18nProvider from './locale/i18nProvider'
|
|
||||||
import {listenSessionDropped} from './state/events'
|
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const [isReady, setIsReady] = React.useState(false)
|
const [isReady, setIsReady] = React.useState(false)
|
||||||
@@ -92,6 +93,7 @@ function InnerApp() {
|
|||||||
<Alf theme={theme}>
|
<Alf theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<RootSiblingParent>
|
<RootSiblingParent>
|
||||||
|
<ActiveVideoProvider>
|
||||||
<React.Fragment
|
<React.Fragment
|
||||||
// Resets the entire tree below when it changes:
|
// Resets the entire tree below when it changes:
|
||||||
key={currentAccount?.did}>
|
key={currentAccount?.did}>
|
||||||
@@ -125,6 +127,7 @@ function InnerApp() {
|
|||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
</ActiveVideoProvider>
|
||||||
</RootSiblingParent>
|
</RootSiblingParent>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</Alf>
|
</Alf>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import {LogScreen} from './view/screens/Log'
|
|||||||
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
|
||||||
import {NotFoundScreen} from './view/screens/NotFound'
|
import {NotFoundScreen} from './view/screens/NotFound'
|
||||||
import {NotificationsScreen} from './view/screens/Notifications'
|
import {NotificationsScreen} from './view/screens/Notifications'
|
||||||
|
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
|
||||||
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
import {PostLikedByScreen} from './view/screens/PostLikedBy'
|
||||||
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
|
||||||
import {PostThreadScreen} from './view/screens/PostThread'
|
import {PostThreadScreen} from './view/screens/PostThread'
|
||||||
@@ -324,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
|
|||||||
getComponent={() => MessagesSettingsScreen}
|
getComponent={() => MessagesSettingsScreen}
|
||||||
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
options={{title: title(msg`Chat settings`), requireAuth: true}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="NotificationsSettings"
|
||||||
|
getComponent={() => NotificationsSettingsScreen}
|
||||||
|
options={{title: title(msg`Notification settings`), requireAuth: true}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Feeds"
|
name="Feeds"
|
||||||
getComponent={() => FeedsScreen}
|
getComponent={() => FeedsScreen}
|
||||||
|
|||||||
+5
-6
@@ -2,7 +2,6 @@ import {Platform, StyleSheet} from 'react-native'
|
|||||||
|
|
||||||
import * as tokens from '#/alf/tokens'
|
import * as tokens from '#/alf/tokens'
|
||||||
import {native, web} from '#/alf/util/platform'
|
import {native, web} from '#/alf/util/platform'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export const atoms = {
|
export const atoms = {
|
||||||
/*
|
/*
|
||||||
@@ -284,19 +283,19 @@ export const atoms = {
|
|||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_t: {
|
border_t: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_b: {
|
border_b: {
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_l: {
|
border_l: {
|
||||||
borderLeftWidth: hairlineWidth,
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
border_r: {
|
border_r: {
|
||||||
borderRightWidth: hairlineWidth,
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ export type ButtonProps = Pick<
|
|||||||
AccessibilityProps &
|
AccessibilityProps &
|
||||||
VariantProps & {
|
VariantProps & {
|
||||||
testID?: string
|
testID?: string
|
||||||
|
/**
|
||||||
|
* For a11y, try to make this descriptive and clear
|
||||||
|
*/
|
||||||
label: string
|
label: string
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
hoverStyle?: StyleProp<ViewStyle>
|
hoverStyle?: StyleProp<ViewStyle>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type Props = {
|
|||||||
export function Default(props: Props) {
|
export function Default(props: Props) {
|
||||||
const {view} = props
|
const {view} = props
|
||||||
return (
|
return (
|
||||||
<Link label={view.displayName} {...props}>
|
<Link {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={view.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
@@ -58,7 +58,7 @@ export function Link({
|
|||||||
view,
|
view,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Props & Omit<LinkProps, 'to'>) {
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const href = React.useMemo(() => {
|
const href = React.useMemo(() => {
|
||||||
@@ -70,7 +70,11 @@ export function Link({
|
|||||||
}, [view, queryClient])
|
}, [view, queryClient])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InternalLink to={href} style={[a.flex_col]} {...props}>
|
<InternalLink
|
||||||
|
label={view.displayName}
|
||||||
|
to={href}
|
||||||
|
style={[a.flex_col]}
|
||||||
|
{...props}>
|
||||||
{children}
|
{children}
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function SuggestedFollows() {
|
|||||||
{profiles.slice(0, maxLength).map(profile => (
|
{profiles.slice(0, maxLength).map(profile => (
|
||||||
<ProfileCard.Link
|
<ProfileCard.Link
|
||||||
key={profile.did}
|
key={profile.did}
|
||||||
did={profile.handle}
|
profile={profile}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
logEvent('feed:interstitial:profileCard:press', {})
|
logEvent('feed:interstitial:profileCard:press', {})
|
||||||
}}
|
}}
|
||||||
@@ -266,7 +266,10 @@ export function SuggestedFollows() {
|
|||||||
a.pt_xs,
|
a.pt_xs,
|
||||||
a.gap_md,
|
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>
|
<Trans>Browse more suggestions</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
@@ -396,7 +399,10 @@ export function SuggestedFeeds() {
|
|||||||
a.pt_xs,
|
a.pt_xs,
|
||||||
a.gap_md,
|
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>
|
<Trans>Browse more suggestions</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<Arrow size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ type BaseLinkProps = Pick<
|
|||||||
> & {
|
> & {
|
||||||
testID?: string
|
testID?: string
|
||||||
|
|
||||||
/**
|
|
||||||
* Label for a11y. Defaults to the href.
|
|
||||||
*/
|
|
||||||
label?: string
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The React Navigation `StackAction` to perform when the link is pressed.
|
* The React Navigation `StackAction` to perform when the link is pressed.
|
||||||
*/
|
*/
|
||||||
@@ -197,7 +192,7 @@ export function useLink({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LinkProps = Omit<BaseLinkProps, 'disableMismatchWarning'> &
|
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
|
* A interactive element that renders as a `<a>` tag on the web. On mobile it
|
||||||
@@ -224,7 +219,6 @@ export function Link({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
label={href}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[a.justify_start, flatten(rest.style)]}
|
style={[a.justify_start, flatten(rest.style)]}
|
||||||
role="link"
|
role="link"
|
||||||
@@ -249,7 +243,8 @@ export function Link({
|
|||||||
|
|
||||||
export type InlineLinkProps = React.PropsWithChildren<
|
export type InlineLinkProps = React.PropsWithChildren<
|
||||||
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
BaseLinkProps & TextStyleProp & Pick<TextProps, 'selectable'>
|
||||||
>
|
> &
|
||||||
|
Pick<ButtonProps, 'label'>
|
||||||
|
|
||||||
export function InlineLinkText({
|
export function InlineLinkText({
|
||||||
children,
|
children,
|
||||||
@@ -291,7 +286,7 @@ export function InlineLinkText({
|
|||||||
<Text
|
<Text
|
||||||
selectable={selectable}
|
selectable={selectable}
|
||||||
accessibilityHint=""
|
accessibilityHint=""
|
||||||
accessibilityLabel={label || href}
|
accessibilityLabel={label}
|
||||||
{...rest}
|
{...rest}
|
||||||
style={[
|
style={[
|
||||||
{color: t.palette.primary_500},
|
{color: t.palette.primary_500},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type Props = {
|
|||||||
export function Default(props: Props) {
|
export function Default(props: Props) {
|
||||||
const {view, showPinButton} = props
|
const {view, showPinButton} = props
|
||||||
return (
|
return (
|
||||||
<Link label={view.name} {...props}>
|
<Link {...props}>
|
||||||
<Outer>
|
<Outer>
|
||||||
<Header>
|
<Header>
|
||||||
<Avatar src={view.avatar} />
|
<Avatar src={view.avatar} />
|
||||||
@@ -67,7 +67,7 @@ export function Link({
|
|||||||
view,
|
view,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: Props & Omit<LinkProps, 'to'>) {
|
}: Props & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const href = React.useMemo(() => {
|
const href = React.useMemo(() => {
|
||||||
@@ -79,7 +79,7 @@ export function Link({
|
|||||||
}, [view, queryClient])
|
}, [view, queryClient])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InternalLink to={href} {...props}>
|
<InternalLink label={view.name} to={href} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
|
|||||||
return (
|
return (
|
||||||
<Error
|
<Error
|
||||||
title={errorTitle ?? _(msg`Oops!`)}
|
title={errorTitle ?? _(msg`Oops!`)}
|
||||||
message={errorMessage ?? _(`Something went wrong!`)}
|
message={errorMessage ?? _(msg`Something went wrong!`)}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onGoBack={onGoBack}
|
onGoBack={onGoBack}
|
||||||
sideBorders={sideBorders}
|
sideBorders={sideBorders}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function Default({
|
|||||||
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
logContext?: 'ProfileCard' | 'StarterPackProfilesList'
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Link did={profile.did}>
|
<Link profile={profile}>
|
||||||
<Card
|
<Card
|
||||||
profile={profile}
|
profile={profile}
|
||||||
moderationOpts={moderationOpts}
|
moderationOpts={moderationOpts}
|
||||||
@@ -96,16 +96,24 @@ export function Header({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Link({
|
export function Link({
|
||||||
did,
|
profile,
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
...rest
|
...rest
|
||||||
}: {did: string} & Omit<LinkProps, 'to'>) {
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
} & Omit<LinkProps, 'to' | 'label'>) {
|
||||||
|
const {_} = useLingui()
|
||||||
return (
|
return (
|
||||||
<InternalLink
|
<InternalLink
|
||||||
|
label={_(
|
||||||
|
msg`View ${
|
||||||
|
profile.displayName || sanitizeHandle(profile.handle)
|
||||||
|
}'s profile`,
|
||||||
|
)}
|
||||||
to={{
|
to={{
|
||||||
screen: 'Profile',
|
screen: 'Profile',
|
||||||
params: {name: did},
|
params: {name: profile.did},
|
||||||
}}
|
}}
|
||||||
style={[a.flex_col, style]}
|
style={[a.flex_col, style]}
|
||||||
{...rest}>
|
{...rest}>
|
||||||
|
|||||||
@@ -74,7 +74,9 @@ function DialogContent({
|
|||||||
|
|
||||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||||
// remove nobody
|
// remove nobody
|
||||||
let newSelected = draft.filter(v => v.type !== 'nobody')
|
let newSelected: ThreadgateSetting[] = draft.filter(
|
||||||
|
v => v.type !== 'nobody',
|
||||||
|
)
|
||||||
// toggle
|
// toggle
|
||||||
const i = newSelected.findIndex(v => isEqual(v, setting))
|
const i = newSelected.findIndex(v => isEqual(v, setting))
|
||||||
if (i === -1) {
|
if (i === -1) {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export function BlockedByListDialog({
|
|||||||
<React.Fragment key={block.source.list.uri}>
|
<React.Fragment key={block.source.list.uri}>
|
||||||
{i === 0 ? null : ', '}
|
{i === 0 ? null : ', '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={block.source.list.name}
|
||||||
to={listUriToHref(block.source.list.uri)}
|
to={listUriToHref(block.source.list.uri)}
|
||||||
style={[a.text_md, a.leading_snug]}>
|
style={[a.text_md, a.leading_snug]}>
|
||||||
{block.source.list.name}
|
{block.source.list.name}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function HeaderReady({
|
|||||||
userBlock?: ModerationCause
|
userBlock?: ModerationCause
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
|
const {_} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const convoState = useConvo()
|
const convoState = useConvo()
|
||||||
const profile = useProfileShadow(profileUnshadowed)
|
const profile = useProfileShadow(profileUnshadowed)
|
||||||
@@ -156,6 +157,7 @@ function HeaderReady({
|
|||||||
<View style={[a.flex_1]}>
|
<View style={[a.flex_1]}>
|
||||||
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
|
||||||
<Link
|
<Link
|
||||||
|
label={_(msg`View ${displayName}'s profile`)}
|
||||||
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
|
||||||
to={makeProfileLink(profile)}>
|
to={makeProfileLink(profile)}>
|
||||||
<View style={[a.pt_2xs]}>
|
<View style={[a.pt_2xs]}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react'
|
|||||||
import {Pressable, View, ViewStyle} from 'react-native'
|
import {Pressable, View, ViewStyle} from 'react-native'
|
||||||
import Animated, {LinearTransition} from 'react-native-reanimated'
|
import Animated, {LinearTransition} from 'react-native-reanimated'
|
||||||
|
|
||||||
|
import {isNative} from '#/platform/detection'
|
||||||
import {HITSLOP_10} from 'lib/constants'
|
import {HITSLOP_10} from 'lib/constants'
|
||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
@@ -459,3 +460,5 @@ export function Radio() {
|
|||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Platform = isNative ? Switch : Checkbox
|
||||||
|
|||||||
@@ -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',
|
|
||||||
})
|
|
||||||
@@ -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
|
disabled?: boolean
|
||||||
labelerDid?: string
|
labelerDid?: string
|
||||||
}) {
|
}) {
|
||||||
const {i18n} = useLingui()
|
const {_, i18n} = useLingui()
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {gtPhone} = useBreakpoints()
|
const {gtPhone} = useBreakpoints()
|
||||||
|
|
||||||
@@ -243,7 +243,10 @@ export function LabelerLabelPreference({
|
|||||||
) : isGlobalLabel ? (
|
) : isGlobalLabel ? (
|
||||||
<Trans>
|
<Trans>
|
||||||
Configured in{' '}
|
Configured in{' '}
|
||||||
<InlineLinkText to="/moderation" style={a.text_sm}>
|
<InlineLinkText
|
||||||
|
label={_(msg`moderation settings`)}
|
||||||
|
to="/moderation"
|
||||||
|
style={a.text_sm}>
|
||||||
moderation settings
|
moderation settings
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ function Label({
|
|||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const {labeler, strings} = useLabelInfo(label)
|
const {labeler, strings} = useLabelInfo(label)
|
||||||
|
const sourceName = labeler
|
||||||
|
? sanitizeHandle(labeler.creator.handle, '@')
|
||||||
|
: label.src
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -169,13 +172,12 @@ function Label({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Source:{' '}
|
Source:{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={sourceName}
|
||||||
to={makeProfileLink(
|
to={makeProfileLink(
|
||||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
)}
|
)}
|
||||||
onPress={() => control.close()}>
|
onPress={() => control.close()}>
|
||||||
{labeler
|
{sourceName}
|
||||||
? sanitizeHandle(labeler.creator.handle, '@')
|
|
||||||
: label.src}
|
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)}
|
||||||
@@ -203,6 +205,9 @@ function AppealForm({
|
|||||||
const isAccountReport = 'did' in subject
|
const isAccountReport = 'did' in subject
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
const sourceName = labeler
|
||||||
|
? sanitizeHandle(labeler.creator.handle, '@')
|
||||||
|
: label.src
|
||||||
|
|
||||||
const {mutate, isPending} = useMutation({
|
const {mutate, isPending} = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -260,12 +265,13 @@ function AppealForm({
|
|||||||
<Trans>
|
<Trans>
|
||||||
This appeal will be sent to{' '}
|
This appeal will be sent to{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={sourceName}
|
||||||
to={makeProfileLink(
|
to={makeProfileLink(
|
||||||
labeler ? labeler.creator : {did: label.src, handle: ''},
|
labeler ? labeler.creator : {did: label.src, handle: ''},
|
||||||
)}
|
)}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
style={[a.text_md, a.leading_snug]}>
|
style={[a.text_md, a.leading_snug]}>
|
||||||
{labeler ? sanitizeHandle(labeler.creator.handle, '@') : label.src}
|
{sourceName}
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ function ModerationDetailsDialogInner({
|
|||||||
description = (
|
description = (
|
||||||
<Trans>
|
<Trans>
|
||||||
This user is included in the{' '}
|
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}
|
{list.name}
|
||||||
</InlineLinkText>{' '}
|
</InlineLinkText>{' '}
|
||||||
list which you have blocked.
|
list which you have blocked.
|
||||||
@@ -83,7 +86,10 @@ function ModerationDetailsDialogInner({
|
|||||||
description = (
|
description = (
|
||||||
<Trans>
|
<Trans>
|
||||||
This user is included in the{' '}
|
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}
|
{list.name}
|
||||||
</InlineLinkText>{' '}
|
</InlineLinkText>{' '}
|
||||||
list which you have muted.
|
list which you have muted.
|
||||||
@@ -127,10 +133,11 @@ function ModerationDetailsDialogInner({
|
|||||||
<Trans>
|
<Trans>
|
||||||
This label was applied by{' '}
|
This label was applied by{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={desc.source || _(msg`an unknown labeler`)}
|
||||||
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
to={makeProfileLink({did: modcause.label.src, handle: ''})}
|
||||||
onPress={() => control.close()}
|
onPress={() => control.close()}
|
||||||
style={a.text_md}>
|
style={a.text_md}>
|
||||||
{desc.source}
|
{desc.source || _(msg`an unknown labeler`)}
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
.
|
.
|
||||||
</Trans>
|
</Trans>
|
||||||
|
|||||||
+62
-63
@@ -14,29 +14,33 @@ export type FeedTunerFn = (
|
|||||||
slices: FeedViewPostsSlice[],
|
slices: FeedViewPostsSlice[],
|
||||||
) => 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 {
|
export class FeedViewPostsSlice {
|
||||||
_reactKey: string
|
_reactKey: string
|
||||||
isFlattenedReply = false
|
_feedPost: FeedViewPost
|
||||||
|
items: FeedSliceItem[]
|
||||||
|
|
||||||
constructor(public items: FeedViewPost[]) {
|
constructor(feedPost: FeedViewPost) {
|
||||||
const item = items[0]
|
this._feedPost = feedPost
|
||||||
this._reactKey = `slice-${item.post.uri}-${
|
this._reactKey = `slice-${feedPost.post.uri}-${
|
||||||
item.reason?.indexedAt || item.post.indexedAt
|
feedPost.reason?.indexedAt || feedPost.post.indexedAt
|
||||||
}`
|
}`
|
||||||
|
this.items = [toSliceItem(feedPost)]
|
||||||
}
|
}
|
||||||
|
|
||||||
get uri() {
|
get uri() {
|
||||||
if (this.isFlattenedReply) {
|
return this._feedPost.post.uri
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isThread() {
|
get isThread() {
|
||||||
@@ -48,31 +52,42 @@ export class FeedViewPostsSlice {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get isFullThread() {
|
get isQuotePost() {
|
||||||
return this.isThread && !this.items[0].reply
|
const embed = this._feedPost.post.embed
|
||||||
}
|
return (
|
||||||
|
AppBskyEmbedRecord.isView(embed) ||
|
||||||
get rootItem() {
|
AppBskyEmbedRecordWithMedia.isView(embed)
|
||||||
if (this.isFlattenedReply) {
|
)
|
||||||
return this.items[1]
|
|
||||||
}
|
|
||||||
return this.items[0]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isReply() {
|
get isReply() {
|
||||||
return (
|
return (
|
||||||
AppBskyFeedPost.isRecord(this.rootItem.post.record) &&
|
AppBskyFeedPost.isRecord(this._feedPost.post.record) &&
|
||||||
!!this.rootItem.post.record.reply
|
!!this._feedPost.post.record.reply
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get source(): ReasonFeedSource | undefined {
|
get reason() {
|
||||||
return this.items.find(item => '__source' in item && !!item.__source)
|
return '__source' in this._feedPost
|
||||||
?.__source as ReasonFeedSource
|
? (this._feedPost.__source as ReasonFeedSource)
|
||||||
|
: this._feedPost.reason
|
||||||
}
|
}
|
||||||
|
|
||||||
get feedContext() {
|
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) {
|
containsUri(uri: string) {
|
||||||
@@ -97,40 +112,30 @@ export class FeedViewPostsSlice {
|
|||||||
if (this.items[0].reply) {
|
if (this.items[0].reply) {
|
||||||
const reply = this.items[0].reply
|
const reply = this.items[0].reply
|
||||||
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
if (AppBskyFeedDefs.isPostView(reply.parent)) {
|
||||||
this.isFlattenedReply = true
|
|
||||||
this.items.splice(0, 0, {post: reply.parent})
|
this.items.splice(0, 0, {post: reply.parent})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isFollowingAllAuthors(userDid: string) {
|
isFollowingAllAuthors(userDid: string) {
|
||||||
const item = this.rootItem
|
const feedPost = this._feedPost
|
||||||
if (item.post.author.did === userDid) {
|
if (feedPost.post.author.did === userDid) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (AppBskyFeedDefs.isPostView(item.reply?.parent)) {
|
if (AppBskyFeedDefs.isPostView(feedPost.reply?.parent)) {
|
||||||
const parent = item.reply?.parent
|
const parent = feedPost.reply?.parent
|
||||||
if (parent?.author.did === userDid) {
|
if (parent?.author.did === userDid) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
parent?.author.viewer?.following && item.post.author.viewer?.following
|
parent?.author.viewer?.following &&
|
||||||
|
feedPost.post.author.viewer?.following
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
export class FeedTuner {
|
||||||
seenKeys: Set<string> = new Set()
|
seenKeys: Set<string> = new Set()
|
||||||
seenUris: Set<string> = new Set()
|
seenUris: Set<string> = new Set()
|
||||||
@@ -165,7 +170,7 @@ export class FeedTuner {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (maintainOrder) {
|
if (maintainOrder) {
|
||||||
slices = feed.map(item => new FeedViewPostsSlice([item]))
|
slices = feed.map(item => new FeedViewPostsSlice(item))
|
||||||
} else {
|
} else {
|
||||||
// arrange the posts into thread slices
|
// arrange the posts into thread slices
|
||||||
for (let i = feed.length - 1; i >= 0; i--) {
|
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
|
// turn non-threads with reply parents into threads
|
||||||
for (const slice of slices) {
|
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
|
const reply = slice.items[0].reply
|
||||||
if (
|
if (
|
||||||
AppBskyFeedDefs.isPostView(reply.parent) &&
|
AppBskyFeedDefs.isPostView(reply.parent) &&
|
||||||
@@ -256,8 +261,7 @@ export class FeedTuner {
|
|||||||
|
|
||||||
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeReposts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = slices.length - 1; i >= 0; i--) {
|
||||||
const reason = slices[i].rootItem.reason
|
if (slices[i].isRepost) {
|
||||||
if (AppBskyFeedDefs.isReasonRepost(reason)) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,11 +270,7 @@ export class FeedTuner {
|
|||||||
|
|
||||||
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
static removeQuotePosts(tuner: FeedTuner, slices: FeedViewPostsSlice[]) {
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = slices.length - 1; i >= 0; i--) {
|
||||||
const embed = slices[i].rootItem.post.embed
|
if (slices[i].isQuotePost) {
|
||||||
if (
|
|
||||||
AppBskyEmbedRecord.isView(embed) ||
|
|
||||||
AppBskyEmbedRecordWithMedia.isView(embed)
|
|
||||||
) {
|
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,21 +315,20 @@ export class FeedTuner {
|
|||||||
// remove any replies without at least minLikes likes
|
// remove any replies without at least minLikes likes
|
||||||
for (let i = slices.length - 1; i >= 0; i--) {
|
for (let i = slices.length - 1; i >= 0; i--) {
|
||||||
const slice = slices[i]
|
const slice = slices[i]
|
||||||
if (slice.isFullThread || !slice.isReply) {
|
if (slice.isReply) {
|
||||||
|
if (slice.isThread && slice.includesThreadRoot) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (slice.isRepost) {
|
||||||
const item = slice.rootItem
|
|
||||||
const isRepost = Boolean(item.reason)
|
|
||||||
if (isRepost) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ((item.post.likeCount || 0) < minLikes) {
|
if (slice.likeCount < minLikes) {
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
} else if (followedOnly && !slice.isFollowingAllAuthors(userDid)) {
|
||||||
slices.splice(i, 1)
|
slices.splice(i, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return slices
|
return slices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ class MergeFeedSource_Following extends MergeFeedSource {
|
|||||||
dryRun: false,
|
dryRun: false,
|
||||||
maintainOrder: true,
|
maintainOrder: true,
|
||||||
})
|
})
|
||||||
res.data.feed = slices.map(slice => slice.rootItem)
|
res.data.feed = slices.map(slice => slice._feedPost)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ interface PostOpts {
|
|||||||
uri: string
|
uri: string
|
||||||
cid: string
|
cid: string
|
||||||
}
|
}
|
||||||
|
video?: {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
}
|
||||||
extLink?: ExternalEmbedDraft
|
extLink?: ExternalEmbedDraft
|
||||||
images?: ImageModel[]
|
images?: ImageModel[]
|
||||||
labels?: string[]
|
labels?: string[]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {timeout} from './timeout'
|
import {timeout} from './timeout'
|
||||||
|
|
||||||
export async function until(
|
export async function until<T>(
|
||||||
retries: number,
|
retries: number,
|
||||||
delay: number,
|
delay: number,
|
||||||
cond: (v: any, err: any) => boolean,
|
cond: (v: T, err: any) => boolean,
|
||||||
fn: () => Promise<any>,
|
fn: () => Promise<T>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
while (retries > 0) {
|
while (retries > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -13,7 +13,9 @@ export async function until(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} 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
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import React from 'react'
|
|||||||
export const useDedupe = () => {
|
export const useDedupe = () => {
|
||||||
const canDo = React.useRef(true)
|
const canDo = React.useRef(true)
|
||||||
|
|
||||||
return React.useRef((cb: () => unknown) => {
|
return React.useCallback((cb: () => unknown) => {
|
||||||
if (canDo.current) {
|
if (canDo.current) {
|
||||||
canDo.current = false
|
canDo.current = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -13,5 +13,5 @@ export const useDedupe = () => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}).current
|
}, [])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -42,14 +42,13 @@ export type CommonNavigatorParams = {
|
|||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesConversation: {conversation: string; embed?: string}
|
MessagesConversation: {conversation: string; embed?: string}
|
||||||
MessagesSettings: undefined
|
MessagesSettings: undefined
|
||||||
|
NotificationsSettings: undefined
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Start: {name: string; rkey: string}
|
Start: {name: string; rkey: string}
|
||||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
StarterPackShort: {code: string}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {
|
StarterPackEdit: {rkey?: string}
|
||||||
rkey?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
export type BottomTabNavigatorParams = CommonNavigatorParams & {
|
||||||
@@ -69,7 +68,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
|
||||||
@@ -84,7 +83,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
|
|||||||
Home: undefined
|
Home: undefined
|
||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
|
||||||
}
|
}
|
||||||
@@ -96,7 +95,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
|||||||
Search: {q?: string}
|
Search: {q?: string}
|
||||||
Feeds: undefined
|
Feeds: undefined
|
||||||
NotificationsTab: undefined
|
NotificationsTab: undefined
|
||||||
Notifications: undefined
|
Notifications: {show?: 'all'}
|
||||||
MyProfileTab: undefined
|
MyProfileTab: undefined
|
||||||
Hashtag: {tag: string; author?: string}
|
Hashtag: {tag: string; author?: string}
|
||||||
MessagesTab: undefined
|
MessagesTab: undefined
|
||||||
@@ -105,9 +104,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
|
|||||||
StarterPack: {name: string; rkey: string; new?: boolean}
|
StarterPack: {name: string; rkey: string; new?: boolean}
|
||||||
StarterPackShort: {code: string}
|
StarterPackShort: {code: string}
|
||||||
StarterPackWizard: undefined
|
StarterPackWizard: undefined
|
||||||
StarterPackEdit: {
|
StarterPackEdit: {rkey?: string}
|
||||||
rkey?: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// NOTE
|
||||||
|
|||||||
+634
-548
File diff suppressed because it is too large
Load Diff
+923
-1235
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+635
-553
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+634
-548
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+628
-554
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+635
-545
File diff suppressed because it is too large
Load Diff
+635
-545
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
@@ -5,6 +5,7 @@ export const router = new Router({
|
|||||||
Search: '/search',
|
Search: '/search',
|
||||||
Feeds: '/feeds',
|
Feeds: '/feeds',
|
||||||
Notifications: '/notifications',
|
Notifications: '/notifications',
|
||||||
|
NotificationsSettings: '/notifications/settings',
|
||||||
Settings: '/settings',
|
Settings: '/settings',
|
||||||
LanguageSettings: '/settings/language',
|
LanguageSettings: '/settings/language',
|
||||||
Lists: '/lists',
|
Lists: '/lists',
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ function Inner() {
|
|||||||
title={_(msg`Something went wrong`)}
|
title={_(msg`Something went wrong`)}
|
||||||
message={_(msg`We couldn't load this conversation`)}
|
message={_(msg`We couldn't load this conversation`)}
|
||||||
onRetry={() => convoState.error.retry()}
|
onRetry={() => convoState.error.retry()}
|
||||||
|
sideBorders={false}
|
||||||
/>
|
/>
|
||||||
</CenteredView>
|
</CenteredView>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -309,7 +309,7 @@ function DesktopHeader({
|
|||||||
a.gap_lg,
|
a.gap_lg,
|
||||||
a.px_lg,
|
a.px_lg,
|
||||||
a.pr_md,
|
a.pr_md,
|
||||||
a.py_md,
|
a.py_sm,
|
||||||
a.border_b,
|
a.border_b,
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
]}>
|
]}>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {useProfileQuery} from '#/state/queries/profile'
|
|||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import * as Toast from '#/view/com/util/Toast'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {ViewHeader} from '#/view/com/util/ViewHeader'
|
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 {atoms as a, useTheme} from '#/alf'
|
||||||
import {Divider} from '#/components/Divider'
|
import {Divider} from '#/components/Divider'
|
||||||
import * as Toggle from '#/components/forms/Toggle'
|
import * as Toggle from '#/components/forms/Toggle'
|
||||||
@@ -55,7 +55,7 @@ export function MessagesSettingsScreen({}: Props) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenteredView sideBorders style={a.h_full_vh}>
|
<ScrollView stickyHeaderIndices={[0]}>
|
||||||
<ViewHeader title={_(msg`Chat Settings`)} showOnDesktop showBorder />
|
<ViewHeader title={_(msg`Chat Settings`)} showOnDesktop showBorder />
|
||||||
<View style={[a.p_lg, a.gap_md]}>
|
<View style={[a.p_lg, a.gap_md]}>
|
||||||
<Text style={[a.text_lg, a.font_bold]}>
|
<Text style={[a.text_lg, a.font_bold]}>
|
||||||
@@ -107,7 +107,7 @@ export function MessagesSettingsScreen({}: Props) {
|
|||||||
a.rounded_md,
|
a.rounded_md,
|
||||||
t.atoms.bg_contrast_25,
|
t.atoms.bg_contrast_25,
|
||||||
]}>
|
]}>
|
||||||
<Text style={[t.atoms.text_contrast_high]}>
|
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
You can continue ongoing conversations regardless of which setting
|
You can continue ongoing conversations regardless of which setting
|
||||||
you choose.
|
you choose.
|
||||||
@@ -149,6 +149,6 @@ export function MessagesSettingsScreen({}: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</CenteredView>
|
</ScrollView>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="moderationlistsBtn" to="/moderation/modlists">
|
<Link
|
||||||
|
label={_(msg`View your moderation lists`)}
|
||||||
|
testID="moderationlistsBtn"
|
||||||
|
to="/moderation/modlists">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Moderation lists`)}
|
title={_(msg`Moderation lists`)}
|
||||||
@@ -252,7 +255,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
|
<Link
|
||||||
|
label={_(msg`View your muted accounts`)}
|
||||||
|
testID="mutedAccountsBtn"
|
||||||
|
to="/moderation/muted-accounts">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Muted accounts`)}
|
title={_(msg`Muted accounts`)}
|
||||||
@@ -264,7 +270,10 @@ export function ModerationScreenInner({
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
|
<Link
|
||||||
|
label={_(msg`View your blocked accounts`)}
|
||||||
|
testID="blockedAccountsBtn"
|
||||||
|
to="/moderation/blocked-accounts">
|
||||||
{state => (
|
{state => (
|
||||||
<SubItem
|
<SubItem
|
||||||
title={_(msg`Blocked accounts`)}
|
title={_(msg`Blocked accounts`)}
|
||||||
@@ -356,6 +365,7 @@ export function ModerationScreenInner({
|
|||||||
<Trans>
|
<Trans>
|
||||||
Adult content can only be enabled via the Web at{' '}
|
Adult content can only be enabled via the Web at{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={_(msg`The Bluesky web application`)}
|
||||||
to=""
|
to=""
|
||||||
onPress={evt => {
|
onPress={evt => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
@@ -569,7 +579,9 @@ function PwiOptOut() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</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>
|
<Trans>Learn more about what is public on Bluesky.</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -45,14 +45,20 @@ export const Policies = ({
|
|||||||
const els = []
|
const els = []
|
||||||
if (tos) {
|
if (tos) {
|
||||||
els.push(
|
els.push(
|
||||||
<InlineLinkText key="tos" to={tos}>
|
<InlineLinkText
|
||||||
|
label={_(msg`Read the Bluesky Terms of Service`)}
|
||||||
|
key="tos"
|
||||||
|
to={tos}>
|
||||||
{_(msg`Terms of Service`)}
|
{_(msg`Terms of Service`)}
|
||||||
</InlineLinkText>,
|
</InlineLinkText>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (pp) {
|
if (pp) {
|
||||||
els.push(
|
els.push(
|
||||||
<InlineLinkText key="pp" to={pp}>
|
<InlineLinkText
|
||||||
|
label={_(msg`Read the Bluesky Privacy Policy`)}
|
||||||
|
key="pp"
|
||||||
|
to={pp}>
|
||||||
{_(msg`Privacy Policy`)}
|
{_(msg`Privacy Policy`)}
|
||||||
</InlineLinkText>,
|
</InlineLinkText>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
|
|||||||
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
|
<Text style={[t.atoms.text, !gtMobile && a.text_md]}>
|
||||||
<Trans>Having trouble?</Trans>{' '}
|
<Trans>Having trouble?</Trans>{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={_(msg`Contact support`)}
|
||||||
to={FEEDBACK_FORM_URL({email: state.email})}
|
to={FEEDBACK_FORM_URL({email: state.email})}
|
||||||
style={[!gtMobile && a.text_md]}>
|
style={[!gtMobile && a.text_md]}>
|
||||||
<Trans>Contact support</Trans>
|
<Trans>Contact support</Trans>
|
||||||
|
|||||||
@@ -46,11 +46,14 @@ const PAGE_SIZE = 30
|
|||||||
type RQPageParam = string | undefined
|
type RQPageParam = string | undefined
|
||||||
|
|
||||||
const RQKEY_ROOT = 'notification-feed'
|
const RQKEY_ROOT = 'notification-feed'
|
||||||
export function RQKEY() {
|
export function RQKEY(priority?: false) {
|
||||||
return [RQKEY_ROOT]
|
return [RQKEY_ROOT, priority]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
export function useNotificationFeedQuery(opts?: {
|
||||||
|
enabled?: boolean
|
||||||
|
overridePriorityNotifications?: boolean
|
||||||
|
}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
@@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
|||||||
const lastPageCountRef = useRef(0)
|
const lastPageCountRef = useRef(0)
|
||||||
const gate = useGate()
|
const gate = useGate()
|
||||||
|
|
||||||
|
// false: force showing all notifications
|
||||||
|
// undefined: let the server decide
|
||||||
|
const priority = opts?.overridePriorityNotifications ? false : undefined
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
FeedPage,
|
FeedPage,
|
||||||
Error,
|
Error,
|
||||||
@@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
|||||||
RQPageParam
|
RQPageParam
|
||||||
>({
|
>({
|
||||||
staleTime: STALE.INFINITY,
|
staleTime: STALE.INFINITY,
|
||||||
queryKey: RQKEY(),
|
queryKey: RQKEY(priority),
|
||||||
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
||||||
let page
|
let page
|
||||||
if (!pageParam) {
|
if (!pageParam) {
|
||||||
@@ -75,8 +82,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
|||||||
page = unreads.getCachedUnreadPage()
|
page = unreads.getCachedUnreadPage()
|
||||||
}
|
}
|
||||||
if (!page) {
|
if (!page) {
|
||||||
page = (
|
const {page: fetchedPage} = await fetchPage({
|
||||||
await fetchPage({
|
|
||||||
agent,
|
agent,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
@@ -84,8 +90,9 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
|||||||
moderationOpts,
|
moderationOpts,
|
||||||
fetchAdditionalData: true,
|
fetchAdditionalData: true,
|
||||||
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
|
||||||
|
priority,
|
||||||
})
|
})
|
||||||
).page
|
page = fetchedPage
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the first page has an unread, mark all read
|
// 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,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ export interface FeedPage {
|
|||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
seenAt: Date
|
seenAt: Date
|
||||||
items: FeedNotification[]
|
items: FeedNotification[]
|
||||||
|
priority: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CachedFeedPage {
|
export interface CachedFeedPage {
|
||||||
|
|||||||
@@ -39,10 +39,15 @@ export async function fetchPage({
|
|||||||
moderationOpts: ModerationOpts | undefined
|
moderationOpts: ModerationOpts | undefined
|
||||||
fetchAdditionalData: boolean
|
fetchAdditionalData: boolean
|
||||||
shouldUngroupFollowBacks?: () => boolean
|
shouldUngroupFollowBacks?: () => boolean
|
||||||
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
|
priority?: boolean
|
||||||
|
}): Promise<{
|
||||||
|
page: FeedPage
|
||||||
|
indexedAt: string | undefined
|
||||||
|
}> {
|
||||||
const res = await agent.listNotifications({
|
const res = await agent.listNotifications({
|
||||||
limit,
|
limit,
|
||||||
cursor,
|
cursor,
|
||||||
|
// priority,
|
||||||
})
|
})
|
||||||
|
|
||||||
const indexedAt = res.data.notifications[0]?.indexedAt
|
const indexedAt = res.data.notifications[0]?.indexedAt
|
||||||
@@ -88,6 +93,7 @@ export async function fetchPage({
|
|||||||
cursor: res.data.cursor,
|
cursor: res.data.cursor,
|
||||||
seenAt,
|
seenAt,
|
||||||
items: notifsGrouped,
|
items: notifsGrouped,
|
||||||
|
priority: res.data.priority ?? false,
|
||||||
},
|
},
|
||||||
indexedAt,
|
indexedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {LikesFeedAPI} from 'lib/api/feed/likes'
|
|||||||
import {ListFeedAPI} from 'lib/api/feed/list'
|
import {ListFeedAPI} from 'lib/api/feed/list'
|
||||||
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
import {MergeFeedAPI} from 'lib/api/feed/merge'
|
||||||
import {FeedAPI, ReasonFeedSource} from 'lib/api/feed/types'
|
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 {BSKY_FEED_OWNER_DIDS} from 'lib/constants'
|
||||||
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
import {KnownError} from '#/view/com/posts/FeedErrorMessage'
|
||||||
import {useFeedTuners} from '../preferences/feed-tuners'
|
import {useFeedTuners} from '../preferences/feed-tuners'
|
||||||
@@ -61,7 +61,6 @@ export type FeedDescriptor =
|
|||||||
| `list|${ListUri}`
|
| `list|${ListUri}`
|
||||||
| `list|${ListUri}|${ListFilter}`
|
| `list|${ListUri}|${ListFilter}`
|
||||||
export interface FeedParams {
|
export interface FeedParams {
|
||||||
disableTuner?: boolean
|
|
||||||
mergeFeedEnabled?: boolean
|
mergeFeedEnabled?: boolean
|
||||||
mergeFeedSources?: string[]
|
mergeFeedSources?: string[]
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,10 @@ export interface FeedPostSliceItem {
|
|||||||
uri: string
|
uri: string
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
|
reason?:
|
||||||
|
| AppBskyFeedDefs.ReasonRepost
|
||||||
|
| ReasonFeedSource
|
||||||
|
| {[k: string]: unknown; $type: string}
|
||||||
feedContext: string | undefined
|
feedContext: string | undefined
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||||
@@ -102,7 +104,7 @@ export interface FeedPageUnselected {
|
|||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
api: FeedAPI
|
api: FeedAPI
|
||||||
tuner: FeedTuner | NoopFeedTuner
|
tuner: FeedTuner
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
slices: FeedPostSlice[]
|
slices: FeedPostSlice[]
|
||||||
fetchedAt: number
|
fetchedAt: number
|
||||||
@@ -139,18 +141,11 @@ export function usePostFeedQuery(
|
|||||||
const selectArgs = React.useMemo(
|
const selectArgs = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
feedTuners,
|
feedTuners,
|
||||||
disableTuner: params?.disableTuner,
|
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
ignoreFilterFor: opts?.ignoreFilterFor,
|
ignoreFilterFor: opts?.ignoreFilterFor,
|
||||||
isDiscover,
|
isDiscover,
|
||||||
}),
|
}),
|
||||||
[
|
[feedTuners, moderationOpts, opts?.ignoreFilterFor, isDiscover],
|
||||||
feedTuners,
|
|
||||||
params?.disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
opts?.ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
@@ -229,17 +224,10 @@ export function usePostFeedQuery(
|
|||||||
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
(data: InfiniteData<FeedPageUnselected, RQPageParam>) => {
|
||||||
// If the selection depends on some data, that data should
|
// If the selection depends on some data, that data should
|
||||||
// be included in the selectArgs object and read here.
|
// be included in the selectArgs object and read here.
|
||||||
const {
|
const {feedTuners, moderationOpts, ignoreFilterFor, isDiscover} =
|
||||||
feedTuners,
|
selectArgs
|
||||||
disableTuner,
|
|
||||||
moderationOpts,
|
|
||||||
ignoreFilterFor,
|
|
||||||
isDiscover,
|
|
||||||
} = selectArgs
|
|
||||||
|
|
||||||
const tuner = disableTuner
|
const tuner = new FeedTuner(feedTuners)
|
||||||
? new NoopFeedTuner()
|
|
||||||
: new FeedTuner(feedTuners)
|
|
||||||
|
|
||||||
// Keep track of the last run and whether we can reuse
|
// Keep track of the last run and whether we can reuse
|
||||||
// some already selected pages from there.
|
// some already selected pages from there.
|
||||||
@@ -311,7 +299,7 @@ export function usePostFeedQuery(
|
|||||||
if (isDiscover) {
|
if (isDiscover) {
|
||||||
userActionHistory.seen(
|
userActionHistory.seen(
|
||||||
slice.items.map(item => ({
|
slice.items.map(item => ({
|
||||||
feedContext: item.feedContext,
|
feedContext: slice.feedContext,
|
||||||
likeCount: item.post.likeCount ?? 0,
|
likeCount: item.post.likeCount ?? 0,
|
||||||
repostCount: item.post.repostCount ?? 0,
|
repostCount: item.post.repostCount ?? 0,
|
||||||
replyCount: item.post.replyCount ?? 0,
|
replyCount: item.post.replyCount ?? 0,
|
||||||
@@ -323,10 +311,10 @@ export function usePostFeedQuery(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const feedPostSlice: FeedPostSlice = {
|
||||||
_reactKey: slice._reactKey,
|
_reactKey: slice._reactKey,
|
||||||
_isFeedPostSlice: true,
|
_isFeedPostSlice: true,
|
||||||
rootUri: slice.rootItem.post.uri,
|
rootUri: slice.uri,
|
||||||
isThread:
|
isThread:
|
||||||
slice.items.length > 1 &&
|
slice.items.length > 1 &&
|
||||||
slice.items.every(
|
slice.items.every(
|
||||||
@@ -341,35 +329,42 @@ export function usePostFeedQuery(
|
|||||||
AppBskyFeedPost.validateRecord(item.post.record)
|
AppBskyFeedPost.validateRecord(item.post.record)
|
||||||
.success
|
.success
|
||||||
) {
|
) {
|
||||||
const parentAuthor =
|
const parent = item.reply?.parent
|
||||||
item.reply?.parent?.author ??
|
let parentAuthor:
|
||||||
|
| AppBskyActorDefs.ProfileViewBasic
|
||||||
|
| undefined
|
||||||
|
if (AppBskyFeedDefs.isPostView(parent)) {
|
||||||
|
parentAuthor = parent.author
|
||||||
|
}
|
||||||
|
if (!parentAuthor) {
|
||||||
|
parentAuthor =
|
||||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||||
|
}
|
||||||
const replyRef = item.reply
|
const replyRef = item.reply
|
||||||
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||||
replyRef?.parent,
|
replyRef?.parent,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
const feedPostSliceItem: FeedPostSliceItem = {
|
||||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||||
uri: item.post.uri,
|
uri: item.post.uri,
|
||||||
post: item.post,
|
post: item.post,
|
||||||
record: item.post.record,
|
record: item.post.record,
|
||||||
reason:
|
reason: slice.reason,
|
||||||
i === 0 && slice.source
|
feedContext: slice.feedContext,
|
||||||
? slice.source
|
|
||||||
: item.reason,
|
|
||||||
feedContext: item.feedContext || slice.feedContext,
|
|
||||||
moderation: moderations[i],
|
moderation: moderations[i],
|
||||||
parentAuthor,
|
parentAuthor,
|
||||||
isParentBlocked,
|
isParentBlocked,
|
||||||
}
|
}
|
||||||
|
return feedPostSliceItem
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
.filter(Boolean) as FeedPostSliceItem[],
|
.filter(n => !!n),
|
||||||
}
|
}
|
||||||
|
return feedPostSlice
|
||||||
})
|
})
|
||||||
.filter(Boolean) as FeedPostSlice[],
|
.filter(n => !!n),
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type ThreadgateSetting =
|
|||||||
| {type: 'nobody'}
|
| {type: 'nobody'}
|
||||||
| {type: 'mention'}
|
| {type: 'mention'}
|
||||||
| {type: 'following'}
|
| {type: 'following'}
|
||||||
| {type: 'list'; list: string}
|
| {type: 'list'; list: unknown}
|
||||||
|
|
||||||
export function threadgateViewToSettings(
|
export function threadgateViewToSettings(
|
||||||
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
|
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
|
||||||
@@ -21,18 +21,18 @@ export function threadgateViewToSettings(
|
|||||||
if (!record.allow?.length) {
|
if (!record.allow?.length) {
|
||||||
return [{type: 'nobody'}]
|
return [{type: 'nobody'}]
|
||||||
}
|
}
|
||||||
return record.allow
|
const settings: ThreadgateSetting[] = record.allow
|
||||||
.map(allow => {
|
.map(allow => {
|
||||||
|
let setting: ThreadgateSetting | undefined
|
||||||
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
|
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 setting
|
||||||
return {type: 'following'}
|
|
||||||
}
|
|
||||||
if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
|
|
||||||
return {type: 'list', list: allow.list}
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
})
|
||||||
.filter(Boolean) as ThreadgateSetting[]
|
.filter(n => !!n)
|
||||||
|
return settings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -132,6 +132,7 @@ export const SplashScreen = ({
|
|||||||
|
|
||||||
function Footer() {
|
function Footer() {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -147,13 +148,19 @@ function Footer() {
|
|||||||
a.flex_1,
|
a.flex_1,
|
||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
]}>
|
]}>
|
||||||
<InlineLinkText to="https://bsky.social">
|
<InlineLinkText
|
||||||
|
label={_(msg`Learn more about Bluesky`)}
|
||||||
|
to="https://bsky.social">
|
||||||
<Trans>Business</Trans>
|
<Trans>Business</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<InlineLinkText to="https://bsky.social/about/blog">
|
<InlineLinkText
|
||||||
|
label={_(msg`Read the Bluesky blog`)}
|
||||||
|
to="https://bsky.social/about/blog">
|
||||||
<Trans>Blog</Trans>
|
<Trans>Blog</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
<InlineLinkText to="https://bsky.social/about/join">
|
<InlineLinkText
|
||||||
|
label={_(msg`See jobs at Bluesky`)}
|
||||||
|
to="https://bsky.social/about/join">
|
||||||
<Trans>Jobs</Trans>
|
<Trans>Jobs</Trans>
|
||||||
</InlineLinkText>
|
</InlineLinkText>
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,16 @@ import {
|
|||||||
Keyboard,
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
LayoutChangeEvent,
|
LayoutChangeEvent,
|
||||||
|
StyleProp,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
View,
|
View,
|
||||||
|
ViewStyle,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
|
// @ts-expect-error no type definition
|
||||||
|
import ProgressCircle from 'react-native-progress/Circle'
|
||||||
import Animated, {
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeOut,
|
||||||
interpolateColor,
|
interpolateColor,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
@@ -55,6 +61,7 @@ import {
|
|||||||
import {useProfileQuery} from '#/state/queries/profile'
|
import {useProfileQuery} from '#/state/queries/profile'
|
||||||
import {Gif} from '#/state/queries/tenor'
|
import {Gif} from '#/state/queries/tenor'
|
||||||
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
import {ThreadgateSetting} from '#/state/queries/threadgate'
|
||||||
|
import {useUploadVideo} from '#/state/queries/video/video'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {useAnalytics} from 'lib/analytics/analytics'
|
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 {isAndroid, isIOS, isNative, isWeb} from 'platform/detection'
|
||||||
import {useDialogStateControlContext} from 'state/dialogs'
|
import {useDialogStateControlContext} from 'state/dialogs'
|
||||||
import {GalleryModel} from 'state/models/media/gallery'
|
import {GalleryModel} from 'state/models/media/gallery'
|
||||||
|
import {State as VideoUploadState} from 'state/queries/video/video'
|
||||||
import {ComposerOpts} from 'state/shell/composer'
|
import {ComposerOpts} from 'state/shell/composer'
|
||||||
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
@@ -96,10 +104,8 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
|
|||||||
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
|
||||||
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
import {useExternalLinkFetch} from './useExternalLinkFetch'
|
||||||
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
import {SelectVideoBtn} from './videos/SelectVideoBtn'
|
||||||
import {useVideoState} from './videos/state'
|
|
||||||
import {VideoPreview} from './videos/VideoPreview'
|
import {VideoPreview} from './videos/VideoPreview'
|
||||||
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
type CancelRef = {
|
type CancelRef = {
|
||||||
onPressCancel: () => void
|
onPressCancel: () => void
|
||||||
@@ -160,14 +166,21 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>(
|
||||||
initQuote,
|
initQuote,
|
||||||
)
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
video,
|
selectVideo,
|
||||||
onSelectVideo,
|
|
||||||
videoPending,
|
|
||||||
videoProcessingData,
|
|
||||||
clearVideo,
|
clearVideo,
|
||||||
videoProcessingProgress,
|
state: videoUploadState,
|
||||||
} = useVideoState({setError})
|
} = useUploadVideo({
|
||||||
|
setStatus: (status: string) => setProcessingState(status),
|
||||||
|
onSuccess: () => {
|
||||||
|
if (publishOnUpload) {
|
||||||
|
onPressPublish(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const [publishOnUpload, setPublishOnUpload] = useState(false)
|
||||||
|
|
||||||
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
|
||||||
const [extGif, setExtGif] = useState<Gif>()
|
const [extGif, setExtGif] = useState<Gif>()
|
||||||
const [labels, setLabels] = useState<string[]>([])
|
const [labels, setLabels] = useState<string[]>([])
|
||||||
@@ -275,7 +288,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return false
|
return false
|
||||||
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
|
||||||
|
|
||||||
const onPressPublish = async () => {
|
const onPressPublish = async (finishedUploading?: boolean) => {
|
||||||
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -284,6 +297,15 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!finishedUploading &&
|
||||||
|
videoUploadState.status !== 'idle' &&
|
||||||
|
videoUploadState.asset
|
||||||
|
) {
|
||||||
|
setPublishOnUpload(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -388,8 +410,12 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
: _(msg`What's up?`)
|
: _(msg`What's up?`)
|
||||||
|
|
||||||
const canSelectImages =
|
const canSelectImages =
|
||||||
gallery.size < 4 && !extLink && !video && !videoPending
|
gallery.size < 4 &&
|
||||||
const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video)
|
!extLink &&
|
||||||
|
videoUploadState.status === 'idle' &&
|
||||||
|
!videoUploadState.video
|
||||||
|
const hasMedia =
|
||||||
|
gallery.size > 0 || Boolean(extLink) || Boolean(videoUploadState.video)
|
||||||
|
|
||||||
const onEmojiButtonPress = useCallback(() => {
|
const onEmojiButtonPress = useCallback(() => {
|
||||||
openPicker?.(textInput.current?.getCursorPosition())
|
openPicker?.(textInput.current?.getCursorPosition())
|
||||||
@@ -501,7 +527,10 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
style={[a.rounded_full, a.py_sm]}
|
style={[a.rounded_full, a.py_sm]}
|
||||||
onPress={onPressPublish}>
|
onPress={() => onPressPublish()}
|
||||||
|
disabled={
|
||||||
|
videoUploadState.status !== 'idle' && publishOnUpload
|
||||||
|
}>
|
||||||
<ButtonText style={[a.text_md]}>
|
<ButtonText style={[a.text_md]}>
|
||||||
{replyTo ? (
|
{replyTo ? (
|
||||||
<Trans context="action">Reply</Trans>
|
<Trans context="action">Reply</Trans>
|
||||||
@@ -573,7 +602,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
autoFocus
|
autoFocus
|
||||||
setRichText={setRichText}
|
setRichText={setRichText}
|
||||||
onPhotoPasted={onPhotoPasted}
|
onPhotoPasted={onPhotoPasted}
|
||||||
onPressPublish={onPressPublish}
|
onPressPublish={() => onPressPublish()}
|
||||||
onNewLink={onNewLink}
|
onNewLink={onNewLink}
|
||||||
onError={setError}
|
onError={setError}
|
||||||
accessible={true}
|
accessible={true}
|
||||||
@@ -603,6 +632,7 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<View style={[a.mt_md]}>
|
||||||
{quote ? (
|
{quote ? (
|
||||||
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
<View style={[s.mt5, s.mb2, isWeb && s.mb10]}>
|
||||||
<View style={{pointerEvents: 'none'}}>
|
<View style={{pointerEvents: 'none'}}>
|
||||||
@@ -613,19 +643,22 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{videoPending && videoProcessingData ? (
|
{videoUploadState.status === 'compressing' &&
|
||||||
|
videoUploadState.asset ? (
|
||||||
<VideoTranscodeProgress
|
<VideoTranscodeProgress
|
||||||
input={videoProcessingData}
|
asset={videoUploadState.asset}
|
||||||
progress={videoProcessingProgress}
|
progress={videoUploadState.progress}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : videoUploadState.video ? (
|
||||||
video && (
|
|
||||||
// remove suspense when we get rid of lazy
|
// remove suspense when we get rid of lazy
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<VideoPreview video={video} clear={clearVideo} />
|
<VideoPreview
|
||||||
|
video={videoUploadState.video}
|
||||||
|
clear={clearVideo}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
) : null}
|
||||||
)}
|
</View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
<SuggestedLanguage text={richtext.text} />
|
<SuggestedLanguage text={richtext.text} />
|
||||||
|
|
||||||
@@ -642,11 +675,14 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
t.atoms.border_contrast_medium,
|
t.atoms.border_contrast_medium,
|
||||||
styles.bottomBar,
|
styles.bottomBar,
|
||||||
]}>
|
]}>
|
||||||
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
|
{videoUploadState.status !== 'idle' ? (
|
||||||
|
<VideoUploadToolbar state={videoUploadState} />
|
||||||
|
) : (
|
||||||
|
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
|
||||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||||
{gate('videos') && (
|
{gate('videos') && (
|
||||||
<SelectVideoBtn
|
<SelectVideoBtn
|
||||||
onSelectVideo={onSelectVideo}
|
onSelectVideo={selectVideo}
|
||||||
disabled={!canSelectImages}
|
disabled={!canSelectImages}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -668,7 +704,8 @@ export const ComposePost = observer(function ComposePost({
|
|||||||
<EmojiSmile size="lg" />
|
<EmojiSmile size="lg" />
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</ToolbarWrapper>
|
||||||
|
)}
|
||||||
<View style={a.flex_1} />
|
<View style={a.flex_1} />
|
||||||
<SelectLangBtn />
|
<SelectLangBtn />
|
||||||
<CharProgress count={graphemeLength} />
|
<CharProgress count={graphemeLength} />
|
||||||
@@ -763,7 +800,7 @@ function useAnimatedBorders() {
|
|||||||
|
|
||||||
const topBarAnimatedStyle = useAnimatedStyle(() => {
|
const topBarAnimatedStyle = useAnimatedStyle(() => {
|
||||||
return {
|
return {
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: interpolateColor(
|
borderColor: interpolateColor(
|
||||||
hasScrolledTop.value,
|
hasScrolledTop.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
@@ -773,7 +810,7 @@ function useAnimatedBorders() {
|
|||||||
})
|
})
|
||||||
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
|
const bottomBarAnimatedStyle = useAnimatedStyle(() => {
|
||||||
return {
|
return {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: interpolateColor(
|
borderColor: interpolateColor(
|
||||||
hasScrolledBottom.value,
|
hasScrolledBottom.value,
|
||||||
[0, 1],
|
[0, 1],
|
||||||
@@ -855,7 +892,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
},
|
},
|
||||||
errorIcon: {
|
errorIcon: {
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
borderColor: colors.red4,
|
borderColor: colors.red4,
|
||||||
color: colors.red4,
|
color: colors.red4,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
@@ -891,6 +928,47 @@ const styles = StyleSheet.create({
|
|||||||
paddingLeft: 7,
|
paddingLeft: 7,
|
||||||
paddingRight: 16,
|
paddingRight: 16,
|
||||||
alignItems: 'center',
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) {
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
@@ -49,7 +48,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
labelMobile: {
|
labelMobile: {
|
||||||
paddingLeft: 12,
|
paddingLeft: 12,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function VideoPreview({
|
|||||||
const player = useVideoPlayer(video.uri, player => {
|
const player = useVideoPlayer(video.uri, player => {
|
||||||
player.loop = true
|
player.loop = true
|
||||||
player.play()
|
player.play()
|
||||||
|
player.volume = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import {Text} from '#/components/Typography'
|
|||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
export function VideoTranscodeProgress({
|
export function VideoTranscodeProgress({
|
||||||
input,
|
asset,
|
||||||
progress,
|
progress,
|
||||||
}: {
|
}: {
|
||||||
input: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
progress: number
|
progress: number
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
|
||||||
const aspectRatio = input.width / input.height
|
const aspectRatio = asset.width / asset.height
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
@@ -29,7 +29,7 @@ export function VideoTranscodeProgress({
|
|||||||
a.overflow_hidden,
|
a.overflow_hidden,
|
||||||
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio},
|
||||||
]}>
|
]}>
|
||||||
<VideoTranscodeBackdrop uri={input.uri} />
|
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {shouldClickOpenNewTab} from '#/platform/urls'
|
||||||
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
|
||||||
import {
|
import {
|
||||||
useAddSavedFeedsMutation,
|
useAddSavedFeedsMutation,
|
||||||
@@ -32,8 +33,6 @@ import * as Prompt from '#/components/Prompt'
|
|||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
import {shouldClickOpenNewTab} from '#/platform/urls'
|
|
||||||
|
|
||||||
export function FeedSourceCard({
|
export function FeedSourceCard({
|
||||||
feedUri,
|
feedUri,
|
||||||
@@ -209,7 +208,7 @@ export function FeedSourceCardLoaded({
|
|||||||
styles.container,
|
styles.container,
|
||||||
pal.border,
|
pal.border,
|
||||||
style,
|
style,
|
||||||
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
|
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||||
]}
|
]}
|
||||||
onPress={e => {
|
onPress={e => {
|
||||||
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
const shouldOpenInNewTab = shouldClickOpenNewTab(e)
|
||||||
@@ -330,7 +329,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 14,
|
gap: 14,
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
headerContainer: {
|
headerContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function HomeHeaderLayoutMobile({
|
|||||||
]}>
|
]}>
|
||||||
{IS_DEV && (
|
{IS_DEV && (
|
||||||
<>
|
<>
|
||||||
<Link to="/sys/debug">
|
<Link label="View storybook" to="/sys/debug">
|
||||||
<ColorPalette size="md" />
|
<ColorPalette size="md" />
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {RichText as RichTextCom} from '#/components/RichText'
|
|||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export const ListCard = ({
|
export const ListCard = ({
|
||||||
testID,
|
testID,
|
||||||
@@ -134,7 +133,7 @@ export const ListCard = ({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
},
|
},
|
||||||
outerNoBorder: {
|
outerNoBorder: {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {Button} from '../util/forms/Button'
|
|||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import * as Toast from '../util/Toast'
|
import * as Toast from '../util/Toast'
|
||||||
import {UserAvatar} from '../util/UserAvatar'
|
import {UserAvatar} from '../util/UserAvatar'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export const snapPoints = ['fullscreen']
|
export const snapPoints = ['fullscreen']
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@ export function Component({
|
|||||||
return [pal.border, {height: screenHeight / 1.5}]
|
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])
|
}, [pal.border, screenHeight])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -188,7 +187,7 @@ function ListItem({
|
|||||||
style={[
|
style={[
|
||||||
styles.listItem,
|
styles.listItem,
|
||||||
pal.border,
|
pal.border,
|
||||||
index !== 0 && {borderTopWidth: hairlineWidth},
|
index !== 0 && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||||
]}>
|
]}>
|
||||||
<View style={styles.listItemAvi}>
|
<View style={styles.listItemAvi}>
|
||||||
<UserAvatar size={40} avatar={list.avatar} type="list" />
|
<UserAvatar size={40} avatar={list.avatar} type="list" />
|
||||||
@@ -248,7 +247,7 @@ const styles = StyleSheet.create({
|
|||||||
gap: 10,
|
gap: 10,
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingBottom: isAndroid ? 10 : 0,
|
paddingBottom: isAndroid ? 10 : 0,
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
footerBtn: {
|
footerBtn: {
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {NotificationFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceho
|
|||||||
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
import {LoadMoreRetryBtn} from '#/view/com/util/LoadMoreRetryBtn'
|
||||||
import {CenteredView} from '#/view/com/util/Views'
|
import {CenteredView} from '#/view/com/util/Views'
|
||||||
import {FeedItem} from './FeedItem'
|
import {FeedItem} from './FeedItem'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
const EMPTY_FEED_ITEM = {_reactKey: '__empty__'}
|
||||||
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
|
||||||
@@ -35,11 +34,13 @@ export function Feed({
|
|||||||
onPressTryAgain,
|
onPressTryAgain,
|
||||||
onScrolledDownChange,
|
onScrolledDownChange,
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
|
overridePriorityNotifications,
|
||||||
}: {
|
}: {
|
||||||
scrollElRef?: ListRef
|
scrollElRef?: ListRef
|
||||||
onPressTryAgain?: () => void
|
onPressTryAgain?: () => void
|
||||||
onScrolledDownChange: (isScrolledDown: boolean) => void
|
onScrolledDownChange: (isScrolledDown: boolean) => void
|
||||||
ListHeaderComponent?: () => JSX.Element
|
ListHeaderComponent?: () => JSX.Element
|
||||||
|
overridePriorityNotifications?: boolean
|
||||||
}) {
|
}) {
|
||||||
const initialNumToRender = useInitialNumToRender()
|
const initialNumToRender = useInitialNumToRender()
|
||||||
|
|
||||||
@@ -59,7 +60,10 @@ export function Feed({
|
|||||||
hasNextPage,
|
hasNextPage,
|
||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
} = useNotificationFeedQuery({enabled: !!moderationOpts})
|
} = useNotificationFeedQuery({
|
||||||
|
enabled: !!moderationOpts,
|
||||||
|
overridePriorityNotifications,
|
||||||
|
})
|
||||||
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
const isEmpty = !isFetching && !data?.pages[0]?.items.length
|
||||||
|
|
||||||
const items = React.useMemo(() => {
|
const items = React.useMemo(() => {
|
||||||
@@ -132,7 +136,7 @@ export function Feed({
|
|||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
pal.border,
|
||||||
!isTabletOrMobile && {borderTopWidth: hairlineWidth},
|
!isTabletOrMobile && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||||
]}>
|
]}>
|
||||||
<NotificationFeedLoadingPlaceholder />
|
<NotificationFeedLoadingPlaceholder />
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -13,27 +13,37 @@ import {
|
|||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
AppBskyGraphFollow,
|
||||||
moderateProfile,
|
moderateProfile,
|
||||||
ModerationDecision,
|
ModerationDecision,
|
||||||
ModerationOpts,
|
ModerationOpts,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '@atproto/api'
|
import {AtUri} from '@atproto/api'
|
||||||
|
import {TID} from '@atproto/common-web'
|
||||||
import {msg, plural, Trans} from '@lingui/macro'
|
import {msg, plural, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
import {useNavigation} from '@react-navigation/native'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useGate} from '#/lib/statsig/statsig'
|
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 {FeedNotification} from '#/state/queries/notifications/feed'
|
||||||
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
import {useAnimatedValue} from 'lib/hooks/useAnimatedValue'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {makeProfileLink} from 'lib/routes/links'
|
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 {sanitizeDisplayName} from 'lib/strings/display-names'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {niceDate} from 'lib/strings/time'
|
import {niceDate} from 'lib/strings/time'
|
||||||
import {colors, s} from 'lib/styles'
|
import {colors, s} from 'lib/styles'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import {DM_SERVICE_HEADERS} from 'state/queries/messages/const'
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
import {precacheProfile} from 'state/queries/profile'
|
||||||
|
import {useAgent} from 'state/session'
|
||||||
import {atoms as a, useTheme} from '#/alf'
|
import {atoms as a, useTheme} from '#/alf'
|
||||||
|
import {Button, ButtonText} from '#/components/Button'
|
||||||
import {
|
import {
|
||||||
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
|
||||||
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
|
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
|
||||||
@@ -41,8 +51,10 @@ import {
|
|||||||
import {Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled} from '#/components/icons/Heart2'
|
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 {PersonPlus_Filled_Stroke2_Corner0_Rounded as PersonPlusIcon} from '#/components/icons/Person'
|
||||||
import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
|
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 {Link as NewLink} from '#/components/Link'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
|
import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
|
||||||
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
import {FeedSourceCard} from '../feeds/FeedSourceCard'
|
||||||
import {Post} from '../post/Post'
|
import {Post} from '../post/Post'
|
||||||
import {ImageHorzList} from '../util/images/ImageHorzList'
|
import {ImageHorzList} from '../util/images/ImageHorzList'
|
||||||
@@ -52,19 +64,6 @@ import {Text} from '../util/text/Text'
|
|||||||
import {TimeElapsed} from '../util/TimeElapsed'
|
import {TimeElapsed} from '../util/TimeElapsed'
|
||||||
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
|
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 MAX_AUTHORS = 5
|
||||||
|
|
||||||
const EXPANDED_AUTHOR_EL_HEIGHT = 35
|
const EXPANDED_AUTHOR_EL_HEIGHT = 35
|
||||||
@@ -171,7 +170,6 @@ let FeedItem = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let isFollowBack = false
|
|
||||||
let action = ''
|
let action = ''
|
||||||
let icon = (
|
let icon = (
|
||||||
<HeartIconFilled
|
<HeartIconFilled
|
||||||
@@ -188,11 +186,28 @@ let FeedItem = ({
|
|||||||
action = _(msg`reposted your post`)
|
action = _(msg`reposted your post`)
|
||||||
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
|
||||||
} else if (item.type === 'follow') {
|
} else if (item.type === 'follow') {
|
||||||
|
let isFollowBack = false
|
||||||
|
|
||||||
if (
|
if (
|
||||||
item.notification.author.viewer?.following &&
|
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`)
|
action = _(msg`followed you back`)
|
||||||
} else {
|
} else {
|
||||||
action = _(msg`followed you`)
|
action = _(msg`followed you`)
|
||||||
@@ -211,7 +226,22 @@ let FeedItem = ({
|
|||||||
return null
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
testID={`feedItem-by-${item.notification.author.handle}`}
|
testID={`feedItem-by-${item.notification.author.handle}`}
|
||||||
@@ -224,10 +254,12 @@ let FeedItem = ({
|
|||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: pal.colors.unreadNotifBg,
|
||||||
borderColor: pal.colors.unreadNotifBorder,
|
borderColor: pal.colors.unreadNotifBorder,
|
||||||
},
|
},
|
||||||
{borderTopWidth: hideTopBorder ? 0 : hairlineWidth},
|
{borderTopWidth: hideTopBorder ? 0 : StyleSheet.hairlineWidth},
|
||||||
]}
|
]}
|
||||||
href={itemHref}
|
href={itemHref}
|
||||||
noFeedback
|
noFeedback
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel={a11yLabel}
|
||||||
accessible={!isAuthorsExpanded}
|
accessible={!isAuthorsExpanded}
|
||||||
accessibilityActions={
|
accessibilityActions={
|
||||||
authors.length > 1
|
authors.length > 1
|
||||||
@@ -272,19 +304,18 @@ let FeedItem = ({
|
|||||||
visible={!isAuthorsExpanded}
|
visible={!isAuthorsExpanded}
|
||||||
authors={authors}
|
authors={authors}
|
||||||
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
|
onToggleAuthorsExpanded={onToggleAuthorsExpanded}
|
||||||
showDmButton={item.type === 'starterpack-joined' || isFollowBack}
|
showDmButton={item.type === 'starterpack-joined'}
|
||||||
/>
|
/>
|
||||||
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
|
<ExpandedAuthorsList visible={isAuthorsExpanded} authors={authors} />
|
||||||
<Text style={[styles.meta, a.self_start]}>
|
<Text
|
||||||
|
style={[styles.meta, a.self_start]}
|
||||||
|
accessibilityHint=""
|
||||||
|
accessibilityLabel={a11yLabel}>
|
||||||
<TextLink
|
<TextLink
|
||||||
key={authors[0].href}
|
key={authors[0].href}
|
||||||
style={[pal.text, s.bold]}
|
style={[pal.text, s.bold]}
|
||||||
href={authors[0].href}
|
href={authors[0].href}
|
||||||
text={forceLTR(
|
text={forceLTR(firstAuthorName)}
|
||||||
sanitizeDisplayName(
|
|
||||||
authors[0].profile.displayName || authors[0].profile.handle,
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
disableMismatchWarning
|
disableMismatchWarning
|
||||||
/>
|
/>
|
||||||
{authors.length > 1 ? (
|
{authors.length > 1 ? (
|
||||||
@@ -306,7 +337,7 @@ let FeedItem = ({
|
|||||||
{({timeElapsed}) => (
|
{({timeElapsed}) => (
|
||||||
<Text
|
<Text
|
||||||
style={[pal.textLight, styles.pointer]}
|
style={[pal.textLight, styles.pointer]}
|
||||||
title={niceDate(item.notification.indexedAt)}>
|
title={niceTimestamp}>
|
||||||
{' ' + timeElapsed}
|
{' ' + timeElapsed}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -458,7 +489,6 @@ function CondensedAuthorsList({
|
|||||||
profile={authors[0].profile}
|
profile={authors[0].profile}
|
||||||
moderation={authors[0].moderation.ui('avatar')}
|
moderation={authors[0].moderation.ui('avatar')}
|
||||||
type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
|
type={authors[0].profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
accessible={false}
|
|
||||||
/>
|
/>
|
||||||
{showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
|
{showDmButton ? <SayHelloBtn profile={authors[0].profile} /> : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -476,7 +506,6 @@ function CondensedAuthorsList({
|
|||||||
profile={author.profile}
|
profile={author.profile}
|
||||||
moderation={author.moderation.ui('avatar')}
|
moderation={author.moderation.ui('avatar')}
|
||||||
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
|
type={author.profile.associated?.labeler ? 'labeler' : 'user'}
|
||||||
accessible={false}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
|||||||
import {PressableWithHover} from '../util/PressableWithHover'
|
import {PressableWithHover} from '../util/PressableWithHover'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {DraggableScrollView} from './DraggableScrollView'
|
import {DraggableScrollView} from './DraggableScrollView'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export interface TabBarProps {
|
export interface TabBarProps {
|
||||||
testID?: string
|
testID?: string
|
||||||
@@ -208,6 +207,6 @@ const mobileStyles = StyleSheet.create({
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: '100%',
|
top: '100%',
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ import {PostEmbeds} from '../util/post-embeds'
|
|||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export function PostThreadItem({
|
export function PostThreadItem({
|
||||||
post,
|
post,
|
||||||
@@ -604,7 +603,7 @@ function PostOuterWrapper({
|
|||||||
{
|
{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
paddingHorizontal: isMobile ? 10 : 6,
|
paddingHorizontal: isMobile ? 10 : 6,
|
||||||
borderTopWidth: depth === 1 ? hairlineWidth : 0,
|
borderTopWidth: depth === 1 ? StyleSheet.hairlineWidth : 0,
|
||||||
},
|
},
|
||||||
]}>
|
]}>
|
||||||
{Array.from(Array(depth - 1)).map((_, n: number) => (
|
{Array.from(Array(depth - 1)).map((_, n: number) => (
|
||||||
@@ -699,7 +698,7 @@ function getThreadAuthor(
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
paddingLeft: 8,
|
paddingLeft: 8,
|
||||||
},
|
},
|
||||||
outerHighlighted: {
|
outerHighlighted: {
|
||||||
@@ -709,7 +708,7 @@ const styles = StyleSheet.create({
|
|||||||
paddingRight: 8,
|
paddingRight: 8,
|
||||||
},
|
},
|
||||||
outerHighlightedRoot: {
|
outerHighlightedRoot: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
paddingTop: 16,
|
paddingTop: 16,
|
||||||
},
|
},
|
||||||
noTopBorder: {
|
noTopBorder: {
|
||||||
@@ -761,8 +760,8 @@ const styles = StyleSheet.create({
|
|||||||
expandedInfo: {
|
expandedInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
padding: 10,
|
padding: 10,
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
marginTop: 5,
|
marginTop: 5,
|
||||||
marginBottom: 10,
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import {PostMeta} from '../util/PostMeta'
|
|||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {UserInfoText} from '../util/UserInfoText'
|
import {UserInfoText} from '../util/UserInfoText'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export function Post({
|
export function Post({
|
||||||
post,
|
post,
|
||||||
@@ -155,7 +154,7 @@ function PostInner({
|
|||||||
style={[
|
style={[
|
||||||
styles.outer,
|
styles.outer,
|
||||||
pal.border,
|
pal.border,
|
||||||
!hideTopBorder && {borderTopWidth: hairlineWidth},
|
!hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth},
|
||||||
style,
|
style,
|
||||||
]}
|
]}
|
||||||
onBeforePress={onBeforePress}>
|
onBeforePress={onBeforePress}>
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ import {msg, Trans} from '@lingui/macro'
|
|||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {useGate} from '#/lib/statsig/statsig'
|
||||||
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
|
import {useSession} from '#/state/session'
|
||||||
import {useComposerControls} from '#/state/shell/composer'
|
import {useComposerControls} from '#/state/shell/composer'
|
||||||
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
|
||||||
import {MAX_POST_LINES} from 'lib/constants'
|
import {MAX_POST_LINES} from 'lib/constants'
|
||||||
@@ -29,6 +31,7 @@ import {countLines} from 'lib/strings/helpers'
|
|||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {precacheProfile} from 'state/queries/profile'
|
import {precacheProfile} from 'state/queries/profile'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||||
import {ContentHider} from '#/components/moderation/ContentHider'
|
import {ContentHider} from '#/components/moderation/ContentHider'
|
||||||
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
|
||||||
import {RichText} from '#/components/RichText'
|
import {RichText} from '#/components/RichText'
|
||||||
@@ -38,17 +41,19 @@ import {FeedNameText} from '../util/FeedInfoText'
|
|||||||
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link'
|
||||||
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
import {PostCtrls} from '../util/post-ctrls/PostCtrls'
|
||||||
import {PostEmbeds} from '../util/post-embeds'
|
import {PostEmbeds} from '../util/post-embeds'
|
||||||
|
import {VideoEmbed} from '../util/post-embeds/VideoEmbed'
|
||||||
import {PostMeta} from '../util/PostMeta'
|
import {PostMeta} from '../util/PostMeta'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {AviFollowButton} from './AviFollowButton'
|
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 {
|
interface FeedItemProps {
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
|
reason:
|
||||||
|
| AppBskyFeedDefs.ReasonRepost
|
||||||
|
| ReasonFeedSource
|
||||||
|
| {[k: string]: unknown; $type: string}
|
||||||
|
| undefined
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
parentAuthor: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
showReplyTo: boolean
|
showReplyTo: boolean
|
||||||
@@ -132,6 +137,8 @@ let FeedItemInner = ({
|
|||||||
const {openComposer} = useComposerControls()
|
const {openComposer} = useComposerControls()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
const gate = useGate()
|
||||||
|
|
||||||
const href = useMemo(() => {
|
const href = useMemo(() => {
|
||||||
const urip = new AtUri(post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(post.author, 'post', urip.rkey)
|
return makeProfileLink(post.author, 'post', urip.rkey)
|
||||||
@@ -197,7 +204,8 @@ let FeedItemInner = ({
|
|||||||
isThreadLastChild || (!isThreadChild && !isThreadParent)
|
isThreadLastChild || (!isThreadChild && !isThreadParent)
|
||||||
? 8
|
? 8
|
||||||
: undefined,
|
: undefined,
|
||||||
borderTopWidth: hideTopBorder || isThreadChild ? 0 : hairlineWidth,
|
borderTopWidth:
|
||||||
|
hideTopBorder || isThreadChild ? 0 : StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -337,7 +345,9 @@ let FeedItemInner = ({
|
|||||||
postHref={href}
|
postHref={href}
|
||||||
onOpenAuthor={onOpenAuthor}
|
onOpenAuthor={onOpenAuthor}
|
||||||
/>
|
/>
|
||||||
{!isThreadChild && showReplyTo && parentAuthor && (
|
{!isThreadChild &&
|
||||||
|
showReplyTo &&
|
||||||
|
(parentAuthor || isParentBlocked) && (
|
||||||
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||||
)}
|
)}
|
||||||
<LabelsOnMyPost post={post} />
|
<LabelsOnMyPost post={post} />
|
||||||
@@ -348,6 +358,9 @@ let FeedItemInner = ({
|
|||||||
postAuthor={post.author}
|
postAuthor={post.author}
|
||||||
onOpenEmbed={onOpenEmbed}
|
onOpenEmbed={onOpenEmbed}
|
||||||
/>
|
/>
|
||||||
|
{__DEV__ && gate('videos') && (
|
||||||
|
<VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" />
|
||||||
|
)}
|
||||||
<PostCtrls
|
<PostCtrls
|
||||||
post={post}
|
post={post}
|
||||||
record={record}
|
record={record}
|
||||||
@@ -431,30 +444,21 @@ function ReplyToLabel({
|
|||||||
profile,
|
profile,
|
||||||
blocked,
|
blocked,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic | undefined
|
||||||
blocked?: boolean
|
blocked?: boolean
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {currentAccount} = useSession()
|
const {currentAccount} = useSession()
|
||||||
const isMe = profile.did === currentAccount?.did
|
|
||||||
|
|
||||||
return (
|
let label
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
if (blocked) {
|
||||||
<FontAwesomeIcon
|
label = <Trans context="description">Reply to a blocked post</Trans>
|
||||||
icon="reply"
|
} else if (profile != null) {
|
||||||
size={9}
|
const isMe = profile.did === currentAccount?.did
|
||||||
style={[{color: pal.colors.textLight} as FontAwesomeIconStyle, s.mr5]}
|
if (isMe) {
|
||||||
/>
|
label = <Trans context="description">Reply to you</Trans>
|
||||||
<Text
|
} else {
|
||||||
type="md"
|
label = (
|
||||||
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">
|
<Trans context="description">
|
||||||
Reply to{' '}
|
Reply to{' '}
|
||||||
<ProfileHoverCard inline did={profile.did}>
|
<ProfileHoverCard inline did={profile.did}>
|
||||||
@@ -472,7 +476,28 @@ function ReplyToLabel({
|
|||||||
/>
|
/>
|
||||||
</ProfileHoverCard>
|
</ProfileHoverCard>
|
||||||
</Trans>
|
</Trans>
|
||||||
)}
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!label) {
|
||||||
|
// Should not happen.
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon="reply"
|
||||||
|
size={9}
|
||||||
|
style={[{color: pal.colors.textLight} as FontAwesomeIconStyle, s.mr5]}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="md"
|
||||||
|
style={[pal.textLight, s.mr2]}
|
||||||
|
lineHeight={1.2}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function FeedShutdownMsg({feedUri}: {feedUri: string}) {
|
|||||||
<Trans>
|
<Trans>
|
||||||
This feed is no longer online. We are showing{' '}
|
This feed is no longer online. We are showing{' '}
|
||||||
<InlineLinkText
|
<InlineLinkText
|
||||||
|
label={_(msg`The Discover feed`)}
|
||||||
to="/profile/bsky.app/feed/whats-hot"
|
to="/profile/bsky.app/feed/whats-hot"
|
||||||
style={[a.text_md]}>
|
style={[a.text_md]}>
|
||||||
Discover
|
Discover
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ import {
|
|||||||
KnownFollowers,
|
KnownFollowers,
|
||||||
shouldShowKnownFollowers,
|
shouldShowKnownFollowers,
|
||||||
} from '#/components/KnownFollowers'
|
} from '#/components/KnownFollowers'
|
||||||
|
import * as Pills from '#/components/Pills'
|
||||||
import {Link} from '../util/Link'
|
import {Link} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar} from '../util/UserAvatar'
|
||||||
import {FollowButton} from './FollowButton'
|
import {FollowButton} from './FollowButton'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
import * as Pills from '#/components/Pills'
|
|
||||||
|
|
||||||
export function ProfileCard({
|
export function ProfileCard({
|
||||||
testID,
|
testID,
|
||||||
@@ -217,7 +216,7 @@ export function ProfileCardWithFollowBtn({
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,15 +15,13 @@ import {makeProfileLink} from 'lib/routes/links'
|
|||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {sanitizeHandle} from 'lib/strings/handles'
|
import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {isNative} from 'platform/detection'
|
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 {TextLink} from '../util/Link'
|
||||||
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
|
import {UserAvatar, UserAvatarType} from '../util/UserAvatar'
|
||||||
import {CenteredView} from '../util/Views'
|
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({
|
export function ProfileSubpageHeader({
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -84,7 +82,7 @@ export function ProfileSubpageHeader({
|
|||||||
{
|
{
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
paddingTop: isNative ? 0 : 8,
|
paddingTop: isNative ? 0 : 8,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
paddingHorizontal: isMobile ? 12 : 14,
|
paddingHorizontal: isMobile ? 12 : 14,
|
||||||
|
|||||||
@@ -344,10 +344,11 @@ function ListImpl<ItemT>(
|
|||||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||||
/>
|
/>
|
||||||
{onStartReached && !isEmpty && (
|
{onStartReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onHeadVisibilityChange}
|
onVisibleChange={onHeadVisibilityChange}
|
||||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{headerComponent}
|
{headerComponent}
|
||||||
@@ -368,11 +369,11 @@ function ListImpl<ItemT>(
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{onEndReached && !isEmpty && (
|
{onEndReached && !isEmpty && (
|
||||||
<Visibility
|
<EdgeVisibility
|
||||||
root={disableFullWindowScroll ? nativeRef : null}
|
root={disableFullWindowScroll ? nativeRef : null}
|
||||||
onVisibleChange={onTailVisibilityChange}
|
onVisibleChange={onTailVisibilityChange}
|
||||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||||
key={data?.length}
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{footerComponent}
|
{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(
|
function useResizeObserver(
|
||||||
ref: React.RefObject<Element>,
|
ref: React.RefObject<Element>,
|
||||||
onResize: undefined | ((w: number, h: number) => void),
|
onResize: undefined | ((w: number, h: number) => void),
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
|
||||||
} from '#/components/icons/Heart2'
|
} from '#/components/icons/Heart2'
|
||||||
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export function LoadingPlaceholder({
|
export function LoadingPlaceholder({
|
||||||
width,
|
width,
|
||||||
@@ -236,7 +235,7 @@ export function FeedLoadingPlaceholder({
|
|||||||
{
|
{
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
paddingVertical: 18,
|
paddingVertical: 18,
|
||||||
borderTopWidth: showTopBorder ? hairlineWidth : 0,
|
borderTopWidth: showTopBorder ? StyleSheet.hairlineWidth : 0,
|
||||||
},
|
},
|
||||||
pal.border,
|
pal.border,
|
||||||
style,
|
style,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {atoms as a, useTheme} from '#/alf'
|
|||||||
import {Text} from '#/components/Typography'
|
import {Text} from '#/components/Typography'
|
||||||
import {IS_TEST} from '#/env'
|
import {IS_TEST} from '#/env'
|
||||||
|
|
||||||
const TIMEOUT = 3.7e3
|
const TIMEOUT = 2e3
|
||||||
|
|
||||||
export function show(
|
export function show(
|
||||||
message: string,
|
message: string,
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
|
|||||||
profile: AppBskyActorDefs.ProfileViewBasic
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
disableHoverCard?: boolean
|
disableHoverCard?: boolean
|
||||||
onBeforePress?: () => void
|
onBeforePress?: () => void
|
||||||
accessible?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const BLUR_AMOUNT = isWeb ? 5 : 100
|
const BLUR_AMOUNT = isWeb ? 5 : 100
|
||||||
@@ -412,7 +411,6 @@ let PreviewableUserAvatar = ({
|
|||||||
profile,
|
profile,
|
||||||
disableHoverCard,
|
disableHoverCard,
|
||||||
onBeforePress,
|
onBeforePress,
|
||||||
accessible = true,
|
|
||||||
...rest
|
...rest
|
||||||
}: PreviewableUserAvatarProps): React.ReactNode => {
|
}: PreviewableUserAvatarProps): React.ReactNode => {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
@@ -426,12 +424,8 @@ let PreviewableUserAvatar = ({
|
|||||||
return (
|
return (
|
||||||
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
<ProfileHoverCard did={profile.did} disable={disableHoverCard}>
|
||||||
<Link
|
<Link
|
||||||
label={
|
label={_(msg`${profile.displayName || profile.handle}'s avatar`)}
|
||||||
accessible
|
accessibilityHint={_(msg`Opens this profile`)}
|
||||||
? _(msg`${profile.displayName || profile.handle}'s avatar`)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
accessibilityHint={accessible ? _(msg`Opens this profile`) : undefined}
|
|
||||||
to={makeProfileLink({
|
to={makeProfileLink({
|
||||||
did: profile.did,
|
did: profile.did,
|
||||||
handle: profile.handle,
|
handle: profile.handle,
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import {usePalette} from 'lib/hooks/usePalette'
|
|||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {NavigationProp} from 'lib/routes/types'
|
import {NavigationProp} from 'lib/routes/types'
|
||||||
import {useTheme} from '#/alf'
|
import {useTheme} from '#/alf'
|
||||||
|
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
|
||||||
import {Text} from './text/Text'
|
import {Text} from './text/Text'
|
||||||
import {CenteredView} from './Views'
|
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}
|
const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
|
||||||
|
|
||||||
@@ -154,7 +153,7 @@ function DesktopWebHeader({
|
|||||||
styles.desktopHeader,
|
styles.desktopHeader,
|
||||||
pal.border,
|
pal.border,
|
||||||
{
|
{
|
||||||
borderBottomWidth: showBorder ? hairlineWidth : 0,
|
borderBottomWidth: showBorder ? StyleSheet.hairlineWidth : 0,
|
||||||
},
|
},
|
||||||
{display: 'flex', flexDirection: 'column'},
|
{display: 'flex', flexDirection: 'column'},
|
||||||
]}>
|
]}>
|
||||||
@@ -243,7 +242,7 @@ const styles = StyleSheet.create({
|
|||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
},
|
},
|
||||||
border: {
|
border: {
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import Animated from 'react-native-reanimated'
|
|||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
||||||
import {addStyle} from 'lib/styles'
|
import {addStyle} from 'lib/styles'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
interface AddedProps {
|
interface AddedProps {
|
||||||
desktopFixedHeight?: boolean | number
|
desktopFixedHeight?: boolean | number
|
||||||
@@ -50,8 +49,8 @@ export const CenteredView = React.forwardRef(function CenteredView(
|
|||||||
}
|
}
|
||||||
if (sideBorders) {
|
if (sideBorders) {
|
||||||
style = addStyle(style, {
|
style = addStyle(style, {
|
||||||
borderLeftWidth: hairlineWidth,
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
borderRightWidth: hairlineWidth,
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
})
|
})
|
||||||
style = addStyle(style, pal.border)
|
style = addStyle(style, pal.border)
|
||||||
}
|
}
|
||||||
@@ -163,8 +162,8 @@ export const ScrollView = React.forwardRef(function ScrollViewImpl(
|
|||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
borderLeftWidth: hairlineWidth,
|
borderLeftWidth: StyleSheet.hairlineWidth,
|
||||||
borderRightWidth: hairlineWidth,
|
borderRightWidth: StyleSheet.hairlineWidth,
|
||||||
// @ts-ignore web only
|
// @ts-ignore web only
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {clamp} from '#/lib/numbers'
|
|||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
const AnimatedTouchableOpacity =
|
const AnimatedTouchableOpacity =
|
||||||
Animated.createAnimatedComponent(TouchableOpacity)
|
Animated.createAnimatedComponent(TouchableOpacity)
|
||||||
@@ -74,7 +73,7 @@ const styles = StyleSheet.create({
|
|||||||
// @ts-ignore 'fixed' is web only -prf
|
// @ts-ignore 'fixed' is web only -prf
|
||||||
position: isWeb ? 'fixed' : 'absolute',
|
position: isWeb ? 'fixed' : 'absolute',
|
||||||
left: 18,
|
left: 18,
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
width: 52,
|
width: 52,
|
||||||
height: 52,
|
height: 52,
|
||||||
borderRadius: 26,
|
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],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,6 @@ import {Link} from '../Link'
|
|||||||
import {PostMeta} from '../PostMeta'
|
import {PostMeta} from '../PostMeta'
|
||||||
import {Text} from '../text/Text'
|
import {Text} from '../text/Text'
|
||||||
import {PostEmbeds} from '.'
|
import {PostEmbeds} from '.'
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
|
||||||
|
|
||||||
export function MaybeQuoteEmbed({
|
export function MaybeQuoteEmbed({
|
||||||
embed,
|
embed,
|
||||||
@@ -262,7 +261,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 12,
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -272,7 +271,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
paddingVertical: 14,
|
paddingVertical: 14,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderWidth: hairlineWidth,
|
borderWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
alert: {
|
alert: {
|
||||||
marginBottom: 6,
|
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
Reference in New Issue
Block a user