diff --git a/.eslintrc.js b/.eslintrc.js
index 541b3d61531..9d2b7bbb1ae 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -31,6 +31,7 @@ module.exports = {
},
},
],
+ 'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 5a182ef106d..d24fdfc601b 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
-yarn lint-staged
+npx lint-staged
diff --git a/__tests__/lib/string.test.ts b/__tests__/lib/string.test.ts
index 78478a26d4e..30072ccb1dd 100644
--- a/__tests__/lib/string.test.ts
+++ b/__tests__/lib/string.test.ts
@@ -6,7 +6,6 @@ import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
-import {ago} from '../../src/lib/strings/time'
import {
makeRecordUri,
toNiceDomain,
@@ -142,79 +141,6 @@ describe('makeRecordUri', () => {
})
})
-// FIXME: Reenable after fixing non-deterministic test.
-describe.skip('ago', () => {
- const oneYearDate = new Date(
- new Date().setMonth(new Date().getMonth() - 11),
- ).setDate(new Date().getDate() - 28)
-
- const inputs = [
- 1671461038,
- '04 Dec 1995 00:12:00 GMT',
- new Date(),
- new Date().setSeconds(new Date().getSeconds() - 10),
- new Date().setMinutes(new Date().getMinutes() - 10),
- new Date().setHours(new Date().getHours() - 1),
- new Date().setDate(new Date().getDate() - 1),
- new Date().setDate(new Date().getDate() - 20),
- new Date().setDate(new Date().getDate() - 25),
- new Date().setDate(new Date().getDate() - 28),
- new Date().setDate(new Date().getDate() - 29),
- new Date().setDate(new Date().getDate() - 30),
- new Date().setMonth(new Date().getMonth() - 1),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 20,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 25,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 28,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 1)).setDate(
- new Date().getDate() - 29,
- ),
- new Date().setMonth(new Date().getMonth() - 11),
- new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
- new Date().getDate() - 20,
- ),
- new Date(new Date().setMonth(new Date().getMonth() - 11)).setDate(
- new Date().getDate() - 25,
- ),
- oneYearDate,
- ]
- const outputs = [
- new Date(1671461038).toLocaleDateString(),
- new Date('04 Dec 1995 00:12:00 GMT').toLocaleDateString(),
- 'now',
- '10s',
- '10m',
- '1h',
- '1d',
- '20d',
- '25d',
- '28d',
- '29d',
- '1mo',
- '1mo',
- '1mo',
- '1mo',
- '2mo',
- '2mo',
- '11mo',
- '11mo',
- '11mo',
- new Date(oneYearDate).toLocaleDateString(),
- ]
-
- it('correctly calculates how much time passed, in a string', () => {
- for (let i = 0; i < inputs.length; i++) {
- const result = ago(inputs[i])
- expect(result).toEqual(outputs[i])
- }
- })
-})
-
describe('makeValidHandle', () => {
const inputs = [
'test-handle-123',
diff --git a/assets/icons/newskie.svg b/assets/icons/newskie.svg
new file mode 100644
index 00000000000..e3a9d83c803
--- /dev/null
+++ b/assets/icons/newskie.svg
@@ -0,0 +1 @@
+
diff --git a/eslint/index.js b/eslint/index.js
index bb31a942d1e..cf5d41225d2 100644
--- a/eslint/index.js
+++ b/eslint/index.js
@@ -3,6 +3,7 @@
module.exports = {
rules: {
'avoid-unwrapped-text': require('./avoid-unwrapped-text'),
+ 'use-exact-imports': require('./use-exact-imports'),
'use-typed-gates': require('./use-typed-gates'),
},
}
diff --git a/eslint/use-exact-imports.js b/eslint/use-exact-imports.js
new file mode 100644
index 00000000000..06723043fe9
--- /dev/null
+++ b/eslint/use-exact-imports.js
@@ -0,0 +1,22 @@
+/* eslint-disable bsky-internal/use-exact-imports */
+const BANNED_IMPORTS = [
+ '@fortawesome/free-regular-svg-icons',
+ '@fortawesome/free-solid-svg-icons',
+]
+
+exports.create = function create(context) {
+ return {
+ Literal(node) {
+ if (typeof node.value !== 'string') {
+ return
+ }
+ if (BANNED_IMPORTS.includes(node.value)) {
+ context.report({
+ node,
+ message:
+ 'Import the specific thing you want instead of the entire package',
+ })
+ }
+ },
+ }
+}
diff --git a/package.json b/package.json
index e08aa9d29c1..bcd5a1d37ed 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.12.18",
+ "@atproto/api": "^0.12.20",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -58,8 +58,9 @@
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
- "@formatjs/intl-locale": "^3.4.3",
- "@formatjs/intl-pluralrules": "^5.2.10",
+ "@formatjs/intl-locale": "^4.0.0",
+ "@formatjs/intl-numberformat": "^8.10.3",
+ "@formatjs/intl-pluralrules": "^5.2.14",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -271,6 +272,7 @@
"resolutions": {
"@types/react": "^18",
"**/zeed-dom": "0.10.9",
+ "**/zod": "3.23.8",
"**/expo-constants": "16.0.1",
"**/expo-device": "6.0.2",
"@react-native/babel-preset": "0.74.1"
diff --git a/patches/react-native+0.74.1.patch b/patches/react-native+0.74.1.patch
index 5d2900a7313..789ba84ace9 100644
--- a/patches/react-native+0.74.1.patch
+++ b/patches/react-native+0.74.1.patch
@@ -1,3 +1,29 @@
+diff --git a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+index b0d71dc..9974932 100644
+--- a/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
++++ b/node_modules/react-native/Libraries/Text/TextInput/RCTBaseTextInputView.mm
+@@ -377,10 +377,6 @@ - (void)textInputDidBeginEditing
+ self.backedTextInputView.attributedText = [NSAttributedString new];
+ }
+
+- if (_selectTextOnFocus) {
+- [self.backedTextInputView selectAll:nil];
+- }
+-
+ [_eventDispatcher sendTextEventWithType:RCTTextEventTypeFocus
+ reactTag:self.reactTag
+ text:[self.backedTextInputView.attributedText.string copy]
+@@ -611,6 +607,10 @@ - (UIView *)reactAccessibilityElement
+ - (void)reactFocus
+ {
+ [self.backedTextInputView reactFocus];
++
++ if (_selectTextOnFocus) {
++ [self.backedTextInputView selectAll:nil];
++ }
+ }
+
+ - (void)reactBlur
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -5,7 +31,7 @@ index e9b330f..1ecdf0a 100644
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
-
+
+- (void)forwarderBeginRefreshing;
+
@end
@@ -16,7 +42,7 @@ index b09e653..4c32b31 100644
@@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO;
-
+
+ if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@@ -29,7 +55,7 @@ index b09e653..4c32b31 100644
_onRefresh(nil);
}
}
-
+
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
@@ -38,15 +64,15 @@ index b09e653..4c32b31 100644
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
-+
++
+ [self sizeToFit];
-+
++
+ if (!self.scrollView) {
+ return;
+ }
-+
++
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
-+
++
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
@@ -58,7 +84,7 @@ index b09e653..4c32b31 100644
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
-+
++
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
@@ -73,7 +99,7 @@ index 5f5e1ab..aac00b6 100644
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/core/JavaTimerManager.java
@@ -99,8 +99,9 @@ public class JavaTimerManager {
}
-
+
// If the JS thread is busy for multiple frames we cancel any other pending runnable.
- if (mCurrentIdleCallbackRunnable != null) {
- mCurrentIdleCallbackRunnable.cancel();
@@ -81,5 +107,5 @@ index 5f5e1ab..aac00b6 100644
+ if (currentRunnable != null) {
+ currentRunnable.cancel();
}
-
+
mCurrentIdleCallbackRunnable = new IdleCallbackRunnable(frameTimeNanos);
diff --git a/patches/react-native+0.74.1.patch.md b/patches/react-native+0.74.1.patch.md
index 9c93aee5cb7..84953df2bd0 100644
--- a/patches/react-native+0.74.1.patch.md
+++ b/patches/react-native+0.74.1.patch.md
@@ -11,3 +11,10 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
module.
+
+
+## TextInput Patch - `selectTextOnFocus` fix
+
+Patching `RCTBaseTextInputView.m` to fix an issue where `selectTextOnFocus` does not work as expected on iOS 17. This
+patch _only_ fixes the Paper version of `TextInput`. If we migrate to Fabric and the fix has not been made upstream,
+we can apply the same fix. See https://github.com/facebook/react-native/pull/44307.
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 5b2071e102f..18461fdd055 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -14,36 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
+import {
+ initialize,
+ Provider as StatsigProvider,
+ tryFetchGates,
+} from '#/lib/statsig/statsig'
+import {s} from '#/lib/styles'
+import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {s} from 'lib/styles'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import {TestCtrls} from 'view/com/testing/TestCtrls'
-import * as Toast from 'view/com/util/Toast'
-import {Shell} from 'view/shell'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import * as Toast from '#/view/com/util/Toast'
+import {Shell} from '#/view/shell'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -69,6 +73,9 @@ function InnerApp() {
try {
if (account) {
await resumeSession(account)
+ } else {
+ await initialize()
+ await tryFetchGates(undefined, 'prefer-fresh-gates')
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -105,10 +112,12 @@ function InnerApp() {
-
-
-
-
+
+
+
+
+
+
@@ -147,21 +156,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 5c4dc4e6372..6af3c7d6fb4 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
+import {ThemeProvider} from '#/lib/ThemeContext'
import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
import {
Provider as SessionProvider,
SessionAccount,
useSession,
useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import * as Toast from 'view/com/util/Toast'
-import {ToastContainer} from 'view/com/util/Toast.web'
-import {Shell} from 'view/shell/index'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import * as Toast from '#/view/com/util/Toast'
+import {ToastContainer} from '#/view/com/util/Toast.web'
+import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +96,11 @@ function InnerApp() {
-
-
-
+
+
+
+
+
@@ -136,21 +138,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 67b89e26270..5d4ba0e3f7b 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
import {useUnreadNotifications} from './state/queries/notifications/unread'
import {useSession} from './state/session'
import {
- setEmailConfirmationRequested,
shouldRequestEmailConfirmation,
+ snoozeEmailConfirmationPrompt,
} from './state/shell/reminders'
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
@@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
openModal({name: 'verify-email', showReminder: true})
- setEmailConfirmationRequested()
+ snoozeEmailConfirmationPrompt()
}
}
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index 1ccb0460c49..1dc2dfa7b02 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -267,6 +267,9 @@ export const atoms = {
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
+ font_heavy: {
+ fontWeight: tokens.fontWeight.heavy,
+ },
italic: {
fontStyle: 'italic',
},
diff --git a/src/alf/tokens.ts b/src/alf/tokens.ts
index 1bddd95d43e..675844e296a 100644
--- a/src/alf/tokens.ts
+++ b/src/alf/tokens.ts
@@ -118,6 +118,7 @@ export const fontWeight = {
normal: '400',
semibold: '500',
bold: '600',
+ heavy: '700',
} as const
export const gradients = {
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index 982f4221348..deac450eea1 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -88,336 +88,355 @@ export function useButtonContext() {
return React.useContext(Context)
}
-export function Button({
- children,
- variant,
- color,
- size,
- shape = 'default',
- label,
- disabled = false,
- style,
- hoverStyle: hoverStyleProp,
- ...rest
-}: ButtonProps) {
- const t = useTheme()
- const [state, setState] = React.useState({
- pressed: false,
- hovered: false,
- focused: false,
- })
-
- const onPressIn = React.useCallback(() => {
- setState(s => ({
- ...s,
- pressed: true,
- }))
- }, [setState])
- const onPressOut = React.useCallback(() => {
- setState(s => ({
- ...s,
+export const Button = React.forwardRef(
+ (
+ {
+ children,
+ variant,
+ color,
+ size,
+ shape = 'default',
+ label,
+ disabled = false,
+ style,
+ hoverStyle: hoverStyleProp,
+ ...rest
+ },
+ ref,
+ ) => {
+ const t = useTheme()
+ const [state, setState] = React.useState({
pressed: false,
- }))
- }, [setState])
- const onHoverIn = React.useCallback(() => {
- setState(s => ({
- ...s,
- hovered: true,
- }))
- }, [setState])
- const onHoverOut = React.useCallback(() => {
- setState(s => ({
- ...s,
hovered: false,
- }))
- }, [setState])
- const onFocus = React.useCallback(() => {
- setState(s => ({
- ...s,
- focused: true,
- }))
- }, [setState])
- const onBlur = React.useCallback(() => {
- setState(s => ({
- ...s,
focused: false,
- }))
- }, [setState])
-
- const {baseStyles, hoverStyles} = React.useMemo(() => {
- const baseStyles: ViewStyle[] = []
- const hoverStyles: ViewStyle[] = []
- const light = t.name === 'light'
-
- if (color === 'primary') {
- if (variant === 'solid') {
- if (!disabled) {
- baseStyles.push({
- backgroundColor: t.palette.primary_500,
+ })
+
+ const onPressIn = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ pressed: true,
+ }))
+ }, [setState])
+ const onPressOut = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ pressed: false,
+ }))
+ }, [setState])
+ const onHoverIn = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ hovered: true,
+ }))
+ }, [setState])
+ const onHoverOut = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ hovered: false,
+ }))
+ }, [setState])
+ const onFocus = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ focused: true,
+ }))
+ }, [setState])
+ const onBlur = React.useCallback(() => {
+ setState(s => ({
+ ...s,
+ focused: false,
+ }))
+ }, [setState])
+
+ const {baseStyles, hoverStyles} = React.useMemo(() => {
+ const baseStyles: ViewStyle[] = []
+ const hoverStyles: ViewStyle[] = []
+ const light = t.name === 'light'
+
+ if (color === 'primary') {
+ if (variant === 'solid') {
+ if (!disabled) {
+ baseStyles.push({
+ backgroundColor: t.palette.primary_500,
+ })
+ hoverStyles.push({
+ backgroundColor: t.palette.primary_600,
+ })
+ } else {
+ baseStyles.push({
+ backgroundColor: t.palette.primary_700,
+ })
+ }
+ } else if (variant === 'outline') {
+ baseStyles.push(a.border, t.atoms.bg, {
+ borderWidth: 1,
})
- hoverStyles.push({
- backgroundColor: t.palette.primary_600,
- })
- } else {
- baseStyles.push({
- backgroundColor: t.palette.primary_700,
- })
- }
- } else if (variant === 'outline') {
- baseStyles.push(a.border, t.atoms.bg, {
- borderWidth: 1,
- })
- if (!disabled) {
- baseStyles.push(a.border, {
- borderColor: t.palette.primary_500,
- })
- hoverStyles.push(a.border, {
- backgroundColor: light
- ? t.palette.primary_50
- : t.palette.primary_950,
- })
- } else {
- baseStyles.push(a.border, {
- borderColor: light ? t.palette.primary_200 : t.palette.primary_900,
- })
+ if (!disabled) {
+ baseStyles.push(a.border, {
+ borderColor: t.palette.primary_500,
+ })
+ hoverStyles.push(a.border, {
+ backgroundColor: light
+ ? t.palette.primary_50
+ : t.palette.primary_950,
+ })
+ } else {
+ baseStyles.push(a.border, {
+ borderColor: light
+ ? t.palette.primary_200
+ : t.palette.primary_900,
+ })
+ }
+ } else if (variant === 'ghost') {
+ if (!disabled) {
+ baseStyles.push(t.atoms.bg)
+ hoverStyles.push({
+ backgroundColor: light
+ ? t.palette.primary_100
+ : t.palette.primary_900,
+ })
+ }
}
- } else if (variant === 'ghost') {
- if (!disabled) {
- baseStyles.push(t.atoms.bg)
- hoverStyles.push({
- backgroundColor: light
- ? t.palette.primary_100
- : t.palette.primary_900,
- })
- }
- }
- } else if (color === 'secondary') {
- if (variant === 'solid') {
- if (!disabled) {
- baseStyles.push({
- backgroundColor: t.palette.contrast_25,
- })
- hoverStyles.push({
- backgroundColor: t.palette.contrast_50,
- })
- } else {
- baseStyles.push({
- backgroundColor: t.palette.contrast_100,
+ } else if (color === 'secondary') {
+ if (variant === 'solid') {
+ if (!disabled) {
+ baseStyles.push({
+ backgroundColor: t.palette.contrast_25,
+ })
+ hoverStyles.push({
+ backgroundColor: t.palette.contrast_50,
+ })
+ } else {
+ baseStyles.push({
+ backgroundColor: t.palette.contrast_100,
+ })
+ }
+ } else if (variant === 'outline') {
+ baseStyles.push(a.border, t.atoms.bg, {
+ borderWidth: 1,
})
- }
- } else if (variant === 'outline') {
- baseStyles.push(a.border, t.atoms.bg, {
- borderWidth: 1,
- })
- if (!disabled) {
- baseStyles.push(a.border, {
- borderColor: t.palette.contrast_300,
- })
- hoverStyles.push(t.atoms.bg_contrast_50)
- } else {
- baseStyles.push(a.border, {
- borderColor: t.palette.contrast_200,
- })
+ if (!disabled) {
+ baseStyles.push(a.border, {
+ borderColor: t.palette.contrast_300,
+ })
+ hoverStyles.push(t.atoms.bg_contrast_50)
+ } else {
+ baseStyles.push(a.border, {
+ borderColor: t.palette.contrast_200,
+ })
+ }
+ } else if (variant === 'ghost') {
+ if (!disabled) {
+ baseStyles.push(t.atoms.bg)
+ hoverStyles.push({
+ backgroundColor: t.palette.contrast_100,
+ })
+ }
}
- } else if (variant === 'ghost') {
- if (!disabled) {
- baseStyles.push(t.atoms.bg)
- hoverStyles.push({
- backgroundColor: t.palette.contrast_100,
+ } else if (color === 'negative') {
+ if (variant === 'solid') {
+ if (!disabled) {
+ baseStyles.push({
+ backgroundColor: t.palette.negative_500,
+ })
+ hoverStyles.push({
+ backgroundColor: t.palette.negative_600,
+ })
+ } else {
+ baseStyles.push({
+ backgroundColor: t.palette.negative_700,
+ })
+ }
+ } else if (variant === 'outline') {
+ baseStyles.push(a.border, t.atoms.bg, {
+ borderWidth: 1,
})
+
+ if (!disabled) {
+ baseStyles.push(a.border, {
+ borderColor: t.palette.negative_500,
+ })
+ hoverStyles.push(a.border, {
+ backgroundColor: light
+ ? t.palette.negative_50
+ : t.palette.negative_975,
+ })
+ } else {
+ baseStyles.push(a.border, {
+ borderColor: light
+ ? t.palette.negative_200
+ : t.palette.negative_900,
+ })
+ }
+ } else if (variant === 'ghost') {
+ if (!disabled) {
+ baseStyles.push(t.atoms.bg)
+ hoverStyles.push({
+ backgroundColor: light
+ ? t.palette.negative_100
+ : t.palette.negative_975,
+ })
+ }
}
}
- } else if (color === 'negative') {
- if (variant === 'solid') {
- if (!disabled) {
- baseStyles.push({
- backgroundColor: t.palette.negative_500,
- })
- hoverStyles.push({
- backgroundColor: t.palette.negative_600,
- })
- } else {
- baseStyles.push({
- backgroundColor: t.palette.negative_700,
- })
- }
- } else if (variant === 'outline') {
- baseStyles.push(a.border, t.atoms.bg, {
- borderWidth: 1,
- })
- if (!disabled) {
- baseStyles.push(a.border, {
- borderColor: t.palette.negative_500,
- })
- hoverStyles.push(a.border, {
- backgroundColor: light
- ? t.palette.negative_50
- : t.palette.negative_975,
- })
- } else {
- baseStyles.push(a.border, {
- borderColor: light
- ? t.palette.negative_200
- : t.palette.negative_900,
- })
+ if (shape === 'default') {
+ if (size === 'large') {
+ baseStyles.push(
+ {paddingVertical: 15},
+ a.px_2xl,
+ a.rounded_sm,
+ a.gap_md,
+ )
+ } else if (size === 'medium') {
+ baseStyles.push(
+ {paddingVertical: 12},
+ a.px_2xl,
+ a.rounded_sm,
+ a.gap_md,
+ )
+ } else if (size === 'small') {
+ baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
+ } else if (size === 'xsmall') {
+ baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
+ } else if (size === 'tiny') {
+ baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
}
- } else if (variant === 'ghost') {
- if (!disabled) {
- baseStyles.push(t.atoms.bg)
- hoverStyles.push({
- backgroundColor: light
- ? t.palette.negative_100
- : t.palette.negative_975,
- })
+ } else if (shape === 'round' || shape === 'square') {
+ if (size === 'large') {
+ if (shape === 'round') {
+ baseStyles.push({height: 54, width: 54})
+ } else {
+ baseStyles.push({height: 50, width: 50})
+ }
+ } else if (size === 'small') {
+ baseStyles.push({height: 34, width: 34})
+ } else if (size === 'xsmall') {
+ baseStyles.push({height: 28, width: 28})
+ } else if (size === 'tiny') {
+ baseStyles.push({height: 20, width: 20})
}
- }
- }
- if (shape === 'default') {
- if (size === 'large') {
- baseStyles.push({paddingVertical: 15}, a.px_2xl, a.rounded_sm, a.gap_md)
- } else if (size === 'medium') {
- baseStyles.push({paddingVertical: 12}, a.px_2xl, a.rounded_sm, a.gap_md)
- } else if (size === 'small') {
- baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm)
- } else if (size === 'xsmall') {
- baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm)
- } else if (size === 'tiny') {
- baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs)
- }
- } else if (shape === 'round' || shape === 'square') {
- if (size === 'large') {
if (shape === 'round') {
- baseStyles.push({height: 54, width: 54})
- } else {
- baseStyles.push({height: 50, width: 50})
- }
- } else if (size === 'small') {
- baseStyles.push({height: 34, width: 34})
- } else if (size === 'xsmall') {
- baseStyles.push({height: 28, width: 28})
- } else if (size === 'tiny') {
- baseStyles.push({height: 20, width: 20})
- }
-
- if (shape === 'round') {
- baseStyles.push(a.rounded_full)
- } else if (shape === 'square') {
- if (size === 'tiny') {
- baseStyles.push(a.rounded_xs)
- } else {
- baseStyles.push(a.rounded_sm)
+ baseStyles.push(a.rounded_full)
+ } else if (shape === 'square') {
+ if (size === 'tiny') {
+ baseStyles.push(a.rounded_xs)
+ } else {
+ baseStyles.push(a.rounded_sm)
+ }
}
}
- }
-
- return {
- baseStyles,
- hoverStyles,
- }
- }, [t, variant, color, size, shape, disabled])
-
- const {gradientColors, gradientHoverColors, gradientLocations} =
- React.useMemo(() => {
- const colors: string[] = []
- const hoverColors: string[] = []
- const locations: number[] = []
- const gradient = {
- primary: tokens.gradients.sky,
- secondary: tokens.gradients.sky,
- negative: tokens.gradients.sky,
- gradient_sky: tokens.gradients.sky,
- gradient_midnight: tokens.gradients.midnight,
- gradient_sunrise: tokens.gradients.sunrise,
- gradient_sunset: tokens.gradients.sunset,
- gradient_nordic: tokens.gradients.nordic,
- gradient_bonfire: tokens.gradients.bonfire,
- }[color || 'primary']
-
- if (variant === 'gradient') {
- colors.push(...gradient.values.map(([_, color]) => color))
- hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
- locations.push(...gradient.values.map(([location, _]) => location))
- }
return {
- gradientColors: colors,
- gradientHoverColors: hoverColors,
- gradientLocations: locations,
+ baseStyles,
+ hoverStyles,
}
- }, [variant, color])
-
- const context = React.useMemo(
- () => ({
- ...state,
- variant,
- color,
- size,
- disabled: disabled || false,
- }),
- [state, variant, color, size, disabled],
- )
-
- const flattenedBaseStyles = flatten(baseStyles)
+ }, [t, variant, color, size, shape, disabled])
+
+ const {gradientColors, gradientHoverColors, gradientLocations} =
+ React.useMemo(() => {
+ const colors: string[] = []
+ const hoverColors: string[] = []
+ const locations: number[] = []
+ const gradient = {
+ primary: tokens.gradients.sky,
+ secondary: tokens.gradients.sky,
+ negative: tokens.gradients.sky,
+ gradient_sky: tokens.gradients.sky,
+ gradient_midnight: tokens.gradients.midnight,
+ gradient_sunrise: tokens.gradients.sunrise,
+ gradient_sunset: tokens.gradients.sunset,
+ gradient_nordic: tokens.gradients.nordic,
+ gradient_bonfire: tokens.gradients.bonfire,
+ }[color || 'primary']
+
+ if (variant === 'gradient') {
+ colors.push(...gradient.values.map(([_, color]) => color))
+ hoverColors.push(...gradient.values.map(_ => gradient.hover_value))
+ locations.push(...gradient.values.map(([location, _]) => location))
+ }
- return (
- (
+ () => ({
+ ...state,
+ variant,
+ color,
+ size,
disabled: disabled || false,
- }}
- style={[
- a.flex_row,
- a.align_center,
- a.justify_center,
- flattenedBaseStyles,
- flatten(style),
- ...(state.hovered || state.pressed
- ? [hoverStyles, flatten(hoverStyleProp)]
- : []),
- ]}
- onPressIn={onPressIn}
- onPressOut={onPressOut}
- onHoverIn={onHoverIn}
- onHoverOut={onHoverOut}
- onFocus={onFocus}
- onBlur={onBlur}>
- {variant === 'gradient' && (
-
-
-
- )}
-
- {typeof children === 'function' ? children(context) : children}
-
-
- )
-}
+ }),
+ [state, variant, color, size, disabled],
+ )
+
+ const flattenedBaseStyles = flatten(baseStyles)
+
+ return (
+
+ {variant === 'gradient' && (
+
+
+
+ )}
+
+ {typeof children === 'function' ? children(context) : children}
+
+
+ )
+ },
+)
+Button.displayName = 'Button'
export function useSharedButtonTextStyles() {
const t = useTheme()
diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx
index 2745ed7c9f5..94d97cb620e 100644
--- a/src/components/FeedCard.tsx
+++ b/src/components/FeedCard.tsx
@@ -11,6 +11,7 @@ import {
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
@@ -116,6 +117,12 @@ export function Likes({count}: {count: number}) {
}
export function Action({uri, pin}: {uri: string; pin?: boolean}) {
+ const {hasSession} = useSession()
+ if (!hasSession) return null
+ return
+}
+
+function ActionInner({uri, pin}: {uri: string; pin?: boolean}) {
const {_} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} =
diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx
index 63f61ce8563..7b861dc660e 100644
--- a/src/components/KnownFollowers.tsx
+++ b/src/components/KnownFollowers.tsx
@@ -100,7 +100,15 @@ function KnownFollowersInner({
moderation,
}
})
- const count = cachedKnownFollowers.count
+
+ // Does not have blocks applied. Always >= slices.length
+ const serverCount = cachedKnownFollowers.count
+
+ /*
+ * We check above too, but here for clarity and a reminder to _check for
+ * valid indices_
+ */
+ if (slice.length === 0) return null
return (
- {count > 2 ? (
-
- Followed by{' '}
-
- {slice[0].profile.displayName}
-
- ,{' '}
-
- {slice[1].profile.displayName}
-
- , and{' '}
-
-
- ) : count === 2 ? (
+ {slice.length >= 2 ? (
+ // 2-n followers, including blocks
+ serverCount > 2 ? (
+
+ Followed by{' '}
+
+ {slice[0].profile.displayName}
+
+ ,{' '}
+
+ {slice[1].profile.displayName}
+
+ , and{' '}
+
+
+ ) : (
+ // only 2
+
+ Followed by{' '}
+
+ {slice[0].profile.displayName}
+ {' '}
+ and{' '}
+
+ {slice[1].profile.displayName}
+
+
+ )
+ ) : serverCount > 1 ? (
+ // 1-n followers, including blocks
Followed by{' '}
{slice[0].profile.displayName}
{' '}
and{' '}
-
- {slice[1].profile.displayName}
-
+
) : (
+ // only 1
Followed by{' '}
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
new file mode 100644
index 00000000000..0354bfc432d
--- /dev/null
+++ b/src/components/NewskieDialog.tsx
@@ -0,0 +1,85 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {differenceInSeconds} from 'date-fns'
+
+import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {HITSLOP_10} from 'lib/constants'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {atoms as a} from '#/alf'
+import {Button} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useDialogControl} from '#/components/Dialog'
+import {Newskie} from '#/components/icons/Newskie'
+import {Text} from '#/components/Typography'
+
+export function NewskieDialog({
+ profile,
+ disabled,
+}: {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ disabled?: boolean
+}) {
+ const {_} = useLingui()
+ const moderationOpts = useModerationOpts()
+ const control = useDialogControl()
+ const profileName = React.useMemo(() => {
+ const name = profile.displayName || profile.handle
+ if (!moderationOpts) return name
+ const moderation = moderateProfile(profile, moderationOpts)
+ return sanitizeDisplayName(name, moderation.ui('displayName'))
+ }, [moderationOpts, profile])
+ const [now] = React.useState(() => Date.now())
+ const timeAgo = useGetTimeAgo()
+ const createdAt = profile.createdAt as string | undefined
+ const daysOld = React.useMemo(() => {
+ if (!createdAt) return Infinity
+ return differenceInSeconds(now, new Date(createdAt)) / 86400
+ }, [createdAt, now])
+
+ if (!createdAt || daysOld > 7) return null
+
+ return (
+
+
+
+
+
+
+
+
+ Say hello!
+
+
+
+ {profileName} joined Bluesky{' '}
+ {timeAgo(createdAt, now, {format: 'long'})} ago
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx
index e17977af43b..319eccfa4ad 100644
--- a/src/components/ProfileHoverCard/index.web.tsx
+++ b/src/components/ProfileHoverCard/index.web.tsx
@@ -51,10 +51,23 @@ const floatingMiddlewares = [
]
export function ProfileHoverCard(props: ProfileHoverCardProps) {
+ const prefetchProfileQuery = usePrefetchProfileQuery()
+ const prefetchedProfile = React.useRef(false)
+ const onPointerMove = () => {
+ if (!prefetchedProfile.current) {
+ prefetchedProfile.current = true
+ prefetchProfileQuery(props.did)
+ }
+ }
+
if (props.disable || isTouchDevice) {
return props.children
} else {
- return
+ return (
+
+
+
+ )
}
}
@@ -456,7 +469,7 @@ function Inner({
)}
-
+
diff --git a/src/components/forms/DateField/index.tsx b/src/components/forms/DateField/index.tsx
index e231ac5bafa..c916f4efce0 100644
--- a/src/components/forms/DateField/index.tsx
+++ b/src/components/forms/DateField/index.tsx
@@ -1,5 +1,5 @@
import React from 'react'
-import {View} from 'react-native'
+import {Keyboard, View} from 'react-native'
import DatePicker from 'react-native-date-picker'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@@ -49,7 +49,10 @@ export function DateField({
{
+ Keyboard.dismiss()
+ control.open()
+ }}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index 73a660ea6c4..f7a827b493b 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -196,6 +196,13 @@ export function createInput(Component: typeof TextInput) {
textAlignVertical: rest.multiline ? 'top' : undefined,
minHeight: rest.multiline ? 80 : undefined,
},
+ // fix for autofill styles covering border
+ web({
+ paddingTop: 12,
+ paddingBottom: 12,
+ marginTop: 2,
+ marginBottom: 2,
+ }),
android({
paddingBottom: 16,
}),
diff --git a/src/components/icons/Newskie.tsx b/src/components/icons/Newskie.tsx
new file mode 100644
index 00000000000..ddbb33201eb
--- /dev/null
+++ b/src/components/icons/Newskie.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Newskie = createSinglePathSVG({
+ path: 'M11.183 8.561c0 .544.348.984.892.984.545 0 .893-.44.893-.985V6.985c0-.544-.348-.985-.893-.985-.543 0-.892.44-.892.985v1.576Zm5.94 7.481c0 .539-.438.942-.976.942H8.004c-.538 0-.975-.411-.975-.95 0-2.782 2.264-5.021 5.046-5.021 2.783 0 5.047 2.247 5.047 5.03Zm-.43-4.584a.983.983 0 0 1 0-1.393l1.114-1.114a.985.985 0 0 1 1.393 1.393l-1.114 1.114a.985.985 0 0 1-1.393 0Zm2.897 3.741h1.575c.544 0 .985.349.985.892 0 .544-.44.892-.985.892h-1.67a.872.872 0 0 1-.89-.887c0-.543.44-.897.985-.897Zm-14.045.893c0-.544-.44-.892-.985-.892H2.985c-.544 0-.985.349-.985.892 0 .544.44.892.985.892H4.56c.545 0 .985-.349.985-.892Zm1.913-6.027a.985.985 0 0 1-1.393 1.393L4.95 10.344A.985.985 0 0 1 6.344 8.95l1.114 1.114Z',
+})
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index 0b48b51d1d6..ec7529a4fff 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -92,6 +92,8 @@ function PostLabel({
) : (
diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx
index 8a64742978f..b6fb1745289 100644
--- a/src/components/moderation/PostHider.tsx
+++ b/src/components/moderation/PostHider.tsx
@@ -1,6 +1,6 @@
import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
-import {AppBskyActorDefs, ModerationUI} from '@atproto/api'
+import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -45,7 +45,8 @@ export function PostHider({
const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl()
const blur =
- modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
+ modui.blurs[0] ||
+ (interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => {
@@ -134,6 +135,13 @@ export function PostHider({
)
}
+function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined {
+ // moderation causes get "downgraded" when they originate from embedded content
+ // a downgraded cause should *only* drive filtering in feeds, so we want to look
+ // for filters that arent downgraded
+ return modui.filters.find(filter => !filter.downgraded)
+}
+
const styles = StyleSheet.create({
child: {
borderWidth: 0,
diff --git a/src/lib/analytics/types.ts b/src/lib/analytics/types.ts
index cdf535dec26..720495ea1d2 100644
--- a/src/lib/analytics/types.ts
+++ b/src/lib/analytics/types.ts
@@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
'Post:ThreadMute': {} // CAN BE SERVER
'Post:ThreadUnmute': {} // CAN BE SERVER
'Post:Reply': {} // CAN BE SERVER
+ 'Post:EditThreadgateOpened': {}
+ 'Post:ThreadgateEdited': {}
// PROFILE events
'Profile:Follow': {
username: string
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index dfaae2e018b..5b1c998cb84 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
return res
}
-async function createThreadgate(
+export async function createThreadgate(
agent: BskyAgent,
postUri: string,
threadgate: ThreadgateSetting[],
@@ -296,10 +296,17 @@ async function createThreadgate(
}
const postUrip = new AtUri(postUri)
- await agent.api.app.bsky.feed.threadgate.create(
- {repo: agent.session!.did, rkey: postUrip.rkey},
- {post: postUri, createdAt: new Date().toISOString(), allow},
- )
+ await agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: postUrip.rkey,
+ record: {
+ $type: 'app.bsky.feed.threadgate',
+ post: postUri,
+ allow,
+ createdAt: new Date().toISOString(),
+ },
+ })
}
// helpers
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 05d1591f561..e0b89980078 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({
export const HITSLOP_10 = createHitslop(10)
export const HITSLOP_20 = createHitslop(20)
export const HITSLOP_30 = createHitslop(30)
+export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10}
export const BACK_HITSLOP = HITSLOP_30
export const MAX_POST_LINES = 25
diff --git a/src/lib/hooks/__tests__/useTimeAgo.test.ts b/src/lib/hooks/__tests__/useTimeAgo.test.ts
new file mode 100644
index 00000000000..e74f9c62db4
--- /dev/null
+++ b/src/lib/hooks/__tests__/useTimeAgo.test.ts
@@ -0,0 +1,102 @@
+import {describe, expect, it} from '@jest/globals'
+import {MessageDescriptor} from '@lingui/core'
+import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
+
+import {dateDiff} from '../useTimeAgo'
+
+const lingui: any = (obj: MessageDescriptor) => obj.message
+
+const base = new Date('2024-06-17T00:00:00Z')
+
+describe('dateDiff', () => {
+ it(`works with numbers`, () => {
+ expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d')
+ })
+ it(`works with strings`, () => {
+ expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d')
+ })
+ it(`works with dates`, () => {
+ expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d')
+ })
+
+ it(`equal values return now`, () => {
+ expect(dateDiff(base, base, {lingui})).toEqual('now')
+ })
+ it(`future dates return now`, () => {
+ expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now')
+ })
+
+ it(`values < 5 seconds ago return now`, () => {
+ const then = subSeconds(base, 4)
+ expect(dateDiff(then, base, {lingui})).toEqual('now')
+ })
+ it(`values >= 5 seconds ago return seconds`, () => {
+ const then = subSeconds(base, 5)
+ expect(dateDiff(then, base, {lingui})).toEqual('5s')
+ })
+
+ it(`values < 1 min return seconds`, () => {
+ const then = subSeconds(base, 59)
+ expect(dateDiff(then, base, {lingui})).toEqual('59s')
+ })
+ it(`values >= 1 min return minutes`, () => {
+ const then = subSeconds(base, 60)
+ expect(dateDiff(then, base, {lingui})).toEqual('1m')
+ })
+ it(`minutes round down`, () => {
+ const then = subSeconds(base, 119)
+ expect(dateDiff(then, base, {lingui})).toEqual('1m')
+ })
+
+ it(`values < 1 hour return minutes`, () => {
+ const then = subMinutes(base, 59)
+ expect(dateDiff(then, base, {lingui})).toEqual('59m')
+ })
+ it(`values >= 1 hour return hours`, () => {
+ const then = subMinutes(base, 60)
+ expect(dateDiff(then, base, {lingui})).toEqual('1h')
+ })
+ it(`hours round down`, () => {
+ const then = subMinutes(base, 119)
+ expect(dateDiff(then, base, {lingui})).toEqual('1h')
+ })
+
+ it(`values < 1 day return hours`, () => {
+ const then = subHours(base, 23)
+ expect(dateDiff(then, base, {lingui})).toEqual('23h')
+ })
+ it(`values >= 1 day return days`, () => {
+ const then = subHours(base, 24)
+ expect(dateDiff(then, base, {lingui})).toEqual('1d')
+ })
+ it(`days round down`, () => {
+ const then = subHours(base, 47)
+ expect(dateDiff(then, base, {lingui})).toEqual('1d')
+ })
+
+ it(`values < 30 days return days`, () => {
+ const then = subDays(base, 29)
+ expect(dateDiff(then, base, {lingui})).toEqual('29d')
+ })
+ it(`values >= 30 days return months`, () => {
+ const then = subDays(base, 30)
+ expect(dateDiff(then, base, {lingui})).toEqual('1mo')
+ })
+ it(`months round down`, () => {
+ const then = subDays(base, 59)
+ expect(dateDiff(then, base, {lingui})).toEqual('1mo')
+ })
+ it(`values are rounded by increments of 30`, () => {
+ const then = subDays(base, 61)
+ expect(dateDiff(then, base, {lingui})).toEqual('2mo')
+ })
+
+ it(`values < 360 days return months`, () => {
+ const then = subDays(base, 359)
+ expect(dateDiff(then, base, {lingui})).toEqual('11mo')
+ })
+ it(`values >= 360 days return the earlier value`, () => {
+ const then = subDays(base, 360)
+ expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString())
+ })
+})
diff --git a/src/lib/hooks/useTimeAgo.ts b/src/lib/hooks/useTimeAgo.ts
new file mode 100644
index 00000000000..efcb4754bb7
--- /dev/null
+++ b/src/lib/hooks/useTimeAgo.ts
@@ -0,0 +1,86 @@
+import {useCallback} from 'react'
+import {msg, plural} from '@lingui/macro'
+import {I18nContext, useLingui} from '@lingui/react'
+import {differenceInSeconds} from 'date-fns'
+
+export type TimeAgoOptions = {
+ lingui: I18nContext['_']
+ format?: 'long' | 'short'
+}
+
+export function useGetTimeAgo() {
+ const {_} = useLingui()
+ return useCallback(
+ (
+ earlier: number | string | Date,
+ later: number | string | Date,
+ options?: Omit,
+ ) => {
+ return dateDiff(earlier, later, {lingui: _, format: options?.format})
+ },
+ [_],
+ )
+}
+
+const NOW = 5
+const MINUTE = 60
+const HOUR = MINUTE * 60
+const DAY = HOUR * 24
+const MONTH_30 = DAY * 30
+
+/**
+ * Returns the difference between `earlier` and `later` dates, formatted as a
+ * natural language string.
+ *
+ * - All month are considered exactly 30 days.
+ * - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
+ * - Differences >= 360 days are returned as the "M/D/YYYY" string
+ * - All values round down
+ */
+export function dateDiff(
+ earlier: number | string | Date,
+ later: number | string | Date,
+ options: TimeAgoOptions,
+): string {
+ const _ = options.lingui
+ const format = options?.format || 'short'
+ const long = format === 'long'
+ const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
+
+ if (diffSeconds < NOW) {
+ return _(msg`now`)
+ } else if (diffSeconds < MINUTE) {
+ return `${diffSeconds}${
+ long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's'
+ }`
+ } else if (diffSeconds < HOUR) {
+ const diff = Math.floor(diffSeconds / MINUTE)
+ return `${diff}${
+ long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm'
+ }`
+ } else if (diffSeconds < DAY) {
+ const diff = Math.floor(diffSeconds / HOUR)
+ return `${diff}${
+ long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h'
+ }`
+ } else if (diffSeconds < MONTH_30) {
+ const diff = Math.floor(diffSeconds / DAY)
+ return `${diff}${
+ long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd'
+ }`
+ } else {
+ const diff = Math.floor(diffSeconds / MONTH_30)
+ if (diff < 12) {
+ return `${diff}${
+ long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo'
+ }`
+ } else {
+ const str = new Date(earlier).toLocaleDateString()
+
+ if (long) {
+ return _(msg`on ${str}`)
+ }
+ return str
+ }
+ }
+}
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 9939f60c9c3..0d77ec8a362 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -103,6 +103,8 @@ export type LogEvents = {
'post:unrepost': {
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
}
+ 'post:mute': {}
+ 'post:unmute': {}
'profile:follow': {
didBecomeMutual: boolean | undefined
followeeClout: number | undefined
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 4481935f779..6e460dc60f5 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -1,5 +1,6 @@
export type Gate =
// Keep this alphabetic please.
+ | 'native_pwi_disabled'
| 'request_notifications_permission_after_onboarding_v2'
| 'show_avi_follow_button'
| 'show_follow_back_label_v2'
diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx
index f6aed999f99..b5a239c3a54 100644
--- a/src/lib/statsig/statsig.tsx
+++ b/src/lib/statsig/statsig.tsx
@@ -14,6 +14,8 @@ import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {LogEvents} from './events'
import {Gate} from './gates'
+const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
+
type StatsigUser = {
userID: string | undefined
// TODO: Remove when enough users have custom.platform:
@@ -251,7 +253,7 @@ AppState.addEventListener('change', (state: AppStateStatus) => {
})
export async function tryFetchGates(
- did: string,
+ did: string | undefined,
strategy: 'prefer-low-latency' | 'prefer-fresh-gates',
) {
try {
@@ -275,6 +277,10 @@ export async function tryFetchGates(
}
}
+export function initialize() {
+ return Statsig.initialize(SDK_KEY, null, createStatsigOptions([]))
+}
+
export function Provider({children}: {children: React.ReactNode}) {
const {currentAccount, accounts} = useSession()
const did = currentAccount?.did
@@ -320,7 +326,7 @@ export function Provider({children}: {children: React.ReactNode}) {
= 0.9) {
- months = Math.ceil(months)
- } else {
- months = Math.floor(months)
- }
-
- if (months < 12) {
- return `${months}mo`
- } else {
- return new Date(ts).toLocaleDateString()
- }
- }
-}
-
export function niceDate(date: number | string | Date) {
const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
@@ -61,3 +19,14 @@ export function getAge(birthDate: Date): number {
}
return age
}
+
+/**
+ * Compares two dates by year, month, and day only
+ */
+export function simpleAreDatesEqual(a: Date, b: Date): boolean {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ )
+}
diff --git a/src/locale/i18n.ts b/src/locale/i18n.ts
index 9f75f83f3da..332b9309aa1 100644
--- a/src/locale/i18n.ts
+++ b/src/locale/i18n.ts
@@ -1,6 +1,10 @@
-import '@formatjs/intl-locale/polyfill'
-import '@formatjs/intl-pluralrules/polyfill'
+// Don't remove -force from these because detection is VERY slow on low-end Android.
+// https://github.com/formatjs/formatjs/issues/4463#issuecomment-2176070577
+import '@formatjs/intl-locale/polyfill-force'
+import '@formatjs/intl-pluralrules/polyfill-force'
+import '@formatjs/intl-numberformat/polyfill-force'
import '@formatjs/intl-pluralrules/locale-data/en'
+import '@formatjs/intl-numberformat/locale-data/en'
import {useEffect} from 'react'
import {i18n} from '@lingui/core'
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index 75e2f99fcd9..02b207303e2 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -152,7 +152,7 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
{children}
-
+
diff --git a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
index 29ba39a0b4a..d1d1af6d9f0 100644
--- a/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
+++ b/src/screens/Onboarding/StepProfile/PlaceholderCanvas.tsx
@@ -5,7 +5,7 @@ import ViewShot from 'react-native-view-shot'
import {useAvatar} from '#/screens/Onboarding/StepProfile/index'
import {atoms as a} from '#/alf'
-const SIZE_MULTIPLIER = 1.5
+const SIZE_MULTIPLIER = 5
export interface PlaceholderCanvasRef {
capture: () => Promise
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 3556bba7a0a..5304aa5031d 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -181,8 +181,8 @@ export function StepProfile() {
image = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
- height: image.height,
- width: image.width,
+ height: 1000,
+ width: 1000,
path: image.path,
})
}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
index 9ab24fbbed9..268b7350f87 100644
--- a/src/screens/Profile/Header/Handle.tsx
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -5,19 +5,26 @@ import {Trans} from '@lingui/macro'
import {Shadow} from '#/state/cache/types'
import {isInvalidHandle} from 'lib/strings/handles'
+import {isAndroid} from 'platform/detection'
import {atoms as a, useTheme, web} from '#/alf'
+import {NewskieDialog} from '#/components/NewskieDialog'
import {Text} from '#/components/Typography'
export function ProfileHeaderHandle({
profile,
+ disableTaps,
}: {
profile: Shadow
+ disableTaps?: boolean
}) {
const t = useTheme()
const invalidHandle = isInvalidHandle(profile.handle)
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
return (
-
+
+
{profile.viewer?.followedBy && !blockHide ? (
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
index 6588eb2e1da..d266decb323 100644
--- a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
const canSubscribe =
isSubscribed ||
- (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+ (preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
useUnlikeMutation()
@@ -328,8 +328,8 @@ function CantSubscribePrompt({
Unable to subscribe
- We're sorry! You can only subscribe to ten labelers, and you've
- reached your limit of ten.
+ We're sorry! You can only subscribe to twenty labelers, and you've
+ reached your limit of twenty.
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
index 1280dd8b105..c7ef34b701b 100644
--- a/src/screens/Profile/Header/index.tsx
+++ b/src/screens/Profile/Header/index.tsx
@@ -6,11 +6,11 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
-import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
-import {usePalette} from 'lib/hooks/usePalette'
-import {ProfileHeaderStandard} from './ProfileHeaderStandard'
+import {usePalette} from 'lib/hooks/usePalette'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
+import {ProfileHeaderStandard} from './ProfileHeaderStandard'
let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
const pal = usePalette('default')
@@ -19,11 +19,11 @@ let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
-
+
-
+
@@ -58,13 +58,13 @@ const styles = StyleSheet.create({
position: 'absolute',
top: 110,
left: 10,
- width: 84,
- height: 84,
- borderRadius: 42,
+ width: 94,
+ height: 94,
+ borderRadius: 47,
borderWidth: 2,
},
content: {
- paddingTop: 8,
+ paddingTop: 12,
paddingHorizontal: 14,
paddingBottom: 4,
},
@@ -73,6 +73,6 @@ const styles = StyleSheet.create({
marginLeft: 'auto',
marginBottom: 12,
},
- br40: {borderRadius: 40},
+ br45: {borderRadius: 45},
br50: {borderRadius: 50},
})
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
index facc680bd76..87700cb88ef 100644
--- a/src/screens/Signup/state.ts
+++ b/src/screens/Signup/state.ts
@@ -252,7 +252,6 @@ export function useSubmitSignup({
dispatch({type: 'setIsLoading', value: true})
try {
- onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({
service: state.serviceUrl,
email: state.email,
@@ -262,8 +261,12 @@ export function useSubmitSignup({
inviteCode: state.inviteCode.trim(),
verificationCode: verificationCode,
})
+ /*
+ * Must happen last so that if the user has multiple tabs open and
+ * createAccount fails, one tab is not stuck in onboarding — Eric
+ */
+ onboardingDispatch({type: 'start'})
} catch (e: any) {
- onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({
diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx
new file mode 100644
index 00000000000..dc5104c140e
--- /dev/null
+++ b/src/state/cache/thread-mutes.tsx
@@ -0,0 +1,97 @@
+import React, {useEffect} from 'react'
+
+import * as persisted from '#/state/persisted'
+import {useAgent, useSession} from '../session'
+
+type StateContext = Map
+type SetStateContext = (uri: string, value: boolean) => void
+
+const stateContext = React.createContext(new Map())
+const setStateContext = React.createContext(
+ (_: string) => false,
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [state, setState] = React.useState(() => new Map())
+
+ const setThreadMute = React.useCallback(
+ (uri: string, value: boolean) => {
+ setState(prev => {
+ const next = new Map(prev)
+ next.set(uri, value)
+ return next
+ })
+ },
+ [setState],
+ )
+
+ useMigrateMutes(setThreadMute)
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useMutedThreads() {
+ return React.useContext(stateContext)
+}
+
+export function useIsThreadMuted(uri: string, defaultValue = false) {
+ const state = React.useContext(stateContext)
+ return state.get(uri) ?? defaultValue
+}
+
+export function useSetThreadMute() {
+ return React.useContext(setStateContext)
+}
+
+function useMigrateMutes(setThreadMute: SetStateContext) {
+ const agent = useAgent()
+ const {currentAccount} = useSession()
+
+ useEffect(() => {
+ if (currentAccount) {
+ if (
+ !persisted
+ .get('mutedThreads')
+ .some(uri => uri.includes(currentAccount.did))
+ ) {
+ return
+ }
+
+ let cancelled = false
+
+ const migrate = async () => {
+ while (!cancelled) {
+ const threads = persisted.get('mutedThreads')
+
+ const root = threads.findLast(uri => uri.includes(currentAccount.did))
+
+ if (!root) break
+
+ persisted.write(
+ 'mutedThreads',
+ threads.filter(uri => uri !== root),
+ )
+
+ setThreadMute(root, true)
+
+ await agent.api.app.bsky.graph
+ .muteThread({root})
+ // not a big deal if this fails, since the post might have been deleted
+ .catch(console.error)
+ }
+ }
+
+ migrate()
+
+ return () => {
+ cancelled = true
+ }
+ }
+ }, [agent, currentAccount, setThreadMute])
+}
diff --git a/src/state/modals/index.tsx b/src/state/modals/index.tsx
index ced14335bd1..685b10bd851 100644
--- a/src/state/modals/index.tsx
+++ b/src/state/modals/index.tsx
@@ -70,7 +70,8 @@ export interface SelfLabelModal {
export interface ThreadgateModal {
name: 'threadgate'
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}
export interface ChangeHandleModal {
diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx
deleted file mode 100644
index 84a717eb798..00000000000
--- a/src/state/muted-threads.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-import * as persisted from '#/state/persisted'
-import {track} from '#/lib/analytics/analytics'
-
-type StateContext = persisted.Schema['mutedThreads']
-type ToggleContext = (uri: string) => boolean
-
-const stateContext = React.createContext(
- persisted.defaults.mutedThreads,
-)
-const toggleContext = React.createContext((_: string) => false)
-
-export function Provider({children}: React.PropsWithChildren<{}>) {
- const [state, setState] = React.useState(persisted.get('mutedThreads'))
-
- const toggleThreadMute = React.useCallback(
- (uri: string) => {
- let muted = false
- setState((arr: string[]) => {
- if (arr.includes(uri)) {
- arr = arr.filter(v => v !== uri)
- muted = false
- track('Post:ThreadUnmute')
- } else {
- arr = arr.concat([uri])
- muted = true
- track('Post:ThreadMute')
- }
- persisted.write('mutedThreads', arr)
- return arr
- })
- return muted
- },
- [setState],
- )
-
- React.useEffect(() => {
- return persisted.onUpdate(() => {
- setState(persisted.get('mutedThreads'))
- })
- }, [setState])
-
- return (
-
-
- {children}
-
-
- )
-}
-
-export function useMutedThreads() {
- return React.useContext(stateContext)
-}
-
-export function useToggleThreadMute() {
- return React.useContext(toggleContext)
-}
-
-export function isThreadMuted(uri: string) {
- return persisted.get('mutedThreads').includes(uri)
-}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index b81cf5962d1..c942828f2a3 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -60,6 +60,7 @@ export const schema = z.object({
appLanguage: z.string(),
}),
requireAltTextEnabled: z.boolean(), // should move to server
+ largeAltBadgeEnabled: z.boolean().optional(),
externalEmbeds: z
.object({
giphy: z.enum(externalEmbedOptions).optional(),
@@ -74,7 +75,6 @@ export const schema = z.object({
flickr: z.enum(externalEmbedOptions).optional(),
})
.optional(),
- mutedThreads: z.array(z.string()), // should move to server
invites: z.object({
copiedInvites: z.array(z.string()),
}),
@@ -88,6 +88,8 @@ export const schema = z.object({
disableHaptics: z.boolean().optional(),
disableAutoplay: z.boolean().optional(),
kawaii: z.boolean().optional(),
+ /** @deprecated */
+ mutedThreads: z.array(z.string()),
})
export type Schema = z.infer
@@ -111,6 +113,7 @@ export const defaults: Schema = {
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
+ largeAltBadgeEnabled: false,
externalEmbeds: {},
mutedThreads: [],
invites: {
diff --git a/src/state/preferences/alt-text-required.tsx b/src/state/preferences/alt-text-required.tsx
index 81de9e00602..642e790fbcb 100644
--- a/src/state/preferences/alt-text-required.tsx
+++ b/src/state/preferences/alt-text-required.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['requireAltTextEnabled']
diff --git a/src/state/preferences/hidden-posts.tsx b/src/state/preferences/hidden-posts.tsx
index 11119ce758a..2c6a373e15a 100644
--- a/src/state/preferences/hidden-posts.tsx
+++ b/src/state/preferences/hidden-posts.tsx
@@ -1,4 +1,5 @@
import React from 'react'
+
import * as persisted from '#/state/persisted'
type SetStateCb = (
diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx
index 70c8efc8056..e1a35f193c7 100644
--- a/src/state/preferences/index.tsx
+++ b/src/state/preferences/index.tsx
@@ -8,6 +8,7 @@ import {Provider as HiddenPostsProvider} from './hidden-posts'
import {Provider as InAppBrowserProvider} from './in-app-browser'
import {Provider as KawaiiProvider} from './kawaii'
import {Provider as LanguagesProvider} from './languages'
+import {Provider as LargeAltBadgeProvider} from './large-alt-badge'
export {
useRequireAltTextEnabled,
@@ -27,17 +28,19 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return (
-
-
-
-
-
- {children}
-
-
-
-
-
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
)
diff --git a/src/state/preferences/large-alt-badge.tsx b/src/state/preferences/large-alt-badge.tsx
new file mode 100644
index 00000000000..b3d597c5cbb
--- /dev/null
+++ b/src/state/preferences/large-alt-badge.tsx
@@ -0,0 +1,49 @@
+import React from 'react'
+
+import * as persisted from '#/state/persisted'
+
+type StateContext = persisted.Schema['largeAltBadgeEnabled']
+type SetContext = (v: persisted.Schema['largeAltBadgeEnabled']) => void
+
+const stateContext = React.createContext(
+ persisted.defaults.largeAltBadgeEnabled,
+)
+const setContext = React.createContext(
+ (_: persisted.Schema['largeAltBadgeEnabled']) => {},
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [state, setState] = React.useState(
+ persisted.get('largeAltBadgeEnabled'),
+ )
+
+ const setStateWrapped = React.useCallback(
+ (largeAltBadgeEnabled: persisted.Schema['largeAltBadgeEnabled']) => {
+ setState(largeAltBadgeEnabled)
+ persisted.write('largeAltBadgeEnabled', largeAltBadgeEnabled)
+ },
+ [setState],
+ )
+
+ React.useEffect(() => {
+ return persisted.onUpdate(() => {
+ setState(persisted.get('largeAltBadgeEnabled'))
+ })
+ }, [setStateWrapped])
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export function useLargeAltBadgeEnabled() {
+ return React.useContext(stateContext)
+}
+
+export function useSetLargeAltBadgeEnabled() {
+ return React.useContext(setContext)
+}
diff --git a/src/state/queries/feed.ts b/src/state/queries/feed.ts
index 2981b41b457..83d6a7634d5 100644
--- a/src/state/queries/feed.ts
+++ b/src/state/queries/feed.ts
@@ -234,26 +234,28 @@ export function useGetPopularFeedsQuery(options?: GetPopularFeedsOptions) {
data: InfiniteData,
) => {
const {savedFeeds, hasSession: hasSessionInner} = selectArgs
- data?.pages.map(page => {
- page.feeds = page.feeds.filter(feed => {
- if (
- !hasSessionInner &&
- KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
- ) {
- return false
- }
- const alreadySaved = Boolean(
- savedFeeds?.find(f => {
- return f.value === feed.uri
+ return {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ feeds: page.feeds.filter(feed => {
+ if (
+ !hasSessionInner &&
+ KNOWN_AUTHED_ONLY_FEEDS.includes(feed.uri)
+ ) {
+ return false
+ }
+ const alreadySaved = Boolean(
+ savedFeeds?.find(f => {
+ return f.value === feed.uri
+ }),
+ )
+ return !alreadySaved
}),
- )
- return !alreadySaved
- })
-
- return page
- })
-
- return data
+ }
+ }),
+ }
},
[selectArgs /* Don't change. Everything needs to go into selectArgs. */],
),
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index d9f019af38a..0607f07a100 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -26,7 +26,6 @@ import {
useQueryClient,
} from '@tanstack/react-query'
-import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
@@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
- const threadMutes = useMutedThreads()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
const lastPageCountRef = useRef(0)
@@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
cursor: pageParam,
queryClient,
moderationOpts,
- threadMutes,
fetchAdditionalData: true,
})
).page
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index ffb8d03bc7a..7bb325ea988 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
-import {useMutedThreads} from '#/state/muted-threads'
import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications'
import {useModerationOpts} from '../../preferences/moderation-opts'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
- const threadMutes = useMutedThreads()
const [numUnread, setNumUnread] = React.useState('')
@@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
limit: 40,
queryClient,
moderationOpts,
- threadMutes,
// only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
},
}
- }, [setNumUnread, queryClient, moderationOpts, threadMutes, agent])
+ }, [setNumUnread, queryClient, moderationOpts, agent])
checkUnreadRef.current = api.checkUnread
return (
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 4662493533a..8ed1c0390c0 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,5 +1,4 @@
import {
- AppBskyEmbedRecord,
AppBskyFeedDefs,
AppBskyFeedLike,
AppBskyFeedPost,
@@ -28,7 +27,6 @@ export async function fetchPage({
limit,
queryClient,
moderationOpts,
- threadMutes,
fetchAdditionalData,
}: {
agent: BskyAgent
@@ -36,7 +34,6 @@ export async function fetchPage({
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
- threadMutes: string[]
fetchAdditionalData: boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
const res = await agent.listNotifications({
@@ -67,11 +64,6 @@ export async function fetchPage({
}
}
- // apply thread muting
- notifsGrouped = notifsGrouped.filter(
- notif => !isThreadMuted(notif, threadMutes),
- )
-
let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
if (Number.isNaN(seenAt.getTime())) {
seenAt = new Date()
@@ -207,45 +199,3 @@ function getSubjectUri(
return notif.reasonSubject
}
}
-
-export function isThreadMuted(notif: FeedNotification, threadMutes: string[]) {
- // If there's a subject we want to use that. This will always work on the notifications tab
- if (notif.subject) {
- const record = notif.subject.record as AppBskyFeedPost.Record
- // Check for a quote record
- if (
- (record.reply && threadMutes.includes(record.reply.root.uri)) ||
- (notif.subject.uri && threadMutes.includes(notif.subject.uri))
- ) {
- return true
- } else if (
- AppBskyEmbedRecord.isMain(record.embed) &&
- threadMutes.includes(record.embed.record.uri)
- ) {
- return true
- }
- } else {
- // Otherwise we just do the best that we can
- const record = notif.notification.record
- if (AppBskyFeedPost.isRecord(record)) {
- if (record.reply && threadMutes.includes(record.reply.root.uri)) {
- // We can always filter replies
- return true
- } else if (
- AppBskyEmbedRecord.isMain(record.embed) &&
- threadMutes.includes(record.embed.record.uri)
- ) {
- // We can also filter quotes if the quoted post is the root
- return true
- }
- } else if (
- AppBskyFeedRepost.isRecord(record) &&
- threadMutes.includes(record.subject.uri)
- ) {
- // Finally we can filter reposts, again if the post is the root
- return true
- }
- }
-
- return false
-}
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 2fb80de37d6..4e44c1c6958 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -78,6 +78,7 @@ export interface FeedPostSliceItem {
feedContext: string | undefined
moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
+ isParentBlocked?: boolean
}
export interface FeedPostSlice {
@@ -311,6 +312,10 @@ export function usePostFeedQuery(
const parentAuthor =
item.reply?.parent?.author ??
slice.items[i + 1]?.reply?.grandparentAuthor
+ const replyRef = item.reply
+ const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
+ replyRef?.parent,
+ )
return {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
@@ -324,6 +329,7 @@ export function usePostFeedQuery(
feedContext: item.feedContext || slice.feedContext,
moderation: moderations[i],
parentAuthor,
+ isParentBlocked,
}
}
return undefined
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index a8b1160fb40..db85e8a1775 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -31,7 +31,8 @@ import {
getEmbeddedPost,
} from './util'
-const RQKEY_ROOT = 'post-thread'
+const REPLY_TREE_DEPTH = 10
+export const RQKEY_ROOT = 'post-thread'
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
@@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) {
gcTime: 0,
queryKey: RQKEY(uri || ''),
async queryFn() {
- const res = await agent.getPostThread({uri: uri!, depth: 10})
+ const res = await agent.getPostThread({
+ uri: uri!,
+ depth: REPLY_TREE_DEPTH,
+ })
if (res.success) {
const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread)
@@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) {
selfThreadNode.ctx.isSelfThread = true
}
const last = selfThreadNodes[selfThreadNodes.length - 1]
- if (last && last.post.replyCount && !last.replies?.length) {
+ if (
+ last &&
+ last.ctx.depth === REPLY_TREE_DEPTH && // at the edge of the tree depth
+ last.post.replyCount && // has replies
+ !last.replies?.length // replies were not hydrated
+ ) {
last.ctx.hasMoreSelfThread = true
}
}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 794f48eb1bc..a511d6b3d74 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {Shadow} from '#/state/cache/types'
import {useAgent, useSession} from '#/state/session'
+import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
import {findProfileQueryData} from './profile'
const RQKEY_ROOT = 'post'
@@ -291,3 +292,72 @@ export function usePostDeleteMutation() {
},
})
}
+
+export function useThreadMuteMutationQueue(
+ post: Shadow,
+ rootUri: string,
+) {
+ const threadMuteMutation = useThreadMuteMutation()
+ const threadUnmuteMutation = useThreadUnmuteMutation()
+ const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
+ const setThreadMute = useSetThreadMute()
+
+ const queueToggle = useToggleMutationQueue({
+ initialState: isThreadMuted,
+ runMutation: async (_prev, shouldMute) => {
+ if (shouldMute) {
+ await threadMuteMutation.mutateAsync({
+ uri: rootUri,
+ })
+ return true
+ } else {
+ await threadUnmuteMutation.mutateAsync({
+ uri: rootUri,
+ })
+ return false
+ }
+ },
+ onSuccess(finalIsMuted) {
+ // finalize
+ setThreadMute(rootUri, finalIsMuted)
+ },
+ })
+
+ const queueMuteThread = useCallback(() => {
+ // optimistically update
+ setThreadMute(rootUri, true)
+ return queueToggle(true)
+ }, [setThreadMute, rootUri, queueToggle])
+
+ const queueUnmuteThread = useCallback(() => {
+ // optimistically update
+ setThreadMute(rootUri, false)
+ return queueToggle(false)
+ }, [rootUri, setThreadMute, queueToggle])
+
+ return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const
+}
+
+function useThreadMuteMutation() {
+ const agent = useAgent()
+ return useMutation<
+ {},
+ Error,
+ {uri: string} // the root post's uri
+ >({
+ mutationFn: ({uri}) => {
+ logEvent('post:mute', {})
+ return agent.api.app.bsky.graph.muteThread({root: uri})
+ },
+ })
+}
+
+function useThreadUnmuteMutation() {
+ const agent = useAgent()
+ return useMutation<{}, Error, {uri: string}>({
+ mutationFn: ({uri}) => {
+ logEvent('post:unmute', {})
+ return agent.api.app.bsky.graph.unmuteThread({root: uri})
+ },
+ })
+}
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
index 4891175825c..67c6f8c0847 100644
--- a/src/state/queries/threadgate.ts
+++ b/src/state/queries/threadgate.ts
@@ -1,5 +1,38 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
export type ThreadgateSetting =
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {type: 'list'; list: string}
+
+export function threadgateViewToSettings(
+ threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
+): ThreadgateSetting[] {
+ const record =
+ threadgate &&
+ AppBskyFeedThreadgate.isRecord(threadgate.record) &&
+ AppBskyFeedThreadgate.validateRecord(threadgate.record).success
+ ? threadgate.record
+ : null
+ if (!record) {
+ return []
+ }
+ if (!record.allow?.length) {
+ return [{type: 'nobody'}]
+ }
+ return record.allow
+ .map(allow => {
+ if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+ return {type: 'mention'}
+ }
+ if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+ return {type: 'following'}
+ }
+ if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+ return {type: 'list', list: allow.list}
+ }
+ return undefined
+ })
+ .filter(Boolean) as ThreadgateSetting[]
+}
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 48f5614bd8e..5a58937faab 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -11,6 +11,7 @@ import {
import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
+import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
configureModerationForAccount,
configureModerationForGuest,
@@ -37,21 +38,7 @@ export async function createAgentAndResume(
}
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount)
- const prevSession: AtpSessionData = {
- // Sorted in the same property order as when returned by BskyAgent (alphabetical).
- accessJwt: storedAccount.accessJwt ?? '',
- did: storedAccount.did,
- email: storedAccount.email,
- emailAuthFactor: storedAccount.emailAuthFactor,
- emailConfirmed: storedAccount.emailConfirmed,
- handle: storedAccount.handle,
- refreshJwt: storedAccount.refreshJwt ?? '',
- /**
- * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
- */
- active: storedAccount.active ?? true,
- status: storedAccount.status,
- }
+ const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession))
} else {
@@ -191,6 +178,13 @@ export async function createAgentAndCreateAccount(
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
}
+ try {
+ // snooze first prompt after signup, defer to next prompt
+ snoozeEmailConfirmationPrompt()
+ } catch (e: any) {
+ logger.error(e, {context: `session: failed snoozeEmailConfirmationPrompt`})
+ }
+
return prepareAgent(agent, gates, moderation, onSessionChange)
}
@@ -245,3 +239,23 @@ export function agentToSessionAccount(
pdsUrl: agent.pdsUrl?.toString(),
}
}
+
+export function sessionAccountToSession(
+ account: SessionAccount,
+): AtpSessionData {
+ return {
+ // Sorted in the same property order as when returned by BskyAgent (alphabetical).
+ accessJwt: account.accessJwt ?? '',
+ did: account.did,
+ email: account.email,
+ emailAuthFactor: account.emailAuthFactor,
+ emailConfirmed: account.emailConfirmed,
+ handle: account.handle,
+ refreshJwt: account.refreshJwt ?? '',
+ /**
+ * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188
+ */
+ active: account.active ?? true,
+ status: account.status,
+ }
+}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 371bd459ad3..314945bcf9f 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -14,6 +14,7 @@ import {
createAgentAndCreateAccount,
createAgentAndLogin,
createAgentAndResume,
+ sessionAccountToSession,
} from './agent'
import {getInitialState, reducer} from './reducer'
@@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (syncedAccount.did !== state.currentAgentState.did) {
resumeSession(syncedAccount)
} else {
- // @ts-ignore we checked for `refreshJwt` above
- state.currentAgentState.agent.session = syncedAccount
+ const agent = state.currentAgentState.agent as BskyAgent
+ agent.session = sessionAccountToSession(syncedAccount)
}
}
})
diff --git a/src/state/shell/reminders.e2e.ts b/src/state/shell/reminders.e2e.ts
index e8c12792ac7..94809a680d6 100644
--- a/src/state/shell/reminders.e2e.ts
+++ b/src/state/shell/reminders.e2e.ts
@@ -1,7 +1,5 @@
-export function init() {}
-
export function shouldRequestEmailConfirmation() {
return false
}
-export function setEmailConfirmationRequested() {}
+export function snoozeEmailConfirmationPrompt() {}
diff --git a/src/state/shell/reminders.ts b/src/state/shell/reminders.ts
index ee924eb001a..db6ee9391b4 100644
--- a/src/state/shell/reminders.ts
+++ b/src/state/shell/reminders.ts
@@ -1,36 +1,45 @@
+import {simpleAreDatesEqual} from '#/lib/strings/time'
+import {logger} from '#/logger'
import * as persisted from '#/state/persisted'
-import {toHashCode} from 'lib/strings/helpers'
-import {isOnboardingActive} from './onboarding'
import {SessionAccount} from '../session'
+import {isOnboardingActive} from './onboarding'
export function shouldRequestEmailConfirmation(account: SessionAccount) {
- if (!account) {
- return false
- }
- if (account.emailConfirmed) {
- return false
- }
- if (isOnboardingActive()) {
- return false
- }
- // only prompt once
- if (persisted.get('reminders').lastEmailConfirm) {
- return false
- }
+ // ignore logged out
+ if (!account) return false
+ // ignore confirmed accounts, this is the success state of this reminder
+ if (account.emailConfirmed) return false
+ // wait for onboarding to complete
+ if (isOnboardingActive()) return false
+
+ const snoozedAt = persisted.get('reminders').lastEmailConfirm
const today = new Date()
- // shard the users into 2 day of the week buckets
- // (this is to avoid a sudden influx of email updates when
- // this feature rolls out)
- const code = toHashCode(account.did) % 7
- if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
+
+ logger.debug('Checking email confirmation reminder', {
+ today,
+ snoozedAt,
+ })
+
+ // never been snoozed, new account
+ if (!snoozedAt) {
+ return true
+ }
+
+ // already snoozed today
+ if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) {
return false
}
+
return true
}
-export function setEmailConfirmationRequested() {
+export function snoozeEmailConfirmationPrompt() {
+ const lastEmailConfirm = new Date().toISOString()
+ logger.debug('Snoozing email confirmation reminder', {
+ snoozedAt: lastEmailConfirm,
+ })
persisted.write('reminders', {
...persisted.get('reminders'),
- lastEmailConfirm: new Date().toISOString(),
+ lastEmailConfirm,
})
}
diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx
index 895baa9a4dd..8cf0452cec7 100644
--- a/src/view/com/home/HomeHeaderLayoutMobile.tsx
+++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx
@@ -120,6 +120,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 5,
width: '100%',
+ minHeight: 46,
},
title: {
fontSize: 21,
diff --git a/src/view/com/modals/Threadgate.tsx b/src/view/com/modals/Threadgate.tsx
index a2e9f391c08..4a9a9e2ab5b 100644
--- a/src/view/com/modals/Threadgate.tsx
+++ b/src/view/com/modals/Threadgate.tsx
@@ -26,9 +26,11 @@ export const snapPoints = ['60%']
export function Component({
settings,
onChange,
+ onConfirm,
}: {
settings: ThreadgateSetting[]
- onChange: (settings: ThreadgateSetting[]) => void
+ onChange?: (settings: ThreadgateSetting[]) => void
+ onConfirm?: (settings: ThreadgateSetting[]) => void
}) {
const pal = usePalette('default')
const {closeModal} = useModalControls()
@@ -38,12 +40,12 @@ export function Component({
const onPressEverybody = () => {
setSelected([])
- onChange([])
+ onChange?.([])
}
const onPressNobody = () => {
setSelected([{type: 'nobody'}])
- onChange([{type: 'nobody'}])
+ onChange?.([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateSetting) => {
@@ -57,7 +59,7 @@ export function Component({
newSelected.splice(i, 1)
}
setSelected(newSelected)
- onChange(newSelected)
+ onChange?.(newSelected)
}
return (
@@ -124,6 +126,7 @@ export function Component({
testID="confirmBtn"
onPress={() => {
closeModal()
+ onConfirm?.(selected)
}}
style={styles.btn}
accessibilityRole="button"
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index d6c38ea61cf..9cd7a291769 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -8,6 +8,7 @@ import {
} from 'react-native'
import {
AppBskyActorDefs,
+ AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs,
@@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth
+import {parseTenorGif} from '#/lib/strings/embed-player'
const MAX_AUTHORS = 5
@@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text
- const images = AppBskyEmbedImages.isView(post.embed)
- ? post.embed.images
- : AppBskyEmbedRecordWithMedia.isView(post.embed) &&
- AppBskyEmbedImages.isView(post.embed.media)
- ? post.embed.media.images
- : undefined
+ let images
+ let isGif = false
+
+ if (AppBskyEmbedImages.isView(post.embed)) {
+ images = post.embed.images
+ } else if (
+ AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+ AppBskyEmbedImages.isView(post.embed.media)
+ ) {
+ images = post.embed.media.images
+ } else if (
+ AppBskyEmbedExternal.isView(post.embed) &&
+ post.embed.external.thumb
+ ) {
+ let url: URL | undefined
+ try {
+ url = new URL(post.embed.external.uri)
+ } catch {}
+ if (url) {
+ const {success} = parseTenorGif(url)
+ if (success) {
+ isGif = true
+ images = [
+ {
+ thumb: post.embed.external.thumb,
+ alt: post.embed.external.title,
+ fullsize: post.embed.external.thumb,
+ },
+ ]
+ }
+ }
+ }
+
return (
<>
{text?.length > 0 && {text}}
{images && images.length > 0 && (
-
+
)}
>
)
diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx
index e940e8d1a9b..1c83ecd6e26 100644
--- a/src/view/com/pager/TabBar.tsx
+++ b/src/view/com/pager/TabBar.tsx
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
position: 'absolute',
left: 0,
right: 0,
- bottom: -1,
+ top: '100%',
borderBottomWidth: 1,
},
})
@@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
position: 'absolute',
left: 0,
right: 0,
- bottom: -1,
+ top: '100%',
borderBottomWidth: hairlineWidth,
},
})
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index 8061eb11c99..a6c1a464877 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -331,7 +331,11 @@ export function PostThread({
- setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
+ setHiddenRepliesState(
+ item === SHOW_HIDDEN_REPLIES
+ ? HiddenRepliesState.Show
+ : HiddenRepliesState.ShowAndOverridePostHider,
+ )
}
hideTopBorder={index === 0}
/>
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 5ee60e4eabb..92b529db78c 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -35,7 +35,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
import {PostAlerts} from '../../../components/moderation/PostAlerts'
import {PostHider} from '../../../components/moderation/PostHider'
import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers'
-import {WhoCanReply} from '../threadgate/WhoCanReply'
+import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {Link, TextLink} from '../util/Link'
import {formatCount} from '../util/numeric/format'
@@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
const itemTitle = _(msg`Post by ${post.author.handle}`)
const authorHref = makeProfileLink(post.author)
const authorTitle = post.author.handle
+ const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
const likesHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
@@ -339,6 +340,7 @@ let PostThreadItemLoaded = ({
@@ -395,7 +397,6 @@ let PostThreadItemLoaded = ({
-
>
)
} else {
@@ -574,12 +575,7 @@ let PostThreadItemLoaded = ({
) : undefined}
-
+
>
)
}
@@ -647,10 +643,12 @@ function PostOuterWrapper({
function ExpandedPostDetails({
post,
+ isThreadAuthor,
needsTranslation,
translatorUrl,
}: {
post: AppBskyFeedDefs.PostView
+ isThreadAuthor: boolean
needsTranslation: boolean
translatorUrl: string
}) {
@@ -663,14 +661,23 @@ function ExpandedPostDetails({
}, [openLink, translatorUrl])
return (
-
- {niceDate(post.indexedAt)}
+
+ {niceDate(post.indexedAt)}
+
{needsTranslation && (
<>
- ·
+ ·
Translate
@@ -681,6 +688,20 @@ function ExpandedPostDetails({
)
}
+function getThreadAuthor(
+ post: AppBskyFeedDefs.PostView,
+ record: AppBskyFeedPost.Record,
+): string {
+ if (!record.reply) {
+ return post.author.did
+ }
+ try {
+ return new AtUri(record.reply.root.uri).host
+ } catch {
+ return ''
+ }
+}
+
const styles = StyleSheet.create({
outer: {
borderTopWidth: hairlineWidth,
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 675f23a88ca..cc767a4a3dc 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -56,6 +56,7 @@ interface FeedItemProps {
isThreadParent?: boolean
feedContext: string | undefined
hideTopBorder?: boolean
+ isParentBlocked?: boolean
}
export function FeedItem({
@@ -70,6 +71,7 @@ export function FeedItem({
isThreadLastChild,
isThreadParent,
hideTopBorder,
+ isParentBlocked,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@@ -100,6 +102,7 @@ export function FeedItem({
isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder}
+ isParentBlocked={isParentBlocked}
/>
)
}
@@ -119,6 +122,7 @@ let FeedItemInner = ({
isThreadLastChild,
isThreadParent,
hideTopBorder,
+ isParentBlocked,
}: FeedItemProps & {
richText: RichTextAPI
post: Shadow
@@ -320,7 +324,7 @@ let FeedItemInner = ({
onOpenAuthor={onOpenAuthor}
/>
{!isThreadChild && showReplyTo && parentAuthor && (
-
+
)}
-
- Reply to{' '}
-
-
-
-
+ {blocked ? (
+ Reply to a blocked post
+ ) : (
+
+ Reply to{' '}
+
+
+
+
+ )}
)
diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx
index aeb24e8bbf5..3e08f253cff 100644
--- a/src/view/com/posts/FeedSlice.tsx
+++ b/src/view/com/posts/FeedSlice.tsx
@@ -34,6 +34,7 @@ let FeedSlice = ({
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
+ isParentBlocked={slice.items[0].isParentBlocked}
/>
>
@@ -82,6 +85,7 @@ let FeedSlice = ({
isThreadLastChild={
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
}
+ isParentBlocked={slice.items[i].isParentBlocked}
hideTopBorder={hideTopBorder && i === 0}
/>
))}
diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx
index c1e36d481db..3f9970f5fba 100644
--- a/src/view/com/threadgate/WhoCanReply.tsx
+++ b/src/view/com/threadgate/WhoCanReply.tsx
@@ -1,128 +1,259 @@
import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
+import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
- AppBskyFeedThreadgate,
+ AppBskyFeedGetPostThread,
AppBskyGraphDefs,
AtUri,
+ BskyAgent,
} from '@atproto/api'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {Trans} from '@lingui/macro'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
-import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {createThreadgate} from '#/lib/api'
+import {until} from '#/lib/async/until'
+import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {colors} from '#/lib/styles'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {useModalControls} from '#/state/modals'
+import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
+import {
+ ThreadgateSetting,
+ threadgateViewToSettings,
+} from '#/state/queries/threadgate'
+import {useAgent} from '#/state/session'
+import * as Toast from 'view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {useDialogControl} from '#/components/Dialog'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
+import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {Text} from '#/components/Typography'
import {TextLink} from '../util/Link'
-import {Text} from '../util/text/Text'
-export function WhoCanReply({
+interface WhoCanReplyProps {
+ post: AppBskyFeedDefs.PostView
+ isThreadAuthor: boolean
+ style?: StyleProp
+}
+
+export function WhoCanReplyInline({
+ post,
+ isThreadAuthor,
+ style,
+}: WhoCanReplyProps) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const infoDialogControl = useDialogControl()
+ const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
+
+ if (!isRootPost) {
+ return null
+ }
+ if (!settings.length && !isThreadAuthor) {
+ return null
+ }
+
+ const isEverybody = settings.length === 0
+ const isNobody = !!settings.find(gate => gate.type === 'nobody')
+ const description = isEverybody
+ ? _(msg`Everybody can reply`)
+ : isNobody
+ ? _(msg`Replies disabled`)
+ : _(msg`Some people can reply`)
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+export function WhoCanReplyBlock({
post,
+ isThreadAuthor,
style,
+}: WhoCanReplyProps) {
+ const {_} = useLingui()
+ const t = useTheme()
+ const infoDialogControl = useDialogControl()
+ const {settings, isRootPost, onPressEdit} = useWhoCanReply(post)
+
+ if (!isRootPost) {
+ return null
+ }
+ if (!settings.length && !isThreadAuthor) {
+ return null
+ }
+
+ const isEverybody = settings.length === 0
+ const isNobody = !!settings.find(gate => gate.type === 'nobody')
+ const description = isEverybody
+ ? _(msg`Everybody can reply`)
+ : isNobody
+ ? _(msg`Replies on this thread are disabled`)
+ : _(msg`Some people can reply`)
+
+ return (
+ <>
+
+
+ >
+ )
+}
+
+function Icon({
+ color,
+ width,
+ settings,
+}: {
+ color: string
+ width?: number
+ settings: ThreadgateSetting[]
+}) {
+ const isEverybody = settings.length === 0
+ const isNobody = !!settings.find(gate => gate.type === 'nobody')
+ const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group
+ return
+}
+
+function InfoDialog({
+ control,
+ post,
+ settings,
}: {
+ control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
- style?: StyleProp
+ settings: ThreadgateSetting[]
}) {
- const pal = usePalette('default')
- const {isMobile} = useWebMediaQueries()
- const containerStyles = useColorSchemeStyle(
- {
- borderColor: pal.colors.unreadNotifBorder,
- backgroundColor: pal.colors.unreadNotifBg,
- },
- {
- borderColor: pal.colors.unreadNotifBorder,
- backgroundColor: pal.colors.unreadNotifBg,
- },
- )
- const iconStyles = useColorSchemeStyle(
- {
- backgroundColor: colors.blue3,
- },
- {
- backgroundColor: colors.blue3,
- },
+ return (
+
+
+
+
)
- const textStyles = useColorSchemeStyle(
- {color: colors.gray7},
- {color: colors.blue1},
+}
+
+function InfoDialogInner({
+ post,
+ settings,
+}: {
+ post: AppBskyFeedDefs.PostView
+ settings: ThreadgateSetting[]
+}) {
+ const {_} = useLingui()
+ return (
+
+
+
+ Who can reply?
+
+
+
+
)
- const record = React.useMemo(
- () =>
- post.threadgate &&
- AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
- AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
- ? post.threadgate.record
- : null,
- [post],
+}
+
+function Rules({
+ post,
+ settings,
+}: {
+ post: AppBskyFeedDefs.PostView
+ settings: ThreadgateSetting[]
+}) {
+ const t = useTheme()
+ return (
+
+ {!settings.length ? (
+ Everybody can reply
+ ) : settings[0].type === 'nobody' ? (
+ Replies to this thread are disabled
+ ) : (
+
+ Only{' '}
+ {settings.map((rule, i) => (
+ <>
+
+
+ >
+ ))}{' '}
+ can reply
+
+ )}
+
)
- if (record) {
- return (
-
-
-
-
-
-
- {!record.allow?.length ? (
- Replies to this thread are disabled
- ) : (
-
- Only{' '}
- {record.allow.map((rule, i) => (
- <>
-
-
- >
- ))}{' '}
- can reply.
-
- )}
-
-
-
- )
- }
- return null
}
function Rule({
@@ -130,15 +261,15 @@ function Rule({
post,
lists,
}: {
- rule: any
+ rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
- const pal = usePalette('default')
- if (AppBskyFeedThreadgate.isMentionRule(rule)) {
+ const t = useTheme()
+ if (rule.type === 'mention') {
return mentioned users
}
- if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
+ if (rule.type === 'following') {
return (
users followed by{' '}
@@ -146,12 +277,12 @@ function Rule({
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
- style={pal.link}
+ style={{color: t.palette.primary_500}}
/>
)
}
- if (AppBskyFeedThreadgate.isListRule(rule)) {
+ if (rule.type === 'list') {
const list = lists?.find(l => l.uri === rule.list)
if (list) {
const listUrip = new AtUri(list.uri)
@@ -161,7 +292,7 @@ function Rule({
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
- style={pal.link}
+ style={{color: t.palette.primary_500}}
/>{' '}
members
@@ -183,3 +314,78 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, >
}
+
+function useWhoCanReply(post: AppBskyFeedDefs.PostView) {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const {openModal} = useModalControls()
+
+ const settings = React.useMemo(
+ () => threadgateViewToSettings(post.threadgate),
+ [post],
+ )
+ const isRootPost = !('reply' in post.record)
+
+ const onPressEdit = () => {
+ if (isNative && Keyboard.isVisible()) {
+ Keyboard.dismiss()
+ }
+ openModal({
+ name: 'threadgate',
+ settings,
+ async onConfirm(newSettings: ThreadgateSetting[]) {
+ try {
+ if (newSettings.length) {
+ await createThreadgate(agent, post.uri, newSettings)
+ } else {
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: new AtUri(post.uri).rkey,
+ })
+ }
+ await whenAppViewReady(agent, post.uri, res => {
+ const thread = res.data.thread
+ if (AppBskyFeedDefs.isThreadViewPost(thread)) {
+ const fetchedSettings = threadgateViewToSettings(
+ thread.post.threadgate,
+ )
+ return (
+ JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
+ )
+ }
+ return false
+ })
+ Toast.show('Thread settings updated')
+ queryClient.invalidateQueries({
+ queryKey: [POST_THREAD_RQKEY_ROOT],
+ })
+ } catch (err) {
+ Toast.show(
+ 'There was an issue. Please check your internet connection and try again.',
+ )
+ logger.error('Failed to edit threadgate', {message: err})
+ }
+ },
+ })
+ }
+
+ return {settings, isRootPost, onPressEdit}
+}
+
+async function whenAppViewReady(
+ agent: BskyAgent,
+ uri: string,
+ fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
+) {
+ await until(
+ 5, // 5 tries
+ 1e3, // 1s delay between tries
+ fn,
+ () =>
+ agent.app.bsky.feed.getPostThread({
+ uri,
+ depth: 0,
+ }),
+ )
+}
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
index 6b0c17762ce..e917ab1d32e 100644
--- a/src/view/com/util/List.web.tsx
+++ b/src/view/com/util/List.web.tsx
@@ -38,6 +38,7 @@ function ListImpl(
{
ListHeaderComponent,
ListFooterComponent,
+ ListEmptyComponent,
containWeb,
contentContainerStyle,
data,
@@ -72,23 +73,35 @@ function ListImpl(
)
}
- let header: JSX.Element | null = null
+ const isEmpty = !data || data.length === 0
+
+ let headerComponent: JSX.Element | null = null
if (ListHeaderComponent != null) {
if (isValidElement(ListHeaderComponent)) {
- header = ListHeaderComponent
+ headerComponent = ListHeaderComponent
} else {
// @ts-ignore Nah it's fine.
- header =
+ headerComponent =
}
}
- let footer: JSX.Element | null = null
+ let footerComponent: JSX.Element | null = null
if (ListFooterComponent != null) {
if (isValidElement(ListFooterComponent)) {
- footer = ListFooterComponent
+ footerComponent = ListFooterComponent
+ } else {
+ // @ts-ignore Nah it's fine.
+ footerComponent =
+ }
+ }
+
+ let emptyComponent: JSX.Element | null = null
+ if (ListEmptyComponent != null) {
+ if (isValidElement(ListEmptyComponent)) {
+ emptyComponent = ListEmptyComponent
} else {
// @ts-ignore Nah it's fine.
- footer =
+ emptyComponent =
}
}
@@ -323,36 +336,38 @@ function ListImpl(
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
- {onStartReached && (
+ {onStartReached && !isEmpty && (
)}
- {header}
- {(data as Array).map((item, index) => {
- const key = keyExtractor!(item, index)
- return (
-
- key={key}
- item={item}
- index={index}
- renderItem={renderItem}
- extraData={extraData}
- onItemSeen={onItemSeen}
- disableContentVisibility={disableContentVisibility}
- />
- )
- })}
- {onEndReached && (
+ {headerComponent}
+ {isEmpty
+ ? emptyComponent
+ : (data as Array)?.map((item, index) => {
+ const key = keyExtractor!(item, index)
+ return (
+
+ key={key}
+ item={item}
+ index={index}
+ renderItem={renderItem}
+ extraData={extraData}
+ onItemSeen={onItemSeen}
+ disableContentVisibility={disableContentVisibility}
+ />
+ )
+ })}
+ {onEndReached && !isEmpty && (
)}
- {footer}
+ {footerComponent}
)
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index df45174b9a4..3f855c336c6 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -3,15 +3,14 @@ import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {useQueryClient} from '@tanstack/react-query'
-import {precacheProfile, usePrefetchProfileQuery} from '#/state/queries/profile'
+import {precacheProfile} from '#/state/queries/profile'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {niceDate} from 'lib/strings/time'
import {TypographyVariant} from 'lib/ThemeContext'
-import {isAndroid, isWeb} from 'platform/detection'
-import {atoms as a} from '#/alf'
+import {isAndroid} from 'platform/detection'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {TextLinkOnWebOnly} from './Link'
import {Text} from './text/Text'
@@ -37,17 +36,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle
- const prefetchProfileQuery = usePrefetchProfileQuery()
-
const profileLink = makeProfileLink(opts.author)
- const prefetchedProfile = React.useRef(false)
- const onPointerMove = React.useCallback(() => {
- if (!prefetchedProfile.current) {
- prefetchedProfile.current = true
- prefetchProfileQuery(opts.author.did)
- }
- }, [opts.author.did, prefetchProfileQuery])
-
const queryClient = useQueryClient()
const onOpenAuthor = opts.onOpenAuthor
const onBeforePressAuthor = useCallback(() => {
@@ -71,39 +60,35 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
)}
-
-
-
- {sanitizeDisplayName(
- displayName,
- opts.moderation?.ui('displayName'),
- )}
- >
- }
- href={profileLink}
- onBeforePress={onBeforePressAuthor}
- />
-
-
-
+
+
+ {sanitizeDisplayName(
+ displayName,
+ opts.moderation?.ui('displayName'),
+ )}
+ >
+ }
+ href={profileLink}
+ onBeforePress={onBeforePressAuthor}
+ />
+
+
{!isAndroid && (
JSX.Element
timeToString?: (timeElapsed: string) => string
}) {
+ const ago = useGetTimeAgo()
+ const format = timeToString ?? ago
const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() =>
- timeToString(timestamp),
+ format(timestamp, tick),
)
const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) {
setPrevTick(tick)
- setTimeAgo(timeToString(timestamp))
+ setTimeAgo(format(timestamp, tick))
}
return children({timeElapsed})
diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx
index 587b466a3c1..e9aa625806a 100644
--- a/src/view/com/util/UserAvatar.tsx
+++ b/src/view/com/util/UserAvatar.tsx
@@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
interface BaseUserAvatarProps {
type?: UserAvatarType
+ shape?: 'circle' | 'square'
size: number
avatar?: string | null
}
@@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
let DefaultAvatar = ({
type,
+ shape: overrideShape,
size,
}: {
type: UserAvatarType
+ shape?: 'square' | 'circle'
size: number
}): React.ReactNode => {
+ const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
if (type === 'algo') {
+ // TODO: shape=circle
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return (
) : undefined}
@@ -257,7 +258,7 @@ let PostCtrls = ({
})
}
accessibilityHint=""
- hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+ hitSlop={POST_CTRL_HITSLOP}>
{post.viewer?.like ? (
) : (
@@ -278,7 +279,7 @@ let PostCtrls = ({
: defaultCtrlColor,
],
]}>
- {post.likeCount}
+ {formatCount(post.likeCount)}
) : undefined}
@@ -298,7 +299,7 @@ let PostCtrls = ({
}}
accessibilityLabel={_(msg`Share`)}
accessibilityHint=""
- hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+ hitSlop={POST_CTRL_HITSLOP}>
diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx
index 81e89d42d91..d49cda442cc 100644
--- a/src/view/com/util/post-ctrls/RepostButton.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.tsx
@@ -3,7 +3,7 @@ import {View} from 'react-native'
import {msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
+import {POST_CTRL_HITSLOP} from '#/lib/constants'
import {useHaptics} from '#/lib/haptics'
import {useRequireAuth} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
@@ -12,6 +12,7 @@ import * as Dialog from '#/components/Dialog'
import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {Text} from '#/components/Typography'
+import {formatCount} from '../numeric/format'
interface Props {
isReposted: boolean
@@ -66,7 +67,7 @@ let RepostButton = ({
shape="round"
variant="ghost"
color="secondary"
- hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
+ hitSlop={POST_CTRL_HITSLOP}>
{typeof repostCount !== 'undefined' && repostCount > 0 ? (
- {repostCount}
+ {formatCount(repostCount)}
) : undefined}
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 0898981419b..17ab736ced6 100644
--- a/src/view/com/util/post-ctrls/RepostButton.web.tsx
+++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx
@@ -12,6 +12,7 @@ import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repos
import * as Menu from '#/components/Menu'
import {Text} from '#/components/Typography'
import {EventStopper} from '../EventStopper'
+import {formatCount} from '../numeric/format'
interface Props {
isReposted: boolean
@@ -115,20 +116,22 @@ const RepostInner = ({
color: {color: string}
repostCount?: number
big?: boolean
-}) => (
-
-
- {typeof repostCount !== 'undefined' && repostCount > 0 ? (
-
- {repostCount}
-
- ) : undefined}
-
-)
+}) => {
+ return (
+
+
+ {typeof repostCount !== 'undefined' && repostCount > 0 ? (
+
+ {formatCount(repostCount)}
+
+ ) : undefined}
+
+ )
+}
diff --git a/src/view/com/util/post-embeds/GifEmbed.tsx b/src/view/com/util/post-embeds/GifEmbed.tsx
index f2e2a8b0e99..1558b75c62e 100644
--- a/src/view/com/util/post-embeds/GifEmbed.tsx
+++ b/src/view/com/util/post-embeds/GifEmbed.tsx
@@ -8,6 +8,7 @@ import {useLingui} from '@lingui/react'
import {HITSLOP_20} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {isWeb} from '#/platform/detection'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {EmbedPlayerParams} from 'lib/strings/embed-player'
import {useAutoplayDisabled} from 'state/preferences'
import {atoms as a, useTheme} from '#/alf'
@@ -157,6 +158,7 @@ export function GifEmbed({
function AltText({text}: {text: string}) {
const control = Prompt.usePromptControl()
+ const largeAltBadge = useLargeAltBadgeEnabled()
const {_} = useLingui()
return (
@@ -169,7 +171,9 @@ function AltText({text}: {text: string}) {
hitSlop={HITSLOP_20}
onPress={control.open}
style={styles.altContainer}>
-
+
ALT
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index a13fffc370f..be34a2869ee 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -21,6 +21,7 @@ import {
import {ImagesLightbox, useLightboxControls} from '#/state/lightbox'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
+import {atoms as a} from '#/alf'
import {ContentHider} from '../../../../components/moderation/ContentHider'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -28,6 +29,7 @@ import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {ListEmbed} from './ListEmbed'
import {MaybeQuoteEmbed} from './QuoteEmbed'
import hairlineWidth = StyleSheet.hairlineWidth
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
type Embed =
| AppBskyEmbedRecord.View
@@ -51,6 +53,7 @@ export function PostEmbeds({
}) {
const pal = usePalette('default')
const {openLightbox} = useLightboxControls()
+ const largeAltBadge = useLargeAltBadgeEnabled()
// quote post with media
// =
@@ -130,10 +133,12 @@ export function PostEmbeds({
dimensionsHint={aspectRatio}
onPress={() => _openLightbox(0)}
onPressIn={() => onPressIn(0)}
- style={[styles.singleImage]}>
+ style={a.rounded_sm}>
{alt === '' ? null : (
-
+
ALT
@@ -151,9 +156,6 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
- style={
- embed.images.length === 1 ? [styles.singleImage] : undefined
- }
/>
@@ -179,9 +181,6 @@ const styles = StyleSheet.create({
imagesContainer: {
marginTop: 8,
},
- singleImage: {
- borderRadius: 8,
- },
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
diff --git a/src/view/icons/index.tsx b/src/view/icons/index.tsx
index 025b903b22b..b4feed990c2 100644
--- a/src/view/icons/index.tsx
+++ b/src/view/icons/index.tsx
@@ -1,5 +1,5 @@
import {library} from '@fortawesome/fontawesome-svg-core'
-import {faAddressCard} from '@fortawesome/free-regular-svg-icons'
+import {faAddressCard} from '@fortawesome/free-regular-svg-icons/faAddressCard'
import {faBell as farBell} from '@fortawesome/free-regular-svg-icons/faBell'
import {faBookmark as farBookmark} from '@fortawesome/free-regular-svg-icons/faBookmark'
import {faCalendar as farCalendar} from '@fortawesome/free-regular-svg-icons/faCalendar'
@@ -25,8 +25,6 @@ import {faSquareCheck} from '@fortawesome/free-regular-svg-icons/faSquareCheck'
import {faSquarePlus} from '@fortawesome/free-regular-svg-icons/faSquarePlus'
import {faTrashCan} from '@fortawesome/free-regular-svg-icons/faTrashCan'
import {faUser} from '@fortawesome/free-regular-svg-icons/faUser'
-import {faFlask} from '@fortawesome/free-solid-svg-icons'
-import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons'
import {faAngleDown} from '@fortawesome/free-solid-svg-icons/faAngleDown'
import {faAngleLeft} from '@fortawesome/free-solid-svg-icons/faAngleLeft'
import {faAngleRight} from '@fortawesome/free-solid-svg-icons/faAngleRight'
@@ -62,6 +60,7 @@ import {faExclamation} from '@fortawesome/free-solid-svg-icons/faExclamation'
import {faEye} from '@fortawesome/free-solid-svg-icons/faEye'
import {faFilter} from '@fortawesome/free-solid-svg-icons/faFilter'
import {faFire} from '@fortawesome/free-solid-svg-icons/faFire'
+import {faFlask} from '@fortawesome/free-solid-svg-icons/faFlask'
import {faGear} from '@fortawesome/free-solid-svg-icons/faGear'
import {faGlobe} from '@fortawesome/free-solid-svg-icons/faGlobe'
import {faHand} from '@fortawesome/free-solid-svg-icons/faHand'
@@ -97,6 +96,7 @@ import {faSignal} from '@fortawesome/free-solid-svg-icons/faSignal'
import {faSliders} from '@fortawesome/free-solid-svg-icons/faSliders'
import {faThumbtack} from '@fortawesome/free-solid-svg-icons/faThumbtack'
import {faTicket} from '@fortawesome/free-solid-svg-icons/faTicket'
+import {faUniversalAccess} from '@fortawesome/free-solid-svg-icons/faUniversalAccess'
import {faUserCheck} from '@fortawesome/free-solid-svg-icons/faUserCheck'
import {faUserPlus} from '@fortawesome/free-solid-svg-icons/faUserPlus'
import {faUsers} from '@fortawesome/free-solid-svg-icons/faUsers'
diff --git a/src/view/screens/AccessibilitySettings.tsx b/src/view/screens/AccessibilitySettings.tsx
index ac0d985f100..9ac9793367d 100644
--- a/src/view/screens/AccessibilitySettings.tsx
+++ b/src/view/screens/AccessibilitySettings.tsx
@@ -4,13 +4,12 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {s} from '#/lib/styles'
import {isNative} from '#/platform/detection'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {useAnalytics} from 'lib/analytics/analytics'
-import {usePalette} from 'lib/hooks/usePalette'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
-import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
-import {s} from 'lib/styles'
import {
useAutoplayDisabled,
useHapticsDisabled,
@@ -18,11 +17,16 @@ import {
useSetAutoplayDisabled,
useSetHapticsDisabled,
useSetRequireAltTextEnabled,
-} from 'state/preferences'
-import {ToggleButton} from 'view/com/util/forms/ToggleButton'
-import {SimpleViewHeader} from '../com/util/SimpleViewHeader'
-import {Text} from '../com/util/text/Text'
-import {ScrollView} from '../com/util/Views'
+} from '#/state/preferences'
+import {
+ useLargeAltBadgeEnabled,
+ useSetLargeAltBadgeEnabled,
+} from '#/state/preferences/large-alt-badge'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {ToggleButton} from '#/view/com/util/forms/ToggleButton'
+import {SimpleViewHeader} from '#/view/com/util/SimpleViewHeader'
+import {Text} from '#/view/com/util/text/Text'
+import {ScrollView} from '#/view/com/util/Views'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
@@ -41,6 +45,8 @@ export function AccessibilitySettingsScreen({}: Props) {
const setAutoplayDisabled = useSetAutoplayDisabled()
const hapticsDisabled = useHapticsDisabled()
const setHapticsDisabled = useSetHapticsDisabled()
+ const largeAltBadgeEnabled = useLargeAltBadgeEnabled()
+ const setLargeAltBadgeEnabled = useSetLargeAltBadgeEnabled()
useFocusEffect(
React.useCallback(() => {
@@ -84,6 +90,13 @@ export function AccessibilitySettingsScreen({}: Props) {
isSelected={requireAltTextEnabled}
onPress={() => setRequireAltTextEnabled(!requireAltTextEnabled)}
/>
+ setLargeAltBadgeEnabled(!largeAltBadgeEnabled)}
+ />
Media
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
index e727a1fb810..e6040b77e26 100644
--- a/src/view/screens/Log.tsx
+++ b/src/view/screens/Log.tsx
@@ -1,18 +1,20 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {useFocusEffect} from '@react-navigation/native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
-import {ScrollView} from '../com/util/Views'
-import {s} from 'lib/styles'
-import {ViewHeader} from '../com/util/ViewHeader'
-import {Text} from '../com/util/text/Text'
-import {usePalette} from 'lib/hooks/usePalette'
-import {getEntries} from '#/logger/logDump'
-import {ago} from 'lib/strings/time'
-import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
+import {getEntries} from '#/logger/logDump'
+import {useTickEveryMinute} from '#/state/shell'
import {useSetMinimalShellMode} from '#/state/shell'
+import {usePalette} from 'lib/hooks/usePalette'
+import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
+import {s} from 'lib/styles'
+import {Text} from '../com/util/text/Text'
+import {ViewHeader} from '../com/util/ViewHeader'
+import {ScrollView} from '../com/util/Views'
export function LogScreen({}: NativeStackScreenProps<
CommonNavigatorParams,
@@ -22,6 +24,8 @@ export function LogScreen({}: NativeStackScreenProps<
const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = React.useState([])
+ const timeAgo = useGetTimeAgo()
+ const tick = useTickEveryMinute()
useFocusEffect(
React.useCallback(() => {
@@ -70,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
/>
) : undefined}
- {ago(entry.timestamp)}
+ {timeAgo(entry.timestamp, tick)}
{expanded.includes(entry.id) ? (
diff --git a/src/view/screens/Search/Explore.tsx b/src/view/screens/Search/Explore.tsx
index f6e998838a3..dd93bf8130f 100644
--- a/src/view/screens/Search/Explore.tsx
+++ b/src/view/screens/Search/Explore.tsx
@@ -16,18 +16,17 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSuggestedFollowsQuery} from '#/state/queries/suggested-follows'
-import {useSession} from '#/state/session'
import {cleanError} from 'lib/strings/errors'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List'
import {UserAvatar} from '#/view/com/util/UserAvatar'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
import {
FeedFeedLoadingPlaceholder,
ProfileCardFeedLoadingPlaceholder,
} from 'view/com/util/LoadingPlaceholder'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
import {Button} from '#/components/Button'
+import * as FeedCard from '#/components/FeedCard'
import {ArrowBottom_Stroke2_Corner0_Rounded as ArrowBottom} from '#/components/icons/Arrow'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
@@ -65,7 +64,7 @@ function SuggestedItemsHeader({
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
- {title}
+ {title}
{description}
@@ -120,6 +119,9 @@ function LoadMore({
})
.filter(Boolean) as LoadMoreItems[]
}, [item.items, moderationOpts])
+
+ if (items.length === 0) return null
+
const type = items[0].type
return (
@@ -143,20 +145,20 @@ function LoadMore({
a.relative,
{
height: 32,
- width: 32 + 15 * 3,
+ width: 32 + 15 * items.length,
},
]}>
item.type === 'profile').slice(-3),
- })
+ if (hasNextProfilesPage) {
+ i.push({
+ type: 'loadMore',
+ key: 'loadMoreProfiles',
+ isLoadingMore: isLoadingMoreProfiles,
+ onLoadMore: onLoadMoreProfiles,
+ items: i.filter(item => item.type === 'profile').slice(-3),
+ })
+ }
} else {
if (profilesError) {
i.push({
@@ -414,7 +417,7 @@ export function Explore() {
message: _(msg`Failed to load feeds preferences`),
error: cleanError(preferencesError),
})
- } else {
+ } else if (hasNextFeedsPage) {
i.push({
type: 'loadMore',
key: 'loadMoreFeeds',
@@ -456,6 +459,8 @@ export function Explore() {
profilesError,
feedsError,
preferencesError,
+ hasNextProfilesPage,
+ hasNextFeedsPage,
])
const renderItem = React.useCallback(
@@ -480,15 +485,14 @@ export function Explore() {
}
case 'feed': {
return (
-
-
+
+
)
}
@@ -538,7 +542,7 @@ export function Explore() {
}
}
},
- [t, hasSession, moderationOpts],
+ [t, moderationOpts],
)
return (
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index ed132d24ef7..0b1fe37aaf5 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -30,7 +30,7 @@ import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {augmentSearchQuery} from '#/lib/strings/helpers'
import {logger} from '#/logger'
-import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {isNative, isWeb} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
@@ -57,8 +57,8 @@ import {Text} from '#/view/com/util/text/Text'
import {CenteredView, ScrollView} from '#/view/com/util/Views'
import {Explore} from '#/view/screens/Search/Explore'
import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search'
-import {FeedSourceCard} from 'view/com/feeds/FeedSourceCard'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme as useThemeNew} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu'
function Loader() {
@@ -285,8 +285,8 @@ let SearchScreenFeedsResults = ({
query: string
active: boolean
}): React.ReactNode => {
+ const t = useThemeNew()
const {_} = useLingui()
- const {hasSession} = useSession()
const {data: results, isFetched} = usePopularFeedsSearch({
query,
@@ -299,13 +299,15 @@ let SearchScreenFeedsResults = ({
(
-
+
+
+
)}
keyExtractor={item => item.uri}
// @ts-ignore web only -prf
@@ -802,12 +804,6 @@ let SearchInputBox = ({
})
} else {
setShowAutocomplete(true)
- if (isIOS) {
- // We rely on selectTextOnFocus, but it's broken on iOS:
- // https://github.com/facebook/react-native/issues/41988
- textInput.current?.setSelection(0, searchText.length)
- // We still rely on selectTextOnFocus for it to be instant on Android.
- }
}
}}
onChangeText={onChangeText}
diff --git a/src/view/shell/createNativeStackNavigatorWithAuth.tsx b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
index 3c611351dc0..82dd6d22c82 100644
--- a/src/view/shell/createNativeStackNavigatorWithAuth.tsx
+++ b/src/view/shell/createNativeStackNavigatorWithAuth.tsx
@@ -29,7 +29,8 @@ import {
useLoggedOutView,
useLoggedOutViewControls,
} from '#/state/shell/logged-out'
-import {isWeb} from 'platform/detection'
+import {useGate} from 'lib/statsig/statsig'
+import {isNative, isWeb} from 'platform/detection'
import {Deactivated} from '#/screens/Deactivated'
import {Onboarding} from '#/screens/Onboarding'
import {SignupQueued} from '#/screens/SignupQueued'
@@ -50,6 +51,7 @@ function NativeStackNavigator({
screenOptions,
...rest
}: NativeStackNavigatorProps) {
+ const gate = useGate()
// --- this is copy and pasted from the original native stack navigator ---
const {state, descriptors, navigation, NavigationContent} =
useNavigationBuilder<
@@ -100,7 +102,11 @@ function NativeStackNavigator({
const {showLoggedOut} = useLoggedOutView()
const {setShowLoggedOut} = useLoggedOutViewControls()
const {isMobile, isTabletOrMobile} = useWebMediaQueries()
- if ((!PWI_ENABLED || activeRouteRequiresAuth) && !hasSession) {
+ const isNativePWIDisabled = isNative && gate('native_pwi_disabled')
+ if (
+ (!PWI_ENABLED || isNativePWIDisabled || activeRouteRequiresAuth) &&
+ !hasSession
+ ) {
return
}
if (hasSession && currentAccount?.signupQueued) {
diff --git a/yarn.lock b/yarn.lock
index c56a56b28ec..a0fd8749a80 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
jsonpointer "^5.0.0"
leven "^3.1.0"
-"@atproto/api@^0.12.18":
- version "0.12.18"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821"
- integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ==
+"@atproto/api@^0.12.20":
+ version "0.12.20"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
+ integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0"
@@ -3897,18 +3897,18 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
-"@formatjs/ecma402-abstract@1.18.0":
- version "1.18.0"
- resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.0.tgz#e2120e7101020140661b58430a7ff4262705a2f2"
- integrity sha512-PEVLoa3zBevWSCZzPIM/lvPCi8P5l4G+NXQMc/CjEiaCWgyHieUoo0nM7Bs0n/NbuQ6JpXEolivQ9pKSBHaDlA==
+"@formatjs/ecma402-abstract@2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-2.0.0.tgz#39197ab90b1c78b7342b129a56a7acdb8f512e17"
+ integrity sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==
dependencies:
- "@formatjs/intl-localematcher" "0.5.2"
+ "@formatjs/intl-localematcher" "0.5.4"
tslib "^2.4.0"
-"@formatjs/intl-enumerator@1.4.3":
- version "1.4.3"
- resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.4.3.tgz#8d278c273485d7c6219916509fbd51ce3142064d"
- integrity sha512-0NpTmAQnDokPoB5aVtXvOdtrUq/uEuPPhBUAr57TYYDjI5MwfFXt8F6JCm6s6CPI0inL8+nxPLjjqH0qyNnP4Q==
+"@formatjs/intl-enumerator@1.4.7":
+ version "1.4.7"
+ resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.4.7.tgz#6ab697f3f8f18cf0cc6a6b028cb9c40db6001f3d"
+ integrity sha512-03RHnFqfpB4H/jwCwlzC+wkTDk2Fi24JmVIY2PVGvTUpikN2bSr9+8oTXfOC+y7B7VxjCArUnqWXVoctkmy85w==
dependencies:
tslib "^2.4.0"
@@ -3919,30 +3919,39 @@
dependencies:
tslib "^2.4.0"
-"@formatjs/intl-locale@^3.4.3":
- version "3.4.3"
- resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-3.4.3.tgz#fdd2a3978b03aa76965abbca86526bb1d02973b6"
- integrity sha512-g/35yMikkkRmLYmqE4W74gvZyKa768oC9OmUFzfLmH3CVYF3v2kvAZI0WsxWLbxYj8TT7wBDeLIL3aIlRw4Osw==
+"@formatjs/intl-locale@^4.0.0":
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-4.0.0.tgz#c111a33078413eba2011e82140466261eb1d67cd"
+ integrity sha512-+4dbMEGsp1bvB3JB3UHH6YTjMnFTifnfdaHp4ROrCCu50NedA69RBsDCG3eivcZkbj57X9ehGhMWjLxlP+gyVw==
dependencies:
- "@formatjs/ecma402-abstract" "1.18.0"
- "@formatjs/intl-enumerator" "1.4.3"
+ "@formatjs/ecma402-abstract" "2.0.0"
+ "@formatjs/intl-enumerator" "1.4.7"
"@formatjs/intl-getcanonicallocales" "2.3.0"
tslib "^2.4.0"
-"@formatjs/intl-localematcher@0.5.2":
- version "0.5.2"
- resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.2.tgz#5fcf029fd218905575e5080fa33facdcb623d532"
- integrity sha512-txaaE2fiBMagLrR4jYhxzFO6wEdEG4TPMqrzBAcbr4HFUYzH/YC+lg6OIzKCHm8WgDdyQevxbAAV1OgcXctuGw==
+"@formatjs/intl-localematcher@0.5.4":
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.4.tgz#caa71f2e40d93e37d58be35cfffe57865f2b366f"
+ integrity sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==
dependencies:
tslib "^2.4.0"
-"@formatjs/intl-pluralrules@^5.2.10":
- version "5.2.10"
- resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.10.tgz#379fc06133625df0cae715c1d902001974ff3279"
- integrity sha512-wfJypePrbOByaZVPP1moLXHgS9LeAvi9coP95XZX7ySVrwdDGPnxz9Pw+o7J1o8AjLxjiqGrvAi74key5zzIjQ==
+"@formatjs/intl-numberformat@^8.10.3":
+ version "8.10.3"
+ resolved "https://registry.yarnpkg.com/@formatjs/intl-numberformat/-/intl-numberformat-8.10.3.tgz#abc97cc6a7b7f1b20da9f07a976b5589c1192ab8"
+ integrity sha512-lH3liLMeIjZ19Zxt8RRPnBcpPweS1YNSXRURDiFfvFmRlDZUOd8+GlcVyECcPZPkIoSH/p4lfGrnaUzepxJ92g==
dependencies:
- "@formatjs/ecma402-abstract" "1.18.0"
- "@formatjs/intl-localematcher" "0.5.2"
+ "@formatjs/ecma402-abstract" "2.0.0"
+ "@formatjs/intl-localematcher" "0.5.4"
+ tslib "^2.4.0"
+
+"@formatjs/intl-pluralrules@^5.2.14":
+ version "5.2.14"
+ resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.14.tgz#7477bd2aa9bfde9e543d839707eff5460eb08026"
+ integrity sha512-l6Ev7aOGXJSh5EPDEqzsbyufdCCKXZk993QXRQebLsB0TXRhIyF4alqjdMEatLwIigK/Mka8kiVIOLeFP5Cj9Q==
+ dependencies:
+ "@formatjs/ecma402-abstract" "2.0.0"
+ "@formatjs/intl-localematcher" "0.5.4"
tslib "^2.4.0"
"@fortawesome/fontawesome-common-types@6.4.2":
@@ -22470,12 +22479,7 @@ zod-validation-error@^3.0.3:
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-3.3.0.tgz#2cfe81b62d044e0453d1aa3ae7c32a2f36dde9af"
integrity sha512-Syib9oumw1NTqEv4LT0e6U83Td9aVRk9iTXPUQr1otyV1PuXQKOvOwhMNqZIq5hluzHP2pMgnOmHEo7kPdI2mw==
-zod@^3.14.2, zod@^3.20.2:
- version "3.22.2"
- resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.2.tgz#3add8c682b7077c05ac6f979fea6998b573e157b"
- integrity sha512-wvWkphh5WQsJbVk1tbx1l1Ly4yg+XecD+Mq280uBGt9wa5BKSWf4Mhp6GmrkPixhMxmabYY7RbzlwVP32pbGCg==
-
-zod@^3.21.4, zod@^3.22.4:
+zod@3.23.8, zod@^3.14.2, zod@^3.20.2, zod@^3.21.4, zod@^3.22.4:
version "3.23.8"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d"
integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==