Compare commits

..

31 Commits

Author SHA1 Message Date
Eric Bailey 2b13ac8429 Convert button to use forwardRef 2024-06-19 18:05:08 -05:00
Samuel Newman 5c31859f7b fix for autofill covering border (#4573) 2024-06-20 01:42:12 +03:00
Samuel Newman 73fc0094dd Update HomeHeaderLayoutMobile.tsx (#4572) 2024-06-20 00:45:08 +03:00
Samuel Newman 0f931933a7 Option for large alt badges (#4571)
* add pref for large alt badge

* add to settings

* do the large badge bit

* Tweak wording

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-20 00:32:44 +03:00
Samuel Newman 22c5aa4da4 Truncate post metrics and fix truncation on native (#4575)
* truncate post counts

* add numberformat polyfill

* Fix perf

* Simplify type shenanigans

* Bump versions to remove dupes

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-20 00:21:02 +03:00
dan ca17cf276f Fix avi placeholder layout (#4570) 2024-06-19 19:03:59 +03:00
Hailey 8788708bd2 add support for ListEmptyComponent, allow undefined data (#4403)
* add support for `ListEmptyComponent`, allow `undefined` data

* change `header` and `footer` to be in line with `emptyComponent`

* don't render `onEndReached` or `onStartReached` `Visibility` if empty
2024-06-19 03:33:43 +03:00
Samuel Newman 3dc34be929 GIF previews in notifications (#4447)
* gifs in notifications

* remove try/catch

* Limit try/catch scope

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-19 03:09:06 +03:00
dan 7ddbc392c3 [Session] Convert account to session data explicitly (#4446)
* Extract a variable

* Extract sessionAccountToSession

* Convert account to session data on assignment
2024-06-19 02:47:50 +03:00
Eric Bailey 5159495274 Move onboarding start to after successfull account creation (#4381)
* Move onboarding start to after successfull account creation

* Remove unused dispatch
2024-06-19 02:46:34 +03:00
Paul Frazee 07c2be255f Collection of moderation fixes (#4566)
* Fix: dont blur parents in threads that embed blocks

* After tapping 'Show hidden replies', show the individual hider cards

* Add shape override to UserAvatar and fix the fallback avi for labelers

* Fix precedence

* Detect shape for DefaultAvatar

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-19 02:36:46 +03:00
Eric Bailey ac08c76168 Fix undefined block (#4479)
* Fix undefined block (#4378)

* Fix undefined block

* Changing text and handling all blocks.

* Tweaks

* Update copy, make non-interactive

* Remove console

* Clarify logic

* Pass through parent blocked state for 3p blocks

* Better translation

---------

Co-authored-by: Josh <hi@oracularhades.com>
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-19 02:03:23 +03:00
Samuel Newman 2237e10ad2 fix gap between tab bar and its border (#4538) 2024-06-19 01:53:54 +03:00
Eric Bailey c92ef2fe31 Better handling of blocks in KnownFollowers (#4563)
* Better handle nested conditionals, use renderable items to determine UI

* Better translate

* Fix translation and fix missing case in the process

* Clarify naming

* Add safeguard

* Remove unneeded msg

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
2024-06-19 01:33:17 +03:00
Eric Bailey 32b4063185 Verify email reminders (#4510)
* Clarify intent

* Increase email reminder period to once per day

* Fallback

* Snooze immediately after account creation, prevent showing right after signup

* Fix e2e test exports

* Remove redundant check

* Better simple date generation

* Replace in DateField

* Use non-string comparison

* Revert change to unrelated code

* Also parse

* Remove side effect
2024-06-18 17:21:34 -05:00
Paul Frazee 853c32b4d8 Bump labeler limit to 20 (#4565)
* Increase the labeler subscription limit to 20

* Bump @atproto/api@0.12.20

* Fix: correctly count labeler subscriptions

---------

Co-authored-by: dan <dan.abramov@gmail.com>
2024-06-19 00:21:55 +03:00
Samuel Newman 0012d1236f Migrate local thread mutes (#4523)
* migrate thread mutes

* don't try and clear if not logged in yet

* migrate mutes one at a time

* write before mutating

* only migrate mutes of self posts

* use /** @deprecated */

* shouldLike -> shouldMute
2024-06-19 00:05:59 +03:00
Eric Bailey 502bcad701 Disable newskie dialog tap in hover card web (#4562) 2024-06-18 14:09:40 -05:00
Paul Frazee d6ce16d15a Implement thread locking (#4545)
* Add the ability to edit threadgates

* Fix bottom border on mobile

* Refresh thread after threadgate edit
2024-06-18 12:07:56 -07:00
Eric Bailey 4165a02b2d Prevent unecessary calls (#4561)
(cherry picked from commit ecb4879767)
2024-06-18 21:52:44 +03:00
Eric Bailey 983d85384b Force callers of getTimeAgo to pass in the value for "now" (#4560)
* Remove icky hook for now

* Force callers of getTimeAgo to pass in the 'now' value

* Update usage in Newskie dialog
2024-06-18 21:50:07 +03:00
Paul Frazee fb76265fcc Fix: only apply self-thread load-more behavior on the outer edge of the reply tree (#4559) 2024-06-18 21:48:49 +03:00
Samuel Newman 5f5d845053 Server-side thread mutes (#4518)
* update atproto/api

* move thread mutes to server side

* rm log

* move muted threads provider to inside did key

* use map instead of object
2024-06-18 21:48:34 +03:00
Eric Bailey 35e54e24a0 Explore fixes (#4540)
* Use safe check, check for next page, handle varied lengths

* Fix border width

* Move safe check

* Add font_heavy and use it on the explore page headers

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
2024-06-18 13:37:14 -05:00
Eric Bailey 1106517481 Is it "newskie" or "newsky" 🤔 (#4557)
* add newskie icon

(cherry picked from commit 152e074ee0)
(cherry picked from commit 8d2326f115c9c9d32aa1c41259bb81936b3868aa)

* add size prop

(cherry picked from commit af09ae2d8f)
(cherry picked from commit 38dd38451bcce8afcf302ad1180802640857722a)

* add a dialog for newskies to profiles

(cherry picked from commit fe16f55e9c)
(cherry picked from commit c5b9f1b16ace276f422832069db076a5360616fe)

* move newskie to handle

(cherry picked from commit 150f2635b2)
(cherry picked from commit 1efaaf835380f4e76d2e4b7fe8b727a92731a794)

* use "say hello" in newskie dialog

(cherry picked from commit d9a286cfc8)
(cherry picked from commit 018dd1739fee68906dec63e05519f5ca9ae73910)

* tweaks

(cherry picked from commit 070363c947)
(cherry picked from commit c30855d4ff311e31fb6ae357a9d6cd1662b291d5)

* Tweaks

* Re-export newskie icon

* Design tweaks

* Tweaks

* Add source icon

* Remove unused file

* Remove unneeded edits

* Simplify logic

* Update source

* Moderate displayName, fix createdAt type

---------

Co-authored-by: Hailey <me@haileyok.com>
2024-06-18 12:59:50 -05:00
Hailey 73c9de3ce2 fix keyboard overlaying onboarding inputs (#4558) 2024-06-18 10:57:08 -07:00
Eric Bailey 443beda741 Add useGetTimeAgo and utils (#4556)
* Create a testable version of ago() and re-enable the disabled test (#4364)

* Enable the test of ago()

* Use test cases

This puts the input and the expected values next to each other.

* Create dateDiff function

This is a copy of ago(), but with the ability to specify the second date instead of using Date.now().

* Let ago() use dateDiff()

* Move constants close to usage

* Test dateDiff instead of ago

This makes it possible to test the dates without being forced to rely on what the current date is.

The commented out tests do not yet pass. This is fixed in later commits.

* Update dateDiff and enable the remaining tests

* Split up tests, use date-fns as helpers

* Remove old test

* Add long format

* Add hook

* Migrate to hooks

* Delete old code

* Or equal to

* Update comment

---------

Co-authored-by: Jan Aagaard <jan@aagaard.net>
2024-06-18 10:55:02 -05:00
dan 08cfb09589 Unconditionally polyfill Intl.PluralRules for native (#4554)
* Revert "Fix Android startup perf regression (#4544)"

This reverts commit e6213d7aa5.

* Force polyfill
2024-06-18 17:27:40 +03:00
dan f142339e06 Dedupe Zod installation (#4551) 2024-06-18 17:20:54 +03:00
dan e30575c0dc Use exact imports for icons (#4549)
* Use exact imports for icons

* Add a lint rule
2024-06-18 15:37:08 +03:00
dan e6213d7aa5 Fix Android startup perf regression (#4544) 2024-06-18 08:23:41 +03:00
75 changed files with 1630 additions and 988 deletions
+1
View File
@@ -31,6 +31,7 @@ module.exports = {
},
},
],
'bsky-internal/use-exact-imports': 'error',
'bsky-internal/use-typed-gates': 'error',
'simple-import-sort/imports': [
'warn',
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#FFC404" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 848 B

+1
View File
@@ -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'),
},
}
+22
View File
@@ -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',
})
}
},
}
}
+5 -3
View File
@@ -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"
+38 -38
View File
@@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
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'
@@ -112,10 +112,12 @@ function InnerApp() {
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
<MutedThreadsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
@@ -154,21 +156,19 @@ function App() {
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
+36 -36
View File
@@ -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() {
<SelectedFeedProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
@@ -136,21 +138,19 @@ function App() {
<SessionProvider>
<ShellStateProvider>
<PrefsStateProvider>
<MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</MutedThreadsProvider>
<InvitesStateProvider>
<ModalStateProvider>
<DialogStateProvider>
<LightboxStateProvider>
<I18nProvider>
<PortalProvider>
<InnerApp />
</PortalProvider>
</I18nProvider>
</LightboxStateProvider>
</DialogStateProvider>
</ModalStateProvider>
</InvitesStateProvider>
</PrefsStateProvider>
</ShellStateProvider>
</SessionProvider>
+2 -2
View File
@@ -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()
}
}
+3
View File
@@ -267,6 +267,9 @@ export const atoms = {
font_bold: {
fontWeight: tokens.fontWeight.bold,
},
font_heavy: {
fontWeight: tokens.fontWeight.heavy,
},
italic: {
fontStyle: 'italic',
},
+1
View File
@@ -118,6 +118,7 @@ export const fontWeight = {
normal: '400',
semibold: '500',
bold: '600',
heavy: '700',
} as const
export const gradients = {
+343 -324
View File
@@ -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,
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,
})
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 (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,
})
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 (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,
})
}
}
}
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)
}
}
}
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,
}
}, [variant, color])
const context = React.useMemo<ButtonContext>(
() => ({
...state,
export const Button = React.forwardRef<View, ButtonProps>(
(
{
children,
variant,
color,
size,
disabled: disabled || false,
}),
[state, variant, color, size, disabled],
)
shape = 'default',
label,
disabled = false,
style,
hoverStyle: hoverStyleProp,
...rest
},
ref,
) => {
const t = useTheme()
const [state, setState] = React.useState({
pressed: false,
hovered: false,
focused: false,
})
const flattenedBaseStyles = flatten(baseStyles)
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])
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
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,
})
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 (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,
})
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 (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,
})
}
}
}
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)
}
}
}
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,
}
}, [variant, color])
const context = React.useMemo<ButtonContext>(
() => ({
...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' && (
<View
style={[
a.absolute,
a.inset_0,
a.overflow_hidden,
{borderRadius: flattenedBaseStyles.borderRadius},
]}>
<LinearGradient
colors={
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
)
}
}),
[state, variant, color, size, disabled],
)
const flattenedBaseStyles = flatten(baseStyles)
return (
<Pressable
role="button"
accessibilityHint={undefined} // optional
{...rest}
ref={ref}
aria-label={label}
aria-pressed={state.pressed}
accessibilityLabel={label}
disabled={disabled || false}
accessibilityState={{
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' && (
<View
style={[
a.absolute,
a.inset_0,
a.overflow_hidden,
{borderRadius: flattenedBaseStyles.borderRadius},
]}>
<LinearGradient
colors={
state.hovered || state.pressed
? gradientHoverColors
: gradientColors
}
locations={gradientLocations}
start={{x: 0, y: 0}}
end={{x: 1, y: 1}}
style={[a.absolute, a.inset_0]}
/>
</View>
)}
<Context.Provider value={context}>
{typeof children === 'function' ? children(context) : children}
</Context.Provider>
</Pressable>
)
},
)
Button.displayName = 'Button'
export function useSharedButtonTextStyles() {
const t = useTheme()
+49 -18
View File
@@ -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 (
<Link
@@ -164,31 +172,54 @@ function KnownFollowersInner({
},
]}
numberOfLines={2}>
{count > 2 ? (
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
,{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
<Plural value={count - 2} one="# other" other="# others" />
</Trans>
) : count === 2 ? (
{slice.length >= 2 ? (
// 2-n followers, including blocks
serverCount > 2 ? (
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
,{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
<Plural
value={serverCount - 2}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 2
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
)
) : serverCount > 1 ? (
// 1-n followers, including blocks
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
<Plural
value={serverCount - 1}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 1
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
+85
View File
@@ -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 (
<View style={[a.pr_2xs]}>
<Button
disabled={disabled}
label={_(
msg`This user is new here. Press for more info about when they joined.`,
)}
hitSlop={HITSLOP_10}
onPress={control.open}>
{({hovered, pressed}) => (
<Newskie
size="lg"
fill="#FFC404"
style={{
opacity: hovered || pressed ? 0.5 : 1,
}}
/>
)}
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`New user info dialog`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}>
<Trans>Say hello!</Trans>
</Text>
<Text style={[a.text_md]}>
<Trans>
{profileName} joined Bluesky{' '}
{timeAgo(createdAt, now, {format: 'long'})} ago
</Trans>
</Text>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
</View>
)
}
@@ -469,7 +469,7 @@ function Inner({
)}
</Text>
<ProfileHeaderHandle profile={profileShadow} />
<ProfileHeaderHandle profile={profileShadow} disableTaps />
</View>
</Link>
+5 -2
View File
@@ -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({
<DateFieldButton
label={label}
value={value}
onPress={control.open}
onPress={() => {
Keyboard.dismiss()
control.open()
}}
isInvalid={isInvalid}
accessibilityHint={accessibilityHint}
/>
+7
View File
@@ -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,
}),
+5
View File
@@ -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',
})
+2
View File
@@ -92,6 +92,8 @@ function PostLabel({
<UserAvatar
avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/>
) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+10 -2
View File
@@ -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,
+2
View File
@@ -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
+12 -5
View File
@@ -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
+4 -13
View File
@@ -1,4 +1,4 @@
import {useCallback, useMemo} from 'react'
import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro'
import {I18nContext, useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns'
@@ -12,25 +12,16 @@ export function useGetTimeAgo() {
const {_} = useLingui()
return useCallback(
(
date: number | string | Date,
earlier: number | string | Date,
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(date, Date.now(), {lingui: _, format: options?.format})
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
}
export function useTimeAgo(
date: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
): string {
const timeAgo = useGetTimeAgo()
return useMemo(() => {
return timeAgo(date, {...options})
}, [date, options, timeAgo])
}
const NOW = 5
const MINUTE = 60
const HOUR = MINUTE * 60
+2
View File
@@ -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
+64 -34
View File
@@ -1,7 +1,8 @@
import {Dimensions, Platform} from 'react-native'
import {Dimensions} from 'react-native'
import {isSafari} from 'lib/browser'
import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = isWeb
@@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
}
}
if (urlp.hostname === 'media.tenor.com') {
let [_, id, filename] = urlp.pathname.split('/')
const tenorGif = parseTenorGif(urlp)
if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
let dimensions
if (h && w) {
dimensions = {
height: Number(h),
width: Number(w),
}
}
if (id && filename && dimensions && id.includes('AAAAC')) {
if (Platform.OS === 'web') {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
return {
type: 'tenor_gif',
source: 'tenor',
isGif: true,
hideDetails: true,
playerUri,
dimensions,
}
}
@@ -516,3 +494,55 @@ export function getGiphyMetaUri(url: URL) {
}
}
}
export function parseTenorGif(urlp: URL):
| {success: false}
| {
success: true
playerUri: string
dimensions: {height: number; width: number}
} {
if (urlp.hostname !== 'media.tenor.com') {
return {success: false}
}
let [_, id, filename] = urlp.pathname.split('/')
if (!id || !filename) {
return {success: false}
}
if (!id.includes('AAAAC')) {
return {success: false}
}
const h = urlp.searchParams.get('hh')
const w = urlp.searchParams.get('ww')
if (!h || !w) {
return {success: false}
}
const dimensions = {
height: Number(h),
width: Number(w),
}
if (isWeb) {
if (isSafari) {
id = id.replace('AAAAC', 'AAAP1')
filename = filename.replace('.gif', '.mp4')
} else {
id = id.replace('AAAAC', 'AAAP3')
filename = filename.replace('.gif', '.webm')
}
} else {
id = id.replace('AAAAC', 'AAAAM')
}
return {
success: true,
playerUri: `https://t.gifs.bsky.app/${id}/${filename}`,
dimensions,
}
}
+11
View File
@@ -19,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()
)
}
+6 -2
View File
@@ -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'
+8 -1
View File
@@ -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<AppBskyActorDefs.ProfileViewDetailed>
disableTaps?: boolean
}) {
const t = useTheme()
const invalidHandle = isInvalidHandle(profile.handle)
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
return (
<View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
<View
style={[a.flex_row, a.gap_xs, a.align_center]}
pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
<NewskieDialog profile={profile} disabled={disableTaps} />
{profile.viewer?.followedBy && !blockHide ? (
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
<Text style={[t.atoms.text, a.text_sm]}>
@@ -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({
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
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.
</Trans>
</Prompt.DescriptionText>
<Prompt.Actions>
+10 -10
View File
@@ -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 => {
<LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
<View
style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
<LoadingPlaceholder width={80} height={80} style={styles.br40} />
<LoadingPlaceholder width={90} height={90} style={styles.br45} />
</View>
<View style={styles.content}>
<View style={[styles.buttonsLine]}>
<LoadingPlaceholder width={167} height={31} style={styles.br50} />
<LoadingPlaceholder width={167} height={36} style={styles.br50} />
</View>
</View>
</View>
@@ -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},
})
+5 -2
View File
@@ -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({
+97
View File
@@ -0,0 +1,97 @@
import React, {useEffect} from 'react'
import * as persisted from '#/state/persisted'
import {useAgent, useSession} from '../session'
type StateContext = Map<string, boolean>
type SetStateContext = (uri: string, value: boolean) => void
const stateContext = React.createContext<StateContext>(new Map())
const setStateContext = React.createContext<SetStateContext>(
(_: string) => false,
)
export function Provider({children}: React.PropsWithChildren<{}>) {
const [state, setState] = React.useState<StateContext>(() => 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 (
<stateContext.Provider value={state}>
<setStateContext.Provider value={setThreadMute}>
{children}
</setStateContext.Provider>
</stateContext.Provider>
)
}
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])
}
+2 -1
View File
@@ -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 {
-62
View File
@@ -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<StateContext>(
persisted.defaults.mutedThreads,
)
const toggleContext = React.createContext<ToggleContext>((_: 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 (
<stateContext.Provider value={state}>
<toggleContext.Provider value={toggleThreadMute}>
{children}
</toggleContext.Provider>
</stateContext.Provider>
)
}
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)
}
+4 -1
View File
@@ -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<typeof schema>
@@ -111,6 +113,7 @@ export const defaults: Schema = {
appLanguage: deviceLocales[0] || 'en',
},
requireAltTextEnabled: false,
largeAltBadgeEnabled: false,
externalEmbeds: {},
mutedThreads: [],
invites: {
@@ -1,4 +1,5 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type StateContext = persisted.Schema['requireAltTextEnabled']
+1
View File
@@ -1,4 +1,5 @@
import React from 'react'
import * as persisted from '#/state/persisted'
type SetStateCb = (
+14 -11
View File
@@ -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 (
<LanguagesProvider>
<AltTextRequiredProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>
<InAppBrowserProvider>
<DisableHapticsProvider>
<AutoplayProvider>
<KawaiiProvider>{children}</KawaiiProvider>
</AutoplayProvider>
</DisableHapticsProvider>
</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider>
<LargeAltBadgeProvider>
<ExternalEmbedsProvider>
<HiddenPostsProvider>
<InAppBrowserProvider>
<DisableHapticsProvider>
<AutoplayProvider>
<KawaiiProvider>{children}</KawaiiProvider>
</AutoplayProvider>
</DisableHapticsProvider>
</InAppBrowserProvider>
</HiddenPostsProvider>
</ExternalEmbedsProvider>
</LargeAltBadgeProvider>
</AltTextRequiredProvider>
</LanguagesProvider>
)
+49
View File
@@ -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<StateContext>(
persisted.defaults.largeAltBadgeEnabled,
)
const setContext = React.createContext<SetContext>(
(_: 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 (
<stateContext.Provider value={state}>
<setContext.Provider value={setStateWrapped}>
{children}
</setContext.Provider>
</stateContext.Provider>
)
}
export function useLargeAltBadgeEnabled() {
return React.useContext(stateContext)
}
export function useSetLargeAltBadgeEnabled() {
return React.useContext(setContext)
}
-3
View File
@@ -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
+1 -4
View File
@@ -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 (
-50
View File
@@ -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
}
+6
View File
@@ -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
+12 -3
View File
@@ -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
}
}
+70
View File
@@ -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<AppBskyFeedDefs.PostView>,
rootUri: string,
) {
const threadMuteMutation = useThreadMuteMutation()
const threadUnmuteMutation = useThreadUnmuteMutation()
const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
const setThreadMute = useSetThreadMute()
const queueToggle = useToggleMutationQueue<boolean>({
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})
},
})
}
+33
View File
@@ -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[]
}
+29 -15
View File
@@ -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,
}
}
+3 -2
View File
@@ -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)
}
}
})
+1 -3
View File
@@ -1,7 +1,5 @@
export function init() {}
export function shouldRequestEmailConfirmation() {
return false
}
export function setEmailConfirmationRequested() {}
export function snoozeEmailConfirmationPrompt() {}
+31 -22
View File
@@ -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,
})
}
@@ -120,6 +120,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 5,
width: '100%',
minHeight: 46,
},
title: {
fontSize: 21,
+7 -4
View File
@@ -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"
+40 -7
View File
@@ -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 style={pal.textLight}>{text}</Text>}
{images && images.length > 0 && (
<ImageHorzList images={images} style={styles.additionalPostImages} />
<ImageHorzList
images={images}
style={styles.additionalPostImages}
gif={isGif}
/>
)}
</>
)
+2 -2
View File
@@ -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,
},
})
+5 -1
View File
@@ -331,7 +331,11 @@ export function PostThread({
<PostThreadShowHiddenReplies
type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
onPress={() =>
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
setHiddenRepliesState(
item === SHOW_HIDDEN_REPLIES
? HiddenRepliesState.Show
: HiddenRepliesState.ShowAndOverridePostHider,
)
}
hideTopBorder={index === 0}
/>
+23 -2
View File
@@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
import {isNative, isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
@@ -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')
@@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
</View>
</View>
</View>
<WhoCanReply post={post} />
<WhoCanReply
post={post}
isThreadAuthor={isThreadAuthor}
style={{borderBottomWidth: isNative ? 1 : 0}}
/>
</>
)
} else {
@@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
post={post}
style={{
marginTop: 4,
borderBottomWidth: 1,
}}
isThreadAuthor={isThreadAuthor}
/>
</>
)
@@ -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,
+33 -20
View File
@@ -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<AppBskyFeedDefs.PostView>
@@ -320,7 +324,7 @@ let FeedItemInner = ({
onOpenAuthor={onOpenAuthor}
/>
{!isThreadChild && showReplyTo && parentAuthor && (
<ReplyToLabel profile={parentAuthor} />
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
)}
<LabelsOnMyPost post={post} />
<PostContent
@@ -409,9 +413,14 @@ let PostContent = ({
}
PostContent = memo(PostContent)
function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
function ReplyToLabel({
profile,
blocked,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
blocked?: boolean
}) {
const pal = usePalette('default')
return (
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon
@@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
style={[pal.textLight, s.mr2]}
lineHeight={1.2}
numberOfLines={1}>
<Trans context="description">
Reply to{' '}
<ProfileHoverCard inline did={profile.did}>
<TextLinkOnWebOnly
type="md"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
href={makeProfileLink(profile)}
text={
profile.displayName
? sanitizeDisplayName(profile.displayName)
: sanitizeHandle(profile.handle)
}
/>
</ProfileHoverCard>
</Trans>
{blocked ? (
<Trans context="description">Reply to a blocked post</Trans>
) : (
<Trans context="description">
Reply to{' '}
<ProfileHoverCard inline did={profile.did}>
<TextLinkOnWebOnly
type="md"
style={pal.textLight}
lineHeight={1.2}
numberOfLines={1}
href={makeProfileLink(profile)}
text={
profile.displayName
? sanitizeDisplayName(profile.displayName)
: sanitizeHandle(profile.handle)
}
/>
</ProfileHoverCard>
</Trans>
)}
</Text>
</View>
)
+4
View File
@@ -34,6 +34,7 @@ let FeedSlice = ({
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
/>
<FeedItem
key={slice.items[1]._reactKey}
@@ -46,6 +47,7 @@ let FeedSlice = ({
moderation={slice.items[1].moderation}
isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)}
isParentBlocked={slice.items[1].isParentBlocked}
/>
<ViewFullThread slice={slice} />
<FeedItem
@@ -59,6 +61,7 @@ let FeedSlice = ({
moderation={slice.items[last].moderation}
isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)}
isParentBlocked={slice.items[2].isParentBlocked}
isThreadLastChild
/>
</>
@@ -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}
/>
))}
+142 -98
View File
@@ -1,128 +1,172 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedThreadgate,
AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {createThreadgate} from '#/lib/api'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
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 {Button} from '#/components/Button'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
export function WhoCanReply({
post,
isThreadAuthor,
style,
}: {
post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean
style?: StyleProp<ViewStyle>
}) {
const {track} = useAnalytics()
const {_} = useLingui()
const pal = usePalette('default')
const {isMobile} = useWebMediaQueries()
const agent = useAgent()
const queryClient = useQueryClient()
const {openModal} = useModalControls()
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,
},
)
const textStyles = useColorSchemeStyle(
{color: colors.gray7},
{color: colors.blue5},
{color: colors.blue1},
)
const record = React.useMemo(
() =>
post.threadgate &&
AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
? post.threadgate.record
: null,
const hoverStyles = useColorSchemeStyle(
{
backgroundColor: colors.white,
},
{
backgroundColor: pal.colors.background,
},
)
const settings = React.useMemo(
() => threadgateViewToSettings(post.threadgate),
[post],
)
if (record) {
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
gap: isMobile ? 8 : 10,
paddingHorizontal: isMobile ? 16 : 18,
paddingVertical: 12,
borderWidth: 1,
borderLeftWidth: isMobile ? 0 : 1,
borderRightWidth: isMobile ? 0 : 1,
},
containerStyles,
style,
]}>
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 19,
},
iconStyles,
]}>
<FontAwesomeIcon
icon={['far', 'comments']}
size={16}
color={'#fff'}
/>
</View>
<View style={{flex: 1}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!record.allow?.length ? (
<Trans>Replies to this thread are disabled</Trans>
) : (
<Trans>
Only{' '}
{record.allow.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator
key={`sep-${i}`}
i={i}
length={record.allow!.length}
/>
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
</View>
)
const isRootPost = !('reply' in post.record)
const onPressEdit = () => {
track('Post:EditThreadgateOpened')
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,
})
}
Toast.show('Thread settings updated')
queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT],
})
track('Post:ThreadgateEdited')
} 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 null
if (!isRootPost) {
return null
}
if (!settings.length && !isThreadAuthor) {
return null
}
return (
<View
style={[
{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingLeft: 18,
paddingRight: 14,
paddingVertical: 10,
borderTopWidth: 1,
},
pal.border,
containerStyles,
style,
]}>
<View style={{flex: 1, paddingVertical: 6}}>
<Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}>
{!settings.length ? (
<Trans>Everybody can reply.</Trans>
) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled.</Trans>
) : (
<Trans>
Only{' '}
{settings.map((rule, i) => (
<>
<Rule
key={`rule-${i}`}
rule={rule}
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '}
can reply.
</Trans>
)}
</Text>
</View>
{isThreadAuthor && (
<View>
<Button label={_(msg`Edit`)} onPress={onPressEdit}>
{({hovered}) => (
<View
style={[
hovered && hoverStyles,
{paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8},
]}>
<Text type="sm" style={pal.link}>
<Trans>Edit</Trans>
</Text>
</View>
)}
</Button>
</View>
)}
</View>
)
}
function Rule({
@@ -130,15 +174,15 @@ function Rule({
post,
lists,
}: {
rule: any
rule: ThreadgateSetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
const pal = usePalette('default')
if (AppBskyFeedThreadgate.isMentionRule(rule)) {
if (rule.type === 'mention') {
return <Trans>mentioned users</Trans>
}
if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
if (rule.type === 'following') {
return (
<Trans>
users followed by{' '}
@@ -151,7 +195,7 @@ function Rule({
</Trans>
)
}
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)
+39 -24
View File
@@ -38,6 +38,7 @@ function ListImpl<ItemT>(
{
ListHeaderComponent,
ListFooterComponent,
ListEmptyComponent,
containWeb,
contentContainerStyle,
data,
@@ -72,23 +73,35 @@ function ListImpl<ItemT>(
)
}
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 = <ListHeaderComponent />
headerComponent = <ListHeaderComponent />
}
}
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.
footer = <ListFooterComponent />
footerComponent = <ListFooterComponent />
}
}
let emptyComponent: JSX.Element | null = null
if (ListEmptyComponent != null) {
if (isValidElement(ListEmptyComponent)) {
emptyComponent = ListEmptyComponent
} else {
// @ts-ignore Nah it's fine.
emptyComponent = <ListEmptyComponent />
}
}
@@ -323,36 +336,38 @@ function ListImpl<ItemT>(
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{onStartReached && (
{onStartReached && !isEmpty && (
<Visibility
root={containWeb ? nativeRef : null}
onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
/>
)}
{header}
{(data as Array<ItemT>).map((item, index) => {
const key = keyExtractor!(item, index)
return (
<Row<ItemT>
key={key}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
onItemSeen={onItemSeen}
disableContentVisibility={disableContentVisibility}
/>
)
})}
{onEndReached && (
{headerComponent}
{isEmpty
? emptyComponent
: (data as Array<ItemT>)?.map((item, index) => {
const key = keyExtractor!(item, index)
return (
<Row<ItemT>
key={key}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
onItemSeen={onItemSeen}
disableContentVisibility={disableContentVisibility}
/>
)
})}
{onEndReached && !isEmpty && (
<Visibility
root={containWeb ? nativeRef : null}
onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
/>
)}
{footer}
{footerComponent}
</View>
</View>
)
+4 -2
View File
@@ -15,12 +15,14 @@ export function TimeElapsed({
const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp))
const [timeElapsed, setTimeAgo] = React.useState(() =>
format(timestamp, tick),
)
const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) {
setPrevTick(tick)
setTimeAgo(format(timestamp))
setTimeAgo(format(timestamp, tick))
}
return children({timeElapsed})
+24 -11
View File
@@ -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 (
<Svg
@@ -84,6 +89,7 @@ let DefaultAvatar = ({
)
}
if (type === 'list') {
// 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 (
<Svg
@@ -117,14 +123,18 @@ let DefaultAvatar = ({
viewBox="0 0 32 32"
fill="none"
stroke="none">
<Rect
x="0"
y="0"
width="32"
height="32"
rx="3"
fill={tokens.color.temp_purple}
/>
{finalShape === 'square' ? (
<Rect
x="0"
y="0"
width="32"
height="32"
rx="3"
fill={tokens.color.temp_purple}
/>
) : (
<Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
)}
<Path
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
stroke="white"
@@ -135,6 +145,7 @@ let DefaultAvatar = ({
</Svg>
)
}
// TODO: shape=square
return (
<Svg
testID="userAvatarFallback"
@@ -159,6 +170,7 @@ export {DefaultAvatar}
let UserAvatar = ({
type = 'user',
shape: overrideShape,
size,
avatar,
moderation,
@@ -166,9 +178,10 @@ let UserAvatar = ({
}: UserAvatarProps): React.ReactNode => {
const pal = usePalette('default')
const backgroundColor = pal.colors.backgroundLight
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list' || type === 'labeler') {
if (finalShape === 'square') {
return {
width: size,
height: size,
@@ -182,7 +195,7 @@ let UserAvatar = ({
borderRadius: Math.floor(size / 2),
backgroundColor,
}
}, [type, size, backgroundColor])
}, [finalShape, size, backgroundColor])
const alert = useMemo(() => {
if (!moderation?.alert) {
@@ -224,7 +237,7 @@ let UserAvatar = ({
</View>
) : (
<View style={{width: size, height: size}}>
<DefaultAvatar type={type} size={size} />
<DefaultAvatar type={type} shape={finalShape} size={size} />
{alert}
</View>
)
+26 -19
View File
@@ -7,7 +7,7 @@ import {
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AtUri,
RichText as RichTextAPI,
@@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser'
import {usePostDeleteMutation} from '#/state/queries/post'
import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
import {useSession} from '#/state/session'
import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing'
@@ -62,9 +65,7 @@ import * as Toast from '../Toast'
let PostDropdownBtn = ({
testID,
postAuthor,
postCid,
postUri,
post,
postFeedContext,
record,
richText,
@@ -74,9 +75,7 @@ let PostDropdownBtn = ({
timestamp,
}: {
testID: string
postAuthor: AppBskyActorDefs.ProfileViewBasic
postCid: string
postUri: string
post: Shadow<AppBskyFeedDefs.PostView>
postFeedContext: string | undefined
record: AppBskyFeedPost.Record
richText: RichTextAPI
@@ -92,8 +91,6 @@ let PostDropdownBtn = ({
const {_} = useLingui()
const defaultCtrlColor = theme.palette.default.postCtrl
const langPrefs = useLanguagePrefs()
const mutedThreads = useMutedThreads()
const toggleThreadMute = useToggleThreadMute()
const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
@@ -107,9 +104,15 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const postUri = post.uri
const postCid = post.cid
const postAuthor = post.author
const rootUri = record.reply?.root?.uri || postUri
const isThreadMuted = mutedThreads.includes(rootUri)
const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
post,
rootUri,
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
@@ -162,18 +165,22 @@ let PostDropdownBtn = ({
const onToggleThreadMute = React.useCallback(() => {
try {
const muted = toggleThreadMute(rootUri)
if (muted) {
if (isThreadMuted) {
unmuteThread()
Toast.show(_(msg`You will now receive notifications for this thread`))
} else {
muteThread()
Toast.show(
_(msg`You will no longer receive notifications for this thread`),
)
} else {
Toast.show(_(msg`You will now receive notifications for this thread`))
}
} catch (e) {
logger.error('Failed to toggle thread mute', {message: e})
} catch (e: any) {
if (e?.name !== 'AbortError') {
logger.error('Failed to toggle thread mute', {message: e})
Toast.show(_(msg`Failed to toggle thread mute, please try again`))
}
}
}, [rootUri, toggleThreadMute, _])
}, [isThreadMuted, unmuteThread, _, muteThread])
const onCopyPostText = React.useCallback(() => {
const str = richTextToString(richText, true)
+10 -12
View File
@@ -5,7 +5,9 @@ import {AppBskyEmbedImages} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from 'platform/detection'
import {isWeb} from '#/platform/detection'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a} from '#/alf'
type EventFunction = (index: number) => void
@@ -27,20 +29,21 @@ export const GalleryItem: FC<GalleryItemProps> = ({
onLongPress,
}) => {
const {_} = useLingui()
const largeAltBadge = useLargeAltBadgeEnabled()
const image = images[index]
return (
<View style={styles.fullWidth}>
<View style={a.flex_1}>
<Pressable
onPress={onPress ? () => onPress(index) : undefined}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={onLongPress ? () => onLongPress(index) : undefined}
style={styles.fullWidth}
style={a.flex_1}
accessibilityRole="button"
accessibilityLabel={image.alt || _(msg`Image`)}
accessibilityHint="">
<Image
source={{uri: image.thumb}}
style={[styles.image, imageStyle]}
style={[a.flex_1, a.rounded_xs, imageStyle]}
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
@@ -49,7 +52,9 @@ export const GalleryItem: FC<GalleryItemProps> = ({
</Pressable>
{image.alt === '' ? null : (
<View style={styles.altContainer}>
<Text style={styles.alt} accessible={false}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
ALT
</Text>
</View>
@@ -59,13 +64,6 @@ export const GalleryItem: FC<GalleryItemProps> = ({
}
const styles = StyleSheet.create({
fullWidth: {
flex: 1,
},
image: {
flex: 1,
borderRadius: 4,
},
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
+39 -18
View File
@@ -2,39 +2,60 @@ import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
interface Props {
images: AppBskyEmbedImages.ViewImage[]
style?: StyleProp<ViewStyle>
gif?: boolean
}
export function ImageHorzList({images, style}: Props) {
export function ImageHorzList({images, style, gif}: Props) {
return (
<View style={[styles.flexRow, style]}>
<View style={[a.flex_row, a.gap_xs, style]}>
{images.map(({thumb, alt}) => (
<Image
<View
key={thumb}
source={{uri: thumb}}
style={styles.image}
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
<Image
key={thumb}
source={{uri: thumb}}
style={[a.flex_1, a.rounded_xs]}
accessible={true}
accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{gif && (
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
)}
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
flexRow: {
flexDirection: 'row',
gap: 5,
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
},
image: {
maxWidth: 100,
aspectRatio: 1,
flex: 1,
borderRadius: 4,
alt: {
color: 'white',
fontSize: 7,
fontWeight: 'bold',
},
})
@@ -3,6 +3,7 @@ import {ScrollView, StyleSheet, View} from 'react-native'
import {isWeb} from '#/platform/detection'
import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle'
import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {atoms as a} from '#/alf'
@@ -29,13 +30,18 @@ export const LoggedOutLayout = ({
borderLeftWidth: 1,
})
const [isKeyboardVisible] = useIsKeyboardVisible()
if (isMobile) {
if (scrollable) {
return (
<ScrollView
style={styles.scrollview}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag">
keyboardDismissMode="none"
contentContainerStyle={[
{paddingBottom: isKeyboardVisible ? 300 : 0},
]}>
<View style={a.pt_md}>{children}</View>
</ScrollView>
)
+4 -5
View File
@@ -39,6 +39,7 @@ import {
} from '#/components/icons/Heart2'
import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
import {Text} from '../text/Text'
import {RepostButton} from './RepostButton'
@@ -226,7 +227,7 @@ let PostCtrls = ({
big ? a.text_md : {fontSize: 15},
a.user_select_none,
]}>
{post.replyCount}
{formatCount(post.replyCount)}
</Text>
) : undefined}
</Pressable>
@@ -278,7 +279,7 @@ let PostCtrls = ({
: defaultCtrlColor,
],
]}>
{post.likeCount}
{formatCount(post.likeCount)}
</Text>
) : undefined}
</Pressable>
@@ -319,9 +320,7 @@ let PostCtrls = ({
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
<PostDropdownBtn
testID="postDropdownBtn"
postAuthor={post.author}
postCid={post.cid}
postUri={post.uri}
post={post}
postFeedContext={feedContext}
record={record}
richText={richText}
@@ -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
@@ -76,7 +77,7 @@ let RepostButton = ({
big ? a.text_md : {fontSize: 15},
isReposted && a.font_bold,
]}>
{repostCount}
{formatCount(repostCount)}
</Text>
) : undefined}
</Button>
@@ -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
}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}>
<Repost style={color} width={big ? 22 : 18} />
{typeof repostCount !== 'undefined' && repostCount > 0 ? (
<Text
testID="repostCount"
style={[
color,
big ? a.text_md : {fontSize: 15},
isReposted && [a.font_bold],
a.user_select_none,
]}>
{repostCount}
</Text>
) : undefined}
</View>
)
}) => {
return (
<View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}>
<Repost style={color} width={big ? 22 : 18} />
{typeof repostCount !== 'undefined' && repostCount > 0 ? (
<Text
testID="repostCount"
style={[
color,
big ? a.text_md : {fontSize: 15},
isReposted && [a.font_bold],
a.user_select_none,
]}>
{formatCount(repostCount)}
</Text>
) : undefined}
</View>
)
}
+5 -1
View File
@@ -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}>
<Text style={styles.alt} accessible={false}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
+7 -8
View File
@@ -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 : (
<View style={styles.altContainer}>
<Text style={styles.alt} accessible={false}>
<Text
style={[styles.alt, largeAltBadge && a.text_xs]}
accessible={false}>
ALT
</Text>
</View>
@@ -151,9 +156,6 @@ export function PostEmbeds({
images={embed.images}
onPress={_openLightbox}
onPressIn={onPressIn}
style={
embed.images.length === 1 ? [styles.singleImage] : undefined
}
/>
</View>
</ContentHider>
@@ -179,9 +181,6 @@ const styles = StyleSheet.create({
imagesContainer: {
marginTop: 8,
},
singleImage: {
borderRadius: 8,
},
altContainer: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
+3 -3
View File
@@ -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'
+24 -11
View File
@@ -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)}
/>
<ToggleButton
type="default-light"
label={_(msg`Display larger alt text badges`)}
labelType="lg"
isSelected={!!largeAltBadgeEnabled}
onPress={() => setLargeAltBadgeEnabled(!largeAltBadgeEnabled)}
/>
</View>
<Text type="xl-bold" style={[pal.text, styles.heading]}>
<Trans>Media</Trans>
+3 -1
View File
@@ -7,6 +7,7 @@ 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'
@@ -24,6 +25,7 @@ export function LogScreen({}: NativeStackScreenProps<
const setMinimalShellMode = useSetMinimalShellMode()
const [expanded, setExpanded] = React.useState<string[]>([])
const timeAgo = useGetTimeAgo()
const tick = useTickEveryMinute()
useFocusEffect(
React.useCallback(() => {
@@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
/>
) : undefined}
<Text type="sm" style={[styles.ts, pal.textLight]}>
{timeAgo(entry.timestamp)}
{timeAgo(entry.timestamp, tick)}
</Text>
</TouchableOpacity>
{expanded.includes(entry.id) ? (
+19 -12
View File
@@ -64,7 +64,7 @@ function SuggestedItemsHeader({
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_bold, t.atoms.text]}>{title}</Text>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>{title}</Text>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
{description}
@@ -119,6 +119,9 @@ function LoadMore({
})
.filter(Boolean) as LoadMoreItems[]
}, [item.items, moderationOpts])
if (items.length === 0) return null
const type = items[0].type
return (
@@ -142,20 +145,20 @@ function LoadMore({
a.relative,
{
height: 32,
width: 32 + 15 * 3,
width: 32 + 15 * items.length,
},
]}>
<View
style={[
a.align_center,
a.justify_center,
a.border,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: 0,
borderWidth: 1,
backgroundColor: t.palette.primary_500,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: type === 'profile' ? 999 : 4,
@@ -169,13 +172,13 @@ function LoadMore({
<View
key={_item.key}
style={[
a.border,
t.atoms.bg_contrast_25,
a.absolute,
{
width: 30,
height: 30,
left: (i + 1) * 15,
borderWidth: 1,
borderColor: t.atoms.bg.backgroundColor,
borderRadius: _item.type === 'profile' ? 999 : 4,
zIndex: 3 - i,
@@ -350,13 +353,15 @@ export function Explore() {
}
}
i.push({
type: 'loadMore',
key: 'loadMoreProfiles',
isLoadingMore: isLoadingMoreProfiles,
onLoadMore: onLoadMoreProfiles,
items: i.filter(item => 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({
@@ -412,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',
@@ -454,6 +459,8 @@ export function Explore() {
profilesError,
feedsError,
preferencesError,
hasNextProfilesPage,
hasNextFeedsPage,
])
const renderItem = React.useCallback(
+39 -35
View File
@@ -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==