Merge branch 'bluesky-social:main' into zh
commit
bdc1ea897f
|
@ -49,7 +49,7 @@
|
||||||
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.12.18",
|
"@atproto/api": "^0.12.20",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
"@braintree/sanitize-url": "^6.0.2",
|
"@braintree/sanitize-url": "^6.0.2",
|
||||||
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
|
||||||
|
|
|
@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
|
||||||
|
import {QueryProvider} from '#/lib/react-query'
|
||||||
import {
|
import {
|
||||||
initialize,
|
initialize,
|
||||||
Provider as StatsigProvider,
|
Provider as StatsigProvider,
|
||||||
tryFetchGates,
|
tryFetchGates,
|
||||||
} from '#/lib/statsig/statsig'
|
} from '#/lib/statsig/statsig'
|
||||||
|
import {s} from '#/lib/styles'
|
||||||
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
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 {MessagesProvider} from '#/state/messages'
|
||||||
|
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||||
import {init as initPersistedState} from '#/state/persisted'
|
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 LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
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 {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
SessionAccount,
|
SessionAccount,
|
||||||
useSession,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from 'state/session'
|
} from '#/state/session'
|
||||||
import {Provider as ShellStateProvider} from 'state/shell'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import {TestCtrls} from 'view/com/testing/TestCtrls'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import {TestCtrls} from '#/view/com/testing/TestCtrls'
|
||||||
import {Shell} from 'view/shell'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
|
import {Shell} from '#/view/shell'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
|
@ -112,10 +112,12 @@ function InnerApp() {
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<GestureHandlerRootView style={s.h100pct}>
|
<MutedThreadsProvider>
|
||||||
<TestCtrls />
|
<GestureHandlerRootView style={s.h100pct}>
|
||||||
<Shell />
|
<TestCtrls />
|
||||||
</GestureHandlerRootView>
|
<Shell />
|
||||||
|
</GestureHandlerRootView>
|
||||||
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
|
@ -154,21 +156,19 @@ function App() {
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<MutedThreadsProvider>
|
<InvitesStateProvider>
|
||||||
<InvitesStateProvider>
|
<ModalStateProvider>
|
||||||
<ModalStateProvider>
|
<DialogStateProvider>
|
||||||
<DialogStateProvider>
|
<LightboxStateProvider>
|
||||||
<LightboxStateProvider>
|
<I18nProvider>
|
||||||
<I18nProvider>
|
<PortalProvider>
|
||||||
<PortalProvider>
|
<InnerApp />
|
||||||
<InnerApp />
|
</PortalProvider>
|
||||||
</PortalProvider>
|
</I18nProvider>
|
||||||
</I18nProvider>
|
</LightboxStateProvider>
|
||||||
</LightboxStateProvider>
|
</DialogStateProvider>
|
||||||
</DialogStateProvider>
|
</ModalStateProvider>
|
||||||
</ModalStateProvider>
|
</InvitesStateProvider>
|
||||||
</InvitesStateProvider>
|
|
||||||
</MutedThreadsProvider>
|
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|
|
@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
|
||||||
import {msg} from '@lingui/macro'
|
import {msg} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
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 {Provider as StatsigProvider} from '#/lib/statsig/statsig'
|
||||||
|
import {ThemeProvider} from '#/lib/ThemeContext'
|
||||||
import {logger} from '#/logger'
|
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 {MessagesProvider} from '#/state/messages'
|
||||||
|
import {Provider as ModalStateProvider} from '#/state/modals'
|
||||||
import {init as initPersistedState} from '#/state/persisted'
|
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 LabelDefsProvider} from '#/state/preferences/label-defs'
|
||||||
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
|
||||||
import {readLastActiveAccount} from '#/state/session/util'
|
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
|
||||||
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 {
|
import {
|
||||||
Provider as SessionProvider,
|
Provider as SessionProvider,
|
||||||
SessionAccount,
|
SessionAccount,
|
||||||
useSession,
|
useSession,
|
||||||
useSessionApi,
|
useSessionApi,
|
||||||
} from 'state/session'
|
} from '#/state/session'
|
||||||
import {Provider as ShellStateProvider} from 'state/shell'
|
import {readLastActiveAccount} from '#/state/session/util'
|
||||||
import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
|
import {Provider as ShellStateProvider} from '#/state/shell'
|
||||||
import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
|
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
|
||||||
import * as Toast from 'view/com/util/Toast'
|
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
|
||||||
import {ToastContainer} from 'view/com/util/Toast.web'
|
import * as Toast from '#/view/com/util/Toast'
|
||||||
import {Shell} from 'view/shell/index'
|
import {ToastContainer} from '#/view/com/util/Toast.web'
|
||||||
|
import {Shell} from '#/view/shell/index'
|
||||||
import {ThemeProvider as Alf} from '#/alf'
|
import {ThemeProvider as Alf} from '#/alf'
|
||||||
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
|
||||||
import {Provider as PortalProvider} from '#/components/Portal'
|
import {Provider as PortalProvider} from '#/components/Portal'
|
||||||
|
@ -96,9 +96,11 @@ function InnerApp() {
|
||||||
<SelectedFeedProvider>
|
<SelectedFeedProvider>
|
||||||
<UnreadNotifsProvider>
|
<UnreadNotifsProvider>
|
||||||
<BackgroundNotificationPreferencesProvider>
|
<BackgroundNotificationPreferencesProvider>
|
||||||
<SafeAreaProvider>
|
<MutedThreadsProvider>
|
||||||
<Shell />
|
<SafeAreaProvider>
|
||||||
</SafeAreaProvider>
|
<Shell />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</MutedThreadsProvider>
|
||||||
</BackgroundNotificationPreferencesProvider>
|
</BackgroundNotificationPreferencesProvider>
|
||||||
</UnreadNotifsProvider>
|
</UnreadNotifsProvider>
|
||||||
</SelectedFeedProvider>
|
</SelectedFeedProvider>
|
||||||
|
@ -136,21 +138,19 @@ function App() {
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<ShellStateProvider>
|
<ShellStateProvider>
|
||||||
<PrefsStateProvider>
|
<PrefsStateProvider>
|
||||||
<MutedThreadsProvider>
|
<InvitesStateProvider>
|
||||||
<InvitesStateProvider>
|
<ModalStateProvider>
|
||||||
<ModalStateProvider>
|
<DialogStateProvider>
|
||||||
<DialogStateProvider>
|
<LightboxStateProvider>
|
||||||
<LightboxStateProvider>
|
<I18nProvider>
|
||||||
<I18nProvider>
|
<PortalProvider>
|
||||||
<PortalProvider>
|
<InnerApp />
|
||||||
<InnerApp />
|
</PortalProvider>
|
||||||
</PortalProvider>
|
</I18nProvider>
|
||||||
</I18nProvider>
|
</LightboxStateProvider>
|
||||||
</LightboxStateProvider>
|
</DialogStateProvider>
|
||||||
</DialogStateProvider>
|
</ModalStateProvider>
|
||||||
</ModalStateProvider>
|
</InvitesStateProvider>
|
||||||
</InvitesStateProvider>
|
|
||||||
</MutedThreadsProvider>
|
|
||||||
</PrefsStateProvider>
|
</PrefsStateProvider>
|
||||||
</ShellStateProvider>
|
</ShellStateProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|
|
@ -54,8 +54,8 @@ import {useModalControls} from './state/modals'
|
||||||
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
import {useUnreadNotifications} from './state/queries/notifications/unread'
|
||||||
import {useSession} from './state/session'
|
import {useSession} from './state/session'
|
||||||
import {
|
import {
|
||||||
setEmailConfirmationRequested,
|
|
||||||
shouldRequestEmailConfirmation,
|
shouldRequestEmailConfirmation,
|
||||||
|
snoozeEmailConfirmationPrompt,
|
||||||
} from './state/shell/reminders'
|
} from './state/shell/reminders'
|
||||||
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
|
import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings'
|
||||||
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines'
|
||||||
|
@ -585,7 +585,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) {
|
||||||
|
|
||||||
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
if (currentAccount && shouldRequestEmailConfirmation(currentAccount)) {
|
||||||
openModal({name: 'verify-email', showReminder: true})
|
openModal({name: 'verify-email', showReminder: true})
|
||||||
setEmailConfirmationRequested()
|
snoozeEmailConfirmationPrompt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -100,7 +100,15 @@ function KnownFollowersInner({
|
||||||
moderation,
|
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 (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
@ -164,31 +172,54 @@ function KnownFollowersInner({
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
numberOfLines={2}>
|
numberOfLines={2}>
|
||||||
{count > 2 ? (
|
{slice.length >= 2 ? (
|
||||||
<Trans>
|
// 2-n followers, including blocks
|
||||||
Followed by{' '}
|
serverCount > 2 ? (
|
||||||
<Text key={slice[0].profile.did} style={textStyle}>
|
<Trans>
|
||||||
{slice[0].profile.displayName}
|
Followed by{' '}
|
||||||
</Text>
|
<Text key={slice[0].profile.did} style={textStyle}>
|
||||||
,{' '}
|
{slice[0].profile.displayName}
|
||||||
<Text key={slice[1].profile.did} style={textStyle}>
|
</Text>
|
||||||
{slice[1].profile.displayName}
|
,{' '}
|
||||||
</Text>
|
<Text key={slice[1].profile.did} style={textStyle}>
|
||||||
, and{' '}
|
{slice[1].profile.displayName}
|
||||||
<Plural value={count - 2} one="# other" other="# others" />
|
</Text>
|
||||||
</Trans>
|
, and{' '}
|
||||||
) : count === 2 ? (
|
<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>
|
<Trans>
|
||||||
Followed by{' '}
|
Followed by{' '}
|
||||||
<Text key={slice[0].profile.did} style={textStyle}>
|
<Text key={slice[0].profile.did} style={textStyle}>
|
||||||
{slice[0].profile.displayName}
|
{slice[0].profile.displayName}
|
||||||
</Text>{' '}
|
</Text>{' '}
|
||||||
and{' '}
|
and{' '}
|
||||||
<Text key={slice[1].profile.did} style={textStyle}>
|
<Plural
|
||||||
{slice[1].profile.displayName}
|
value={serverCount - 1}
|
||||||
</Text>
|
one="# other"
|
||||||
|
other="# others"
|
||||||
|
/>
|
||||||
</Trans>
|
</Trans>
|
||||||
) : (
|
) : (
|
||||||
|
// only 1
|
||||||
<Trans>
|
<Trans>
|
||||||
Followed by{' '}
|
Followed by{' '}
|
||||||
<Text key={slice[0].profile.did} style={textStyle}>
|
<Text key={slice[0].profile.did} style={textStyle}>
|
||||||
|
|
|
@ -18,8 +18,10 @@ import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function NewskieDialog({
|
export function NewskieDialog({
|
||||||
profile,
|
profile,
|
||||||
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||||
|
disabled?: boolean
|
||||||
}) {
|
}) {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
|
@ -30,18 +32,20 @@ export function NewskieDialog({
|
||||||
const moderation = moderateProfile(profile, moderationOpts)
|
const moderation = moderateProfile(profile, moderationOpts)
|
||||||
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
return sanitizeDisplayName(name, moderation.ui('displayName'))
|
||||||
}, [moderationOpts, profile])
|
}, [moderationOpts, profile])
|
||||||
|
const [now] = React.useState(() => Date.now())
|
||||||
const timeAgo = useGetTimeAgo()
|
const timeAgo = useGetTimeAgo()
|
||||||
const createdAt = profile.createdAt as string | undefined
|
const createdAt = profile.createdAt as string | undefined
|
||||||
const daysOld = React.useMemo(() => {
|
const daysOld = React.useMemo(() => {
|
||||||
if (!createdAt) return Infinity
|
if (!createdAt) return Infinity
|
||||||
return differenceInSeconds(new Date(), new Date(createdAt)) / 86400
|
return differenceInSeconds(now, new Date(createdAt)) / 86400
|
||||||
}, [createdAt])
|
}, [createdAt, now])
|
||||||
|
|
||||||
if (!createdAt || daysOld > 7) return null
|
if (!createdAt || daysOld > 7) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.pr_2xs]}>
|
<View style={[a.pr_2xs]}>
|
||||||
<Button
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
label={_(
|
label={_(
|
||||||
msg`This user is new here. Press for more info about when they joined.`,
|
msg`This user is new here. Press for more info about when they joined.`,
|
||||||
)}
|
)}
|
||||||
|
@ -70,7 +74,7 @@ export function NewskieDialog({
|
||||||
<Text style={[a.text_md]}>
|
<Text style={[a.text_md]}>
|
||||||
<Trans>
|
<Trans>
|
||||||
{profileName} joined Bluesky{' '}
|
{profileName} joined Bluesky{' '}
|
||||||
{timeAgo(createdAt, {format: 'long'})} ago
|
{timeAgo(createdAt, now, {format: 'long'})} ago
|
||||||
</Trans>
|
</Trans>
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -469,7 +469,7 @@ function Inner({
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<ProfileHeaderHandle profile={profileShadow} />
|
<ProfileHeaderHandle profile={profileShadow} disableTaps />
|
||||||
</View>
|
</View>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,8 @@ function PostLabel({
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={desc.sourceAvi}
|
avatar={desc.sourceAvi}
|
||||||
size={size === 'large' ? 16 : 12}
|
size={size === 'large' ? 16 : 12}
|
||||||
|
type="labeler"
|
||||||
|
shape="circle"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, {ComponentProps} from 'react'
|
import React, {ComponentProps} from 'react'
|
||||||
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
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 {msg, Trans} from '@lingui/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
import {useQueryClient} from '@tanstack/react-query'
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
|
@ -45,7 +45,8 @@ export function PostHider({
|
||||||
const [override, setOverride] = React.useState(false)
|
const [override, setOverride] = React.useState(false)
|
||||||
const control = useModerationDetailsDialogControl()
|
const control = useModerationDetailsDialogControl()
|
||||||
const blur =
|
const blur =
|
||||||
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined)
|
modui.blurs[0] ||
|
||||||
|
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
|
||||||
const desc = useModerationCauseDescription(blur)
|
const desc = useModerationCauseDescription(blur)
|
||||||
|
|
||||||
const onBeforePress = React.useCallback(() => {
|
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({
|
const styles = StyleSheet.create({
|
||||||
child: {
|
child: {
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
|
|
|
@ -32,6 +32,8 @@ export type TrackPropertiesMap = {
|
||||||
'Post:ThreadMute': {} // CAN BE SERVER
|
'Post:ThreadMute': {} // CAN BE SERVER
|
||||||
'Post:ThreadUnmute': {} // CAN BE SERVER
|
'Post:ThreadUnmute': {} // CAN BE SERVER
|
||||||
'Post:Reply': {} // CAN BE SERVER
|
'Post:Reply': {} // CAN BE SERVER
|
||||||
|
'Post:EditThreadgateOpened': {}
|
||||||
|
'Post:ThreadgateEdited': {}
|
||||||
// PROFILE events
|
// PROFILE events
|
||||||
'Profile:Follow': {
|
'Profile:Follow': {
|
||||||
username: string
|
username: string
|
||||||
|
|
|
@ -270,7 +270,7 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createThreadgate(
|
export async function createThreadgate(
|
||||||
agent: BskyAgent,
|
agent: BskyAgent,
|
||||||
postUri: string,
|
postUri: string,
|
||||||
threadgate: ThreadgateSetting[],
|
threadgate: ThreadgateSetting[],
|
||||||
|
@ -296,10 +296,17 @@ async function createThreadgate(
|
||||||
}
|
}
|
||||||
|
|
||||||
const postUrip = new AtUri(postUri)
|
const postUrip = new AtUri(postUri)
|
||||||
await agent.api.app.bsky.feed.threadgate.create(
|
await agent.api.com.atproto.repo.putRecord({
|
||||||
{repo: agent.session!.did, rkey: postUrip.rkey},
|
repo: agent.session!.did,
|
||||||
{post: postUri, createdAt: new Date().toISOString(), allow},
|
collection: 'app.bsky.feed.threadgate',
|
||||||
)
|
rkey: postUrip.rkey,
|
||||||
|
record: {
|
||||||
|
$type: 'app.bsky.feed.threadgate',
|
||||||
|
post: postUri,
|
||||||
|
allow,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {useCallback, useMemo} from 'react'
|
import {useCallback} from 'react'
|
||||||
import {msg, plural} from '@lingui/macro'
|
import {msg, plural} from '@lingui/macro'
|
||||||
import {I18nContext, useLingui} from '@lingui/react'
|
import {I18nContext, useLingui} from '@lingui/react'
|
||||||
import {differenceInSeconds} from 'date-fns'
|
import {differenceInSeconds} from 'date-fns'
|
||||||
|
@ -12,25 +12,16 @@ export function useGetTimeAgo() {
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(
|
(
|
||||||
date: number | string | Date,
|
earlier: number | string | Date,
|
||||||
|
later: number | string | Date,
|
||||||
options?: Omit<TimeAgoOptions, 'lingui'>,
|
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 NOW = 5
|
||||||
const MINUTE = 60
|
const MINUTE = 60
|
||||||
const HOUR = MINUTE * 60
|
const HOUR = MINUTE * 60
|
||||||
|
|
|
@ -103,6 +103,8 @@ export type LogEvents = {
|
||||||
'post:unrepost': {
|
'post:unrepost': {
|
||||||
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
|
||||||
}
|
}
|
||||||
|
'post:mute': {}
|
||||||
|
'post:unmute': {}
|
||||||
'profile:follow': {
|
'profile:follow': {
|
||||||
didBecomeMutual: boolean | undefined
|
didBecomeMutual: boolean | undefined
|
||||||
followeeClout: number | undefined
|
followeeClout: number | undefined
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import {Dimensions, Platform} from 'react-native'
|
import {Dimensions} from 'react-native'
|
||||||
|
|
||||||
import {isSafari} from 'lib/browser'
|
import {isSafari} from 'lib/browser'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
|
||||||
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
const {height: SCREEN_HEIGHT} = Dimensions.get('window')
|
||||||
|
|
||||||
const IFRAME_HOST = isWeb
|
const IFRAME_HOST = isWeb
|
||||||
|
@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlp.hostname === 'media.tenor.com') {
|
const tenorGif = parseTenorGif(urlp)
|
||||||
let [_, id, filename] = urlp.pathname.split('/')
|
if (tenorGif.success) {
|
||||||
|
const {playerUri, dimensions} = tenorGif
|
||||||
|
|
||||||
const h = urlp.searchParams.get('hh')
|
return {
|
||||||
const w = urlp.searchParams.get('ww')
|
type: 'tenor_gif',
|
||||||
let dimensions
|
source: 'tenor',
|
||||||
if (h && w) {
|
isGif: true,
|
||||||
dimensions = {
|
hideDetails: true,
|
||||||
height: Number(h),
|
playerUri,
|
||||||
width: Number(w),
|
dimensions,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,3 +19,14 @@ export function getAge(birthDate: Date): number {
|
||||||
}
|
}
|
||||||
return age
|
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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -12,8 +12,10 @@ import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
export function ProfileHeaderHandle({
|
export function ProfileHeaderHandle({
|
||||||
profile,
|
profile,
|
||||||
|
disableTaps,
|
||||||
}: {
|
}: {
|
||||||
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
|
||||||
|
disableTaps?: boolean
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const invalidHandle = isInvalidHandle(profile.handle)
|
const invalidHandle = isInvalidHandle(profile.handle)
|
||||||
|
@ -21,8 +23,8 @@ export function ProfileHeaderHandle({
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
style={[a.flex_row, a.gap_xs, a.align_center]}
|
style={[a.flex_row, a.gap_xs, a.align_center]}
|
||||||
pointerEvents={isAndroid ? 'box-only' : 'auto'}>
|
pointerEvents={disableTaps ? 'none' : isAndroid ? 'box-only' : 'auto'}>
|
||||||
<NewskieDialog profile={profile} />
|
<NewskieDialog profile={profile} disabled={disableTaps} />
|
||||||
{profile.viewer?.followedBy && !blockHide ? (
|
{profile.viewer?.followedBy && !blockHide ? (
|
||||||
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
|
<View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
|
||||||
<Text style={[t.atoms.text, a.text_sm]}>
|
<Text style={[t.atoms.text, a.text_sm]}>
|
||||||
|
|
|
@ -82,7 +82,7 @@ let ProfileHeaderLabeler = ({
|
||||||
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
|
||||||
const canSubscribe =
|
const canSubscribe =
|
||||||
isSubscribed ||
|
isSubscribed ||
|
||||||
(preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
|
(preferences ? preferences?.moderationPrefs.labelers.length <= 20 : false)
|
||||||
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
|
||||||
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
|
||||||
useUnlikeMutation()
|
useUnlikeMutation()
|
||||||
|
@ -328,8 +328,8 @@ function CantSubscribePrompt({
|
||||||
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
|
<Prompt.TitleText>Unable to subscribe</Prompt.TitleText>
|
||||||
<Prompt.DescriptionText>
|
<Prompt.DescriptionText>
|
||||||
<Trans>
|
<Trans>
|
||||||
We're sorry! You can only subscribe to ten labelers, and you've
|
We're sorry! You can only subscribe to twenty labelers, and you've
|
||||||
reached your limit of ten.
|
reached your limit of twenty.
|
||||||
</Trans>
|
</Trans>
|
||||||
</Prompt.DescriptionText>
|
</Prompt.DescriptionText>
|
||||||
<Prompt.Actions>
|
<Prompt.Actions>
|
||||||
|
|
|
@ -252,7 +252,6 @@ export function useSubmitSignup({
|
||||||
dispatch({type: 'setIsLoading', value: true})
|
dispatch({type: 'setIsLoading', value: true})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
|
|
||||||
await createAccount({
|
await createAccount({
|
||||||
service: state.serviceUrl,
|
service: state.serviceUrl,
|
||||||
email: state.email,
|
email: state.email,
|
||||||
|
@ -262,8 +261,12 @@ export function useSubmitSignup({
|
||||||
inviteCode: state.inviteCode.trim(),
|
inviteCode: state.inviteCode.trim(),
|
||||||
verificationCode: verificationCode,
|
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) {
|
} catch (e: any) {
|
||||||
onboardingDispatch({type: 'skip'}) // undo starting the onboard
|
|
||||||
let errMsg = e.toString()
|
let errMsg = e.toString()
|
||||||
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
|
||||||
dispatch({
|
dispatch({
|
||||||
|
|
|
@ -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])
|
||||||
|
}
|
|
@ -70,7 +70,8 @@ export interface SelfLabelModal {
|
||||||
export interface ThreadgateModal {
|
export interface ThreadgateModal {
|
||||||
name: 'threadgate'
|
name: 'threadgate'
|
||||||
settings: ThreadgateSetting[]
|
settings: ThreadgateSetting[]
|
||||||
onChange: (settings: ThreadgateSetting[]) => void
|
onChange?: (settings: ThreadgateSetting[]) => void
|
||||||
|
onConfirm?: (settings: ThreadgateSetting[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeHandleModal {
|
export interface ChangeHandleModal {
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -74,7 +74,6 @@ export const schema = z.object({
|
||||||
flickr: z.enum(externalEmbedOptions).optional(),
|
flickr: z.enum(externalEmbedOptions).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
mutedThreads: z.array(z.string()), // should move to server
|
|
||||||
invites: z.object({
|
invites: z.object({
|
||||||
copiedInvites: z.array(z.string()),
|
copiedInvites: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
|
@ -88,6 +87,8 @@ export const schema = z.object({
|
||||||
disableHaptics: z.boolean().optional(),
|
disableHaptics: z.boolean().optional(),
|
||||||
disableAutoplay: z.boolean().optional(),
|
disableAutoplay: z.boolean().optional(),
|
||||||
kawaii: z.boolean().optional(),
|
kawaii: z.boolean().optional(),
|
||||||
|
/** @deprecated */
|
||||||
|
mutedThreads: z.array(z.string()),
|
||||||
})
|
})
|
||||||
export type Schema = z.infer<typeof schema>
|
export type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
|
|
|
@ -26,7 +26,6 @@ import {
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
|
|
||||||
import {useMutedThreads} from '#/state/muted-threads'
|
|
||||||
import {useAgent} from '#/state/session'
|
import {useAgent} from '#/state/session'
|
||||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||||
import {STALE} from '..'
|
import {STALE} from '..'
|
||||||
|
@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const threadMutes = useMutedThreads()
|
|
||||||
const unreads = useUnreadNotificationsApi()
|
const unreads = useUnreadNotificationsApi()
|
||||||
const enabled = opts?.enabled !== false
|
const enabled = opts?.enabled !== false
|
||||||
const lastPageCountRef = useRef(0)
|
const lastPageCountRef = useRef(0)
|
||||||
|
@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
cursor: pageParam,
|
cursor: pageParam,
|
||||||
queryClient,
|
queryClient,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
threadMutes,
|
|
||||||
fetchAdditionalData: true,
|
fetchAdditionalData: true,
|
||||||
})
|
})
|
||||||
).page
|
).page
|
||||||
|
|
|
@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
|
||||||
|
|
||||||
import BroadcastChannel from '#/lib/broadcast'
|
import BroadcastChannel from '#/lib/broadcast'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {useMutedThreads} from '#/state/muted-threads'
|
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
import {resetBadgeCount} from 'lib/notifications/notifications'
|
import {resetBadgeCount} from 'lib/notifications/notifications'
|
||||||
import {useModerationOpts} from '../../preferences/moderation-opts'
|
import {useModerationOpts} from '../../preferences/moderation-opts'
|
||||||
|
@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
const agent = useAgent()
|
const agent = useAgent()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const moderationOpts = useModerationOpts()
|
const moderationOpts = useModerationOpts()
|
||||||
const threadMutes = useMutedThreads()
|
|
||||||
|
|
||||||
const [numUnread, setNumUnread] = React.useState('')
|
const [numUnread, setNumUnread] = React.useState('')
|
||||||
|
|
||||||
|
@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
limit: 40,
|
limit: 40,
|
||||||
queryClient,
|
queryClient,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
threadMutes,
|
|
||||||
|
|
||||||
// only fetch subjects when the page is going to be used
|
// only fetch subjects when the page is going to be used
|
||||||
// in the notifications query, otherwise skip it
|
// 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
|
checkUnreadRef.current = api.checkUnread
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
AppBskyEmbedRecord,
|
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedLike,
|
AppBskyFeedLike,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
|
@ -28,7 +27,6 @@ export async function fetchPage({
|
||||||
limit,
|
limit,
|
||||||
queryClient,
|
queryClient,
|
||||||
moderationOpts,
|
moderationOpts,
|
||||||
threadMutes,
|
|
||||||
fetchAdditionalData,
|
fetchAdditionalData,
|
||||||
}: {
|
}: {
|
||||||
agent: BskyAgent
|
agent: BskyAgent
|
||||||
|
@ -36,7 +34,6 @@ export async function fetchPage({
|
||||||
limit: number
|
limit: number
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
moderationOpts: ModerationOpts | undefined
|
moderationOpts: ModerationOpts | undefined
|
||||||
threadMutes: string[]
|
|
||||||
fetchAdditionalData: boolean
|
fetchAdditionalData: boolean
|
||||||
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
|
}): Promise<{page: FeedPage; indexedAt: string | undefined}> {
|
||||||
const res = await agent.listNotifications({
|
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()
|
let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
|
||||||
if (Number.isNaN(seenAt.getTime())) {
|
if (Number.isNaN(seenAt.getTime())) {
|
||||||
seenAt = new Date()
|
seenAt = new Date()
|
||||||
|
@ -207,45 +199,3 @@ function getSubjectUri(
|
||||||
return notif.reasonSubject
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -78,6 +78,7 @@ export interface FeedPostSliceItem {
|
||||||
feedContext: string | undefined
|
feedContext: string | undefined
|
||||||
moderation: ModerationDecision
|
moderation: ModerationDecision
|
||||||
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
parentAuthor?: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
isParentBlocked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedPostSlice {
|
export interface FeedPostSlice {
|
||||||
|
@ -311,6 +312,10 @@ export function usePostFeedQuery(
|
||||||
const parentAuthor =
|
const parentAuthor =
|
||||||
item.reply?.parent?.author ??
|
item.reply?.parent?.author ??
|
||||||
slice.items[i + 1]?.reply?.grandparentAuthor
|
slice.items[i + 1]?.reply?.grandparentAuthor
|
||||||
|
const replyRef = item.reply
|
||||||
|
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
|
||||||
|
replyRef?.parent,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
|
||||||
|
@ -324,6 +329,7 @@ export function usePostFeedQuery(
|
||||||
feedContext: item.feedContext || slice.feedContext,
|
feedContext: item.feedContext || slice.feedContext,
|
||||||
moderation: moderations[i],
|
moderation: moderations[i],
|
||||||
parentAuthor,
|
parentAuthor,
|
||||||
|
isParentBlocked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|
|
@ -31,7 +31,8 @@ import {
|
||||||
getEmbeddedPost,
|
getEmbeddedPost,
|
||||||
} from './util'
|
} 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]
|
export const RQKEY = (uri: string) => [RQKEY_ROOT, uri]
|
||||||
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
type ThreadViewNode = AppBskyFeedGetPostThread.OutputSchema['thread']
|
||||||
|
|
||||||
|
@ -90,7 +91,10 @@ export function usePostThreadQuery(uri: string | undefined) {
|
||||||
gcTime: 0,
|
gcTime: 0,
|
||||||
queryKey: RQKEY(uri || ''),
|
queryKey: RQKEY(uri || ''),
|
||||||
async queryFn() {
|
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) {
|
if (res.success) {
|
||||||
const thread = responseToThreadNodes(res.data.thread)
|
const thread = responseToThreadNodes(res.data.thread)
|
||||||
annotateSelfThread(thread)
|
annotateSelfThread(thread)
|
||||||
|
@ -287,7 +291,12 @@ function annotateSelfThread(thread: ThreadNode) {
|
||||||
selfThreadNode.ctx.isSelfThread = true
|
selfThreadNode.ctx.isSelfThread = true
|
||||||
}
|
}
|
||||||
const last = selfThreadNodes[selfThreadNodes.length - 1]
|
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
|
last.ctx.hasMoreSelfThread = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
|
||||||
import {updatePostShadow} from '#/state/cache/post-shadow'
|
import {updatePostShadow} from '#/state/cache/post-shadow'
|
||||||
import {Shadow} from '#/state/cache/types'
|
import {Shadow} from '#/state/cache/types'
|
||||||
import {useAgent, useSession} from '#/state/session'
|
import {useAgent, useSession} from '#/state/session'
|
||||||
|
import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
|
||||||
import {findProfileQueryData} from './profile'
|
import {findProfileQueryData} from './profile'
|
||||||
|
|
||||||
const RQKEY_ROOT = 'post'
|
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})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,38 @@
|
||||||
|
import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
|
||||||
|
|
||||||
export type ThreadgateSetting =
|
export type ThreadgateSetting =
|
||||||
| {type: 'nobody'}
|
| {type: 'nobody'}
|
||||||
| {type: 'mention'}
|
| {type: 'mention'}
|
||||||
| {type: 'following'}
|
| {type: 'following'}
|
||||||
| {type: 'list'; list: string}
|
| {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[]
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
import {tryFetchGates} from '#/lib/statsig/statsig'
|
import {tryFetchGates} from '#/lib/statsig/statsig'
|
||||||
import {getAge} from '#/lib/strings/time'
|
import {getAge} from '#/lib/strings/time'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
|
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
|
||||||
import {
|
import {
|
||||||
configureModerationForAccount,
|
configureModerationForAccount,
|
||||||
configureModerationForGuest,
|
configureModerationForGuest,
|
||||||
|
@ -37,21 +38,7 @@ export async function createAgentAndResume(
|
||||||
}
|
}
|
||||||
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
|
||||||
const moderation = configureModerationForAccount(agent, storedAccount)
|
const moderation = configureModerationForAccount(agent, storedAccount)
|
||||||
const prevSession: AtpSessionData = {
|
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
if (isSessionExpired(storedAccount)) {
|
if (isSessionExpired(storedAccount)) {
|
||||||
await networkRetry(1, () => agent.resumeSession(prevSession))
|
await networkRetry(1, () => agent.resumeSession(prevSession))
|
||||||
} else {
|
} else {
|
||||||
|
@ -191,6 +178,13 @@ export async function createAgentAndCreateAccount(
|
||||||
agent.setPersonalDetails({birthDate: birthDate.toISOString()})
|
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)
|
return prepareAgent(agent, gates, moderation, onSessionChange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,3 +239,23 @@ export function agentToSessionAccount(
|
||||||
pdsUrl: agent.pdsUrl?.toString(),
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
createAgentAndCreateAccount,
|
createAgentAndCreateAccount,
|
||||||
createAgentAndLogin,
|
createAgentAndLogin,
|
||||||
createAgentAndResume,
|
createAgentAndResume,
|
||||||
|
sessionAccountToSession,
|
||||||
} from './agent'
|
} from './agent'
|
||||||
import {getInitialState, reducer} from './reducer'
|
import {getInitialState, reducer} from './reducer'
|
||||||
|
|
||||||
|
@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||||
if (syncedAccount.did !== state.currentAgentState.did) {
|
if (syncedAccount.did !== state.currentAgentState.did) {
|
||||||
resumeSession(syncedAccount)
|
resumeSession(syncedAccount)
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore we checked for `refreshJwt` above
|
const agent = state.currentAgentState.agent as BskyAgent
|
||||||
state.currentAgentState.agent.session = syncedAccount
|
agent.session = sessionAccountToSession(syncedAccount)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
export function init() {}
|
|
||||||
|
|
||||||
export function shouldRequestEmailConfirmation() {
|
export function shouldRequestEmailConfirmation() {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEmailConfirmationRequested() {}
|
export function snoozeEmailConfirmationPrompt() {}
|
||||||
|
|
|
@ -1,36 +1,45 @@
|
||||||
|
import {simpleAreDatesEqual} from '#/lib/strings/time'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import * as persisted from '#/state/persisted'
|
import * as persisted from '#/state/persisted'
|
||||||
import {toHashCode} from 'lib/strings/helpers'
|
|
||||||
import {isOnboardingActive} from './onboarding'
|
|
||||||
import {SessionAccount} from '../session'
|
import {SessionAccount} from '../session'
|
||||||
|
import {isOnboardingActive} from './onboarding'
|
||||||
|
|
||||||
export function shouldRequestEmailConfirmation(account: SessionAccount) {
|
export function shouldRequestEmailConfirmation(account: SessionAccount) {
|
||||||
if (!account) {
|
// ignore logged out
|
||||||
return false
|
if (!account) return false
|
||||||
}
|
// ignore confirmed accounts, this is the success state of this reminder
|
||||||
if (account.emailConfirmed) {
|
if (account.emailConfirmed) return false
|
||||||
return false
|
// wait for onboarding to complete
|
||||||
}
|
if (isOnboardingActive()) return false
|
||||||
if (isOnboardingActive()) {
|
|
||||||
return false
|
const snoozedAt = persisted.get('reminders').lastEmailConfirm
|
||||||
}
|
|
||||||
// only prompt once
|
|
||||||
if (persisted.get('reminders').lastEmailConfirm) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const today = new Date()
|
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
|
logger.debug('Checking email confirmation reminder', {
|
||||||
// this feature rolls out)
|
today,
|
||||||
const code = toHashCode(account.did) % 7
|
snoozedAt,
|
||||||
if (code !== today.getDay() && code !== (today.getDay() + 1) % 7) {
|
})
|
||||||
|
|
||||||
|
// never been snoozed, new account
|
||||||
|
if (!snoozedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// already snoozed today
|
||||||
|
if (simpleAreDatesEqual(new Date(Date.parse(snoozedAt)), new Date())) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
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.write('reminders', {
|
||||||
...persisted.get('reminders'),
|
...persisted.get('reminders'),
|
||||||
lastEmailConfirm: new Date().toISOString(),
|
lastEmailConfirm,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,9 +26,11 @@ export const snapPoints = ['60%']
|
||||||
export function Component({
|
export function Component({
|
||||||
settings,
|
settings,
|
||||||
onChange,
|
onChange,
|
||||||
|
onConfirm,
|
||||||
}: {
|
}: {
|
||||||
settings: ThreadgateSetting[]
|
settings: ThreadgateSetting[]
|
||||||
onChange: (settings: ThreadgateSetting[]) => void
|
onChange?: (settings: ThreadgateSetting[]) => void
|
||||||
|
onConfirm?: (settings: ThreadgateSetting[]) => void
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {closeModal} = useModalControls()
|
const {closeModal} = useModalControls()
|
||||||
|
@ -38,12 +40,12 @@ export function Component({
|
||||||
|
|
||||||
const onPressEverybody = () => {
|
const onPressEverybody = () => {
|
||||||
setSelected([])
|
setSelected([])
|
||||||
onChange([])
|
onChange?.([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressNobody = () => {
|
const onPressNobody = () => {
|
||||||
setSelected([{type: 'nobody'}])
|
setSelected([{type: 'nobody'}])
|
||||||
onChange([{type: 'nobody'}])
|
onChange?.([{type: 'nobody'}])
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPressAudience = (setting: ThreadgateSetting) => {
|
const onPressAudience = (setting: ThreadgateSetting) => {
|
||||||
|
@ -57,7 +59,7 @@ export function Component({
|
||||||
newSelected.splice(i, 1)
|
newSelected.splice(i, 1)
|
||||||
}
|
}
|
||||||
setSelected(newSelected)
|
setSelected(newSelected)
|
||||||
onChange(newSelected)
|
onChange?.(newSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -124,6 +126,7 @@ export function Component({
|
||||||
testID="confirmBtn"
|
testID="confirmBtn"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
closeModal()
|
closeModal()
|
||||||
|
onConfirm?.(selected)
|
||||||
}}
|
}}
|
||||||
style={styles.btn}
|
style={styles.btn}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
|
|
|
@ -8,6 +8,7 @@ import {
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyActorDefs,
|
||||||
|
AppBskyEmbedExternal,
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedRecordWithMedia,
|
AppBskyEmbedRecordWithMedia,
|
||||||
AppBskyFeedDefs,
|
AppBskyFeedDefs,
|
||||||
|
@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
|
||||||
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
|
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
|
||||||
|
|
||||||
import hairlineWidth = StyleSheet.hairlineWidth
|
import hairlineWidth = StyleSheet.hairlineWidth
|
||||||
|
import {parseTenorGif} from '#/lib/strings/embed-player'
|
||||||
|
|
||||||
const MAX_AUTHORS = 5
|
const MAX_AUTHORS = 5
|
||||||
|
|
||||||
|
@ -465,17 +467,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
if (post && AppBskyFeedPost.isRecord(post?.record)) {
|
||||||
const text = post.record.text
|
const text = post.record.text
|
||||||
const images = AppBskyEmbedImages.isView(post.embed)
|
let images
|
||||||
? post.embed.images
|
let isGif = false
|
||||||
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
|
|
||||||
AppBskyEmbedImages.isView(post.embed.media)
|
if (AppBskyEmbedImages.isView(post.embed)) {
|
||||||
? post.embed.media.images
|
images = post.embed.images
|
||||||
: undefined
|
} 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
|
||||||
{images && images.length > 0 && (
|
{images && images.length > 0 && (
|
||||||
<ImageHorzList images={images} style={styles.additionalPostImages} />
|
<ImageHorzList
|
||||||
|
images={images}
|
||||||
|
style={styles.additionalPostImages}
|
||||||
|
gif={isGif}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: -1,
|
top: '100%',
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: -1,
|
top: '100%',
|
||||||
borderBottomWidth: hairlineWidth,
|
borderBottomWidth: hairlineWidth,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -331,7 +331,11 @@ export function PostThread({
|
||||||
<PostThreadShowHiddenReplies
|
<PostThreadShowHiddenReplies
|
||||||
type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
|
type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
|
||||||
onPress={() =>
|
onPress={() =>
|
||||||
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider)
|
setHiddenRepliesState(
|
||||||
|
item === SHOW_HIDDEN_REPLIES
|
||||||
|
? HiddenRepliesState.Show
|
||||||
|
: HiddenRepliesState.ShowAndOverridePostHider,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
hideTopBorder={index === 0}
|
hideTopBorder={index === 0}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles'
|
||||||
import {countLines} from 'lib/strings/helpers'
|
import {countLines} from 'lib/strings/helpers'
|
||||||
import {niceDate} from 'lib/strings/time'
|
import {niceDate} from 'lib/strings/time'
|
||||||
import {s} from 'lib/styles'
|
import {s} from 'lib/styles'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isNative, isWeb} from 'platform/detection'
|
||||||
import {useSession} from 'state/session'
|
import {useSession} from 'state/session'
|
||||||
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
|
@ -189,6 +189,7 @@ let PostThreadItemLoaded = ({
|
||||||
const itemTitle = _(msg`Post by ${post.author.handle}`)
|
const itemTitle = _(msg`Post by ${post.author.handle}`)
|
||||||
const authorHref = makeProfileLink(post.author)
|
const authorHref = makeProfileLink(post.author)
|
||||||
const authorTitle = post.author.handle
|
const authorTitle = post.author.handle
|
||||||
|
const isThreadAuthor = getThreadAuthor(post, record) === currentAccount?.did
|
||||||
const likesHref = React.useMemo(() => {
|
const likesHref = React.useMemo(() => {
|
||||||
const urip = new AtUri(post.uri)
|
const urip = new AtUri(post.uri)
|
||||||
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
return makeProfileLink(post.author, 'post', urip.rkey, 'liked-by')
|
||||||
|
@ -395,7 +396,11 @@ let PostThreadItemLoaded = ({
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<WhoCanReply post={post} />
|
<WhoCanReply
|
||||||
|
post={post}
|
||||||
|
isThreadAuthor={isThreadAuthor}
|
||||||
|
style={{borderBottomWidth: isNative ? 1 : 0}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
@ -578,7 +583,9 @@ let PostThreadItemLoaded = ({
|
||||||
post={post}
|
post={post}
|
||||||
style={{
|
style={{
|
||||||
marginTop: 4,
|
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({
|
const styles = StyleSheet.create({
|
||||||
outer: {
|
outer: {
|
||||||
borderTopWidth: hairlineWidth,
|
borderTopWidth: hairlineWidth,
|
||||||
|
|
|
@ -56,6 +56,7 @@ interface FeedItemProps {
|
||||||
isThreadParent?: boolean
|
isThreadParent?: boolean
|
||||||
feedContext: string | undefined
|
feedContext: string | undefined
|
||||||
hideTopBorder?: boolean
|
hideTopBorder?: boolean
|
||||||
|
isParentBlocked?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FeedItem({
|
export function FeedItem({
|
||||||
|
@ -70,6 +71,7 @@ export function FeedItem({
|
||||||
isThreadLastChild,
|
isThreadLastChild,
|
||||||
isThreadParent,
|
isThreadParent,
|
||||||
hideTopBorder,
|
hideTopBorder,
|
||||||
|
isParentBlocked,
|
||||||
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
|
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
|
||||||
const postShadowed = usePostShadow(post)
|
const postShadowed = usePostShadow(post)
|
||||||
const richText = useMemo(
|
const richText = useMemo(
|
||||||
|
@ -100,6 +102,7 @@ export function FeedItem({
|
||||||
isThreadLastChild={isThreadLastChild}
|
isThreadLastChild={isThreadLastChild}
|
||||||
isThreadParent={isThreadParent}
|
isThreadParent={isThreadParent}
|
||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
|
isParentBlocked={isParentBlocked}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -119,6 +122,7 @@ let FeedItemInner = ({
|
||||||
isThreadLastChild,
|
isThreadLastChild,
|
||||||
isThreadParent,
|
isThreadParent,
|
||||||
hideTopBorder,
|
hideTopBorder,
|
||||||
|
isParentBlocked,
|
||||||
}: FeedItemProps & {
|
}: FeedItemProps & {
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
post: Shadow<AppBskyFeedDefs.PostView>
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
|
@ -320,7 +324,7 @@ let FeedItemInner = ({
|
||||||
onOpenAuthor={onOpenAuthor}
|
onOpenAuthor={onOpenAuthor}
|
||||||
/>
|
/>
|
||||||
{!isThreadChild && showReplyTo && parentAuthor && (
|
{!isThreadChild && showReplyTo && parentAuthor && (
|
||||||
<ReplyToLabel profile={parentAuthor} />
|
<ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
|
||||||
)}
|
)}
|
||||||
<LabelsOnMyPost post={post} />
|
<LabelsOnMyPost post={post} />
|
||||||
<PostContent
|
<PostContent
|
||||||
|
@ -409,9 +413,14 @@ let PostContent = ({
|
||||||
}
|
}
|
||||||
PostContent = memo(PostContent)
|
PostContent = memo(PostContent)
|
||||||
|
|
||||||
function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
|
function ReplyToLabel({
|
||||||
|
profile,
|
||||||
|
blocked,
|
||||||
|
}: {
|
||||||
|
profile: AppBskyActorDefs.ProfileViewBasic
|
||||||
|
blocked?: boolean
|
||||||
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
|
@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
|
||||||
style={[pal.textLight, s.mr2]}
|
style={[pal.textLight, s.mr2]}
|
||||||
lineHeight={1.2}
|
lineHeight={1.2}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
<Trans context="description">
|
{blocked ? (
|
||||||
Reply to{' '}
|
<Trans context="description">Reply to a blocked post</Trans>
|
||||||
<ProfileHoverCard inline did={profile.did}>
|
) : (
|
||||||
<TextLinkOnWebOnly
|
<Trans context="description">
|
||||||
type="md"
|
Reply to{' '}
|
||||||
style={pal.textLight}
|
<ProfileHoverCard inline did={profile.did}>
|
||||||
lineHeight={1.2}
|
<TextLinkOnWebOnly
|
||||||
numberOfLines={1}
|
type="md"
|
||||||
href={makeProfileLink(profile)}
|
style={pal.textLight}
|
||||||
text={
|
lineHeight={1.2}
|
||||||
profile.displayName
|
numberOfLines={1}
|
||||||
? sanitizeDisplayName(profile.displayName)
|
href={makeProfileLink(profile)}
|
||||||
: sanitizeHandle(profile.handle)
|
text={
|
||||||
}
|
profile.displayName
|
||||||
/>
|
? sanitizeDisplayName(profile.displayName)
|
||||||
</ProfileHoverCard>
|
: sanitizeHandle(profile.handle)
|
||||||
</Trans>
|
}
|
||||||
|
/>
|
||||||
|
</ProfileHoverCard>
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -34,6 +34,7 @@ let FeedSlice = ({
|
||||||
isThreadParent={isThreadParentAt(slice.items, 0)}
|
isThreadParent={isThreadParentAt(slice.items, 0)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, 0)}
|
isThreadChild={isThreadChildAt(slice.items, 0)}
|
||||||
hideTopBorder={hideTopBorder}
|
hideTopBorder={hideTopBorder}
|
||||||
|
isParentBlocked={slice.items[0].isParentBlocked}
|
||||||
/>
|
/>
|
||||||
<FeedItem
|
<FeedItem
|
||||||
key={slice.items[1]._reactKey}
|
key={slice.items[1]._reactKey}
|
||||||
|
@ -46,6 +47,7 @@ let FeedSlice = ({
|
||||||
moderation={slice.items[1].moderation}
|
moderation={slice.items[1].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, 1)}
|
isThreadParent={isThreadParentAt(slice.items, 1)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, 1)}
|
isThreadChild={isThreadChildAt(slice.items, 1)}
|
||||||
|
isParentBlocked={slice.items[1].isParentBlocked}
|
||||||
/>
|
/>
|
||||||
<ViewFullThread slice={slice} />
|
<ViewFullThread slice={slice} />
|
||||||
<FeedItem
|
<FeedItem
|
||||||
|
@ -59,6 +61,7 @@ let FeedSlice = ({
|
||||||
moderation={slice.items[last].moderation}
|
moderation={slice.items[last].moderation}
|
||||||
isThreadParent={isThreadParentAt(slice.items, last)}
|
isThreadParent={isThreadParentAt(slice.items, last)}
|
||||||
isThreadChild={isThreadChildAt(slice.items, last)}
|
isThreadChild={isThreadChildAt(slice.items, last)}
|
||||||
|
isParentBlocked={slice.items[2].isParentBlocked}
|
||||||
isThreadLastChild
|
isThreadLastChild
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -82,6 +85,7 @@ let FeedSlice = ({
|
||||||
isThreadLastChild={
|
isThreadLastChild={
|
||||||
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
|
isThreadChildAt(slice.items, i) && slice.items.length === i + 1
|
||||||
}
|
}
|
||||||
|
isParentBlocked={slice.items[i].isParentBlocked}
|
||||||
hideTopBorder={hideTopBorder && i === 0}
|
hideTopBorder={hideTopBorder && i === 0}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,128 +1,172 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import {StyleProp, View, ViewStyle} from 'react-native'
|
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
|
||||||
import {
|
import {AppBskyFeedDefs, AppBskyGraphDefs, AtUri} from '@atproto/api'
|
||||||
AppBskyFeedDefs,
|
import {msg, Trans} from '@lingui/macro'
|
||||||
AppBskyFeedThreadgate,
|
import {useLingui} from '@lingui/react'
|
||||||
AppBskyGraphDefs,
|
import {useQueryClient} from '@tanstack/react-query'
|
||||||
AtUri,
|
|
||||||
} from '@atproto/api'
|
|
||||||
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
|
|
||||||
import {Trans} from '@lingui/macro'
|
|
||||||
|
|
||||||
|
import {useAnalytics} from '#/lib/analytics/analytics'
|
||||||
|
import {createThreadgate} from '#/lib/api'
|
||||||
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
|
||||||
import {usePalette} from '#/lib/hooks/usePalette'
|
import {usePalette} from '#/lib/hooks/usePalette'
|
||||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|
||||||
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
|
||||||
import {colors} from '#/lib/styles'
|
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 {TextLink} from '../util/Link'
|
||||||
import {Text} from '../util/text/Text'
|
import {Text} from '../util/text/Text'
|
||||||
|
|
||||||
export function WhoCanReply({
|
export function WhoCanReply({
|
||||||
post,
|
post,
|
||||||
|
isThreadAuthor,
|
||||||
style,
|
style,
|
||||||
}: {
|
}: {
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
|
isThreadAuthor: boolean
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
|
const {track} = useAnalytics()
|
||||||
|
const {_} = useLingui()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const {isMobile} = useWebMediaQueries()
|
const agent = useAgent()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const {openModal} = useModalControls()
|
||||||
const containerStyles = useColorSchemeStyle(
|
const containerStyles = useColorSchemeStyle(
|
||||||
{
|
{
|
||||||
borderColor: pal.colors.unreadNotifBorder,
|
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: pal.colors.unreadNotifBg,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
borderColor: pal.colors.unreadNotifBorder,
|
|
||||||
backgroundColor: pal.colors.unreadNotifBg,
|
backgroundColor: pal.colors.unreadNotifBg,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const iconStyles = useColorSchemeStyle(
|
|
||||||
{
|
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
backgroundColor: colors.blue3,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const textStyles = useColorSchemeStyle(
|
const textStyles = useColorSchemeStyle(
|
||||||
{color: colors.gray7},
|
{color: colors.blue5},
|
||||||
{color: colors.blue1},
|
{color: colors.blue1},
|
||||||
)
|
)
|
||||||
const record = React.useMemo(
|
const hoverStyles = useColorSchemeStyle(
|
||||||
() =>
|
{
|
||||||
post.threadgate &&
|
backgroundColor: colors.white,
|
||||||
AppBskyFeedThreadgate.isRecord(post.threadgate.record) &&
|
},
|
||||||
AppBskyFeedThreadgate.validateRecord(post.threadgate.record).success
|
{
|
||||||
? post.threadgate.record
|
backgroundColor: pal.colors.background,
|
||||||
: null,
|
},
|
||||||
|
)
|
||||||
|
const settings = React.useMemo(
|
||||||
|
() => threadgateViewToSettings(post.threadgate),
|
||||||
[post],
|
[post],
|
||||||
)
|
)
|
||||||
if (record) {
|
const isRootPost = !('reply' in post.record)
|
||||||
return (
|
|
||||||
<View
|
const onPressEdit = () => {
|
||||||
style={[
|
track('Post:EditThreadgateOpened')
|
||||||
{
|
if (isNative && Keyboard.isVisible()) {
|
||||||
flexDirection: 'row',
|
Keyboard.dismiss()
|
||||||
alignItems: 'center',
|
}
|
||||||
gap: isMobile ? 8 : 10,
|
openModal({
|
||||||
paddingHorizontal: isMobile ? 16 : 18,
|
name: 'threadgate',
|
||||||
paddingVertical: 12,
|
settings,
|
||||||
borderWidth: 1,
|
async onConfirm(newSettings: ThreadgateSetting[]) {
|
||||||
borderLeftWidth: isMobile ? 0 : 1,
|
try {
|
||||||
borderRightWidth: isMobile ? 0 : 1,
|
if (newSettings.length) {
|
||||||
},
|
await createThreadgate(agent, post.uri, newSettings)
|
||||||
containerStyles,
|
} else {
|
||||||
style,
|
await agent.api.com.atproto.repo.deleteRecord({
|
||||||
]}>
|
repo: agent.session!.did,
|
||||||
<View
|
collection: 'app.bsky.feed.threadgate',
|
||||||
style={[
|
rkey: new AtUri(post.uri).rkey,
|
||||||
{
|
})
|
||||||
flexDirection: 'row',
|
}
|
||||||
alignItems: 'center',
|
Toast.show('Thread settings updated')
|
||||||
justifyContent: 'center',
|
queryClient.invalidateQueries({
|
||||||
width: 32,
|
queryKey: [POST_THREAD_RQKEY_ROOT],
|
||||||
height: 32,
|
})
|
||||||
borderRadius: 19,
|
track('Post:ThreadgateEdited')
|
||||||
},
|
} catch (err) {
|
||||||
iconStyles,
|
Toast.show(
|
||||||
]}>
|
'There was an issue. Please check your internet connection and try again.',
|
||||||
<FontAwesomeIcon
|
)
|
||||||
icon={['far', 'comments']}
|
logger.error('Failed to edit threadgate', {message: err})
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
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({
|
function Rule({
|
||||||
|
@ -130,15 +174,15 @@ function Rule({
|
||||||
post,
|
post,
|
||||||
lists,
|
lists,
|
||||||
}: {
|
}: {
|
||||||
rule: any
|
rule: ThreadgateSetting
|
||||||
post: AppBskyFeedDefs.PostView
|
post: AppBskyFeedDefs.PostView
|
||||||
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
if (AppBskyFeedThreadgate.isMentionRule(rule)) {
|
if (rule.type === 'mention') {
|
||||||
return <Trans>mentioned users</Trans>
|
return <Trans>mentioned users</Trans>
|
||||||
}
|
}
|
||||||
if (AppBskyFeedThreadgate.isFollowingRule(rule)) {
|
if (rule.type === 'following') {
|
||||||
return (
|
return (
|
||||||
<Trans>
|
<Trans>
|
||||||
users followed by{' '}
|
users followed by{' '}
|
||||||
|
@ -151,7 +195,7 @@ function Rule({
|
||||||
</Trans>
|
</Trans>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (AppBskyFeedThreadgate.isListRule(rule)) {
|
if (rule.type === 'list') {
|
||||||
const list = lists?.find(l => l.uri === rule.list)
|
const list = lists?.find(l => l.uri === rule.list)
|
||||||
if (list) {
|
if (list) {
|
||||||
const listUrip = new AtUri(list.uri)
|
const listUrip = new AtUri(list.uri)
|
||||||
|
|
|
@ -38,6 +38,7 @@ function ListImpl<ItemT>(
|
||||||
{
|
{
|
||||||
ListHeaderComponent,
|
ListHeaderComponent,
|
||||||
ListFooterComponent,
|
ListFooterComponent,
|
||||||
|
ListEmptyComponent,
|
||||||
containWeb,
|
containWeb,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
data,
|
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 (ListHeaderComponent != null) {
|
||||||
if (isValidElement(ListHeaderComponent)) {
|
if (isValidElement(ListHeaderComponent)) {
|
||||||
header = ListHeaderComponent
|
headerComponent = ListHeaderComponent
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore Nah it's fine.
|
// @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 (ListFooterComponent != null) {
|
||||||
if (isValidElement(ListFooterComponent)) {
|
if (isValidElement(ListFooterComponent)) {
|
||||||
footer = ListFooterComponent
|
footerComponent = ListFooterComponent
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore Nah it's fine.
|
// @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}
|
onVisibleChange={handleAboveTheFoldVisibleChange}
|
||||||
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
|
||||||
/>
|
/>
|
||||||
{onStartReached && (
|
{onStartReached && !isEmpty && (
|
||||||
<Visibility
|
<Visibility
|
||||||
root={containWeb ? nativeRef : null}
|
root={containWeb ? nativeRef : null}
|
||||||
onVisibleChange={onHeadVisibilityChange}
|
onVisibleChange={onHeadVisibilityChange}
|
||||||
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{header}
|
{headerComponent}
|
||||||
{(data as Array<ItemT>).map((item, index) => {
|
{isEmpty
|
||||||
const key = keyExtractor!(item, index)
|
? emptyComponent
|
||||||
return (
|
: (data as Array<ItemT>)?.map((item, index) => {
|
||||||
<Row<ItemT>
|
const key = keyExtractor!(item, index)
|
||||||
key={key}
|
return (
|
||||||
item={item}
|
<Row<ItemT>
|
||||||
index={index}
|
key={key}
|
||||||
renderItem={renderItem}
|
item={item}
|
||||||
extraData={extraData}
|
index={index}
|
||||||
onItemSeen={onItemSeen}
|
renderItem={renderItem}
|
||||||
disableContentVisibility={disableContentVisibility}
|
extraData={extraData}
|
||||||
/>
|
onItemSeen={onItemSeen}
|
||||||
)
|
disableContentVisibility={disableContentVisibility}
|
||||||
})}
|
/>
|
||||||
{onEndReached && (
|
)
|
||||||
|
})}
|
||||||
|
{onEndReached && !isEmpty && (
|
||||||
<Visibility
|
<Visibility
|
||||||
root={containWeb ? nativeRef : null}
|
root={containWeb ? nativeRef : null}
|
||||||
onVisibleChange={onTailVisibilityChange}
|
onVisibleChange={onTailVisibilityChange}
|
||||||
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{footer}
|
{footerComponent}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,12 +15,14 @@ export function TimeElapsed({
|
||||||
const ago = useGetTimeAgo()
|
const ago = useGetTimeAgo()
|
||||||
const format = timeToString ?? ago
|
const format = timeToString ?? ago
|
||||||
const tick = useTickEveryMinute()
|
const tick = useTickEveryMinute()
|
||||||
const [timeElapsed, setTimeAgo] = React.useState(() => format(timestamp))
|
const [timeElapsed, setTimeAgo] = React.useState(() =>
|
||||||
|
format(timestamp, tick),
|
||||||
|
)
|
||||||
|
|
||||||
const [prevTick, setPrevTick] = React.useState(tick)
|
const [prevTick, setPrevTick] = React.useState(tick)
|
||||||
if (prevTick !== tick) {
|
if (prevTick !== tick) {
|
||||||
setPrevTick(tick)
|
setPrevTick(tick)
|
||||||
setTimeAgo(format(timestamp))
|
setTimeAgo(format(timestamp, tick))
|
||||||
}
|
}
|
||||||
|
|
||||||
return children({timeElapsed})
|
return children({timeElapsed})
|
||||||
|
|
|
@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
|
||||||
|
|
||||||
interface BaseUserAvatarProps {
|
interface BaseUserAvatarProps {
|
||||||
type?: UserAvatarType
|
type?: UserAvatarType
|
||||||
|
shape?: 'circle' | 'square'
|
||||||
size: number
|
size: number
|
||||||
avatar?: string | null
|
avatar?: string | null
|
||||||
}
|
}
|
||||||
|
@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
|
||||||
|
|
||||||
let DefaultAvatar = ({
|
let DefaultAvatar = ({
|
||||||
type,
|
type,
|
||||||
|
shape: overrideShape,
|
||||||
size,
|
size,
|
||||||
}: {
|
}: {
|
||||||
type: UserAvatarType
|
type: UserAvatarType
|
||||||
|
shape?: 'square' | 'circle'
|
||||||
size: number
|
size: number
|
||||||
}): React.ReactNode => {
|
}): React.ReactNode => {
|
||||||
|
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||||
if (type === 'algo') {
|
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.
|
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg
|
||||||
|
@ -84,6 +89,7 @@ let DefaultAvatar = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (type === 'list') {
|
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.
|
// Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg
|
||||||
|
@ -117,14 +123,18 @@ let DefaultAvatar = ({
|
||||||
viewBox="0 0 32 32"
|
viewBox="0 0 32 32"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="none">
|
stroke="none">
|
||||||
<Rect
|
{finalShape === 'square' ? (
|
||||||
x="0"
|
<Rect
|
||||||
y="0"
|
x="0"
|
||||||
width="32"
|
y="0"
|
||||||
height="32"
|
width="32"
|
||||||
rx="3"
|
height="32"
|
||||||
fill={tokens.color.temp_purple}
|
rx="3"
|
||||||
/>
|
fill={tokens.color.temp_purple}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
|
||||||
|
)}
|
||||||
<Path
|
<Path
|
||||||
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
|
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"
|
stroke="white"
|
||||||
|
@ -135,6 +145,7 @@ let DefaultAvatar = ({
|
||||||
</Svg>
|
</Svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// TODO: shape=square
|
||||||
return (
|
return (
|
||||||
<Svg
|
<Svg
|
||||||
testID="userAvatarFallback"
|
testID="userAvatarFallback"
|
||||||
|
@ -159,6 +170,7 @@ export {DefaultAvatar}
|
||||||
|
|
||||||
let UserAvatar = ({
|
let UserAvatar = ({
|
||||||
type = 'user',
|
type = 'user',
|
||||||
|
shape: overrideShape,
|
||||||
size,
|
size,
|
||||||
avatar,
|
avatar,
|
||||||
moderation,
|
moderation,
|
||||||
|
@ -166,9 +178,10 @@ let UserAvatar = ({
|
||||||
}: UserAvatarProps): React.ReactNode => {
|
}: UserAvatarProps): React.ReactNode => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const backgroundColor = pal.colors.backgroundLight
|
const backgroundColor = pal.colors.backgroundLight
|
||||||
|
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
|
||||||
|
|
||||||
const aviStyle = useMemo(() => {
|
const aviStyle = useMemo(() => {
|
||||||
if (type === 'algo' || type === 'list' || type === 'labeler') {
|
if (finalShape === 'square') {
|
||||||
return {
|
return {
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
|
@ -182,7 +195,7 @@ let UserAvatar = ({
|
||||||
borderRadius: Math.floor(size / 2),
|
borderRadius: Math.floor(size / 2),
|
||||||
backgroundColor,
|
backgroundColor,
|
||||||
}
|
}
|
||||||
}, [type, size, backgroundColor])
|
}, [finalShape, size, backgroundColor])
|
||||||
|
|
||||||
const alert = useMemo(() => {
|
const alert = useMemo(() => {
|
||||||
if (!moderation?.alert) {
|
if (!moderation?.alert) {
|
||||||
|
@ -224,7 +237,7 @@ let UserAvatar = ({
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={{width: size, height: size}}>
|
<View style={{width: size, height: size}}>
|
||||||
<DefaultAvatar type={type} size={size} />
|
<DefaultAvatar type={type} shape={finalShape} size={size} />
|
||||||
{alert}
|
{alert}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import * as Clipboard from 'expo-clipboard'
|
import * as Clipboard from 'expo-clipboard'
|
||||||
import {
|
import {
|
||||||
AppBskyActorDefs,
|
AppBskyFeedDefs,
|
||||||
AppBskyFeedPost,
|
AppBskyFeedPost,
|
||||||
AtUri,
|
AtUri,
|
||||||
RichText as RichTextAPI,
|
RichText as RichTextAPI,
|
||||||
|
@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
|
||||||
import {getTranslatorLink} from '#/locale/helpers'
|
import {getTranslatorLink} from '#/locale/helpers'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {isWeb} from '#/platform/detection'
|
import {isWeb} from '#/platform/detection'
|
||||||
|
import {Shadow} from '#/state/cache/post-shadow'
|
||||||
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
import {useFeedFeedbackContext} from '#/state/feed-feedback'
|
||||||
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
|
|
||||||
import {useLanguagePrefs} from '#/state/preferences'
|
import {useLanguagePrefs} from '#/state/preferences'
|
||||||
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
|
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
|
||||||
import {useOpenLink} from '#/state/preferences/in-app-browser'
|
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 {useSession} from '#/state/session'
|
||||||
import {getCurrentRoute} from 'lib/routes/helpers'
|
import {getCurrentRoute} from 'lib/routes/helpers'
|
||||||
import {shareUrl} from 'lib/sharing'
|
import {shareUrl} from 'lib/sharing'
|
||||||
|
@ -62,9 +65,7 @@ import * as Toast from '../Toast'
|
||||||
|
|
||||||
let PostDropdownBtn = ({
|
let PostDropdownBtn = ({
|
||||||
testID,
|
testID,
|
||||||
postAuthor,
|
post,
|
||||||
postCid,
|
|
||||||
postUri,
|
|
||||||
postFeedContext,
|
postFeedContext,
|
||||||
record,
|
record,
|
||||||
richText,
|
richText,
|
||||||
|
@ -74,9 +75,7 @@ let PostDropdownBtn = ({
|
||||||
timestamp,
|
timestamp,
|
||||||
}: {
|
}: {
|
||||||
testID: string
|
testID: string
|
||||||
postAuthor: AppBskyActorDefs.ProfileViewBasic
|
post: Shadow<AppBskyFeedDefs.PostView>
|
||||||
postCid: string
|
|
||||||
postUri: string
|
|
||||||
postFeedContext: string | undefined
|
postFeedContext: string | undefined
|
||||||
record: AppBskyFeedPost.Record
|
record: AppBskyFeedPost.Record
|
||||||
richText: RichTextAPI
|
richText: RichTextAPI
|
||||||
|
@ -92,8 +91,6 @@ let PostDropdownBtn = ({
|
||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
const defaultCtrlColor = theme.palette.default.postCtrl
|
const defaultCtrlColor = theme.palette.default.postCtrl
|
||||||
const langPrefs = useLanguagePrefs()
|
const langPrefs = useLanguagePrefs()
|
||||||
const mutedThreads = useMutedThreads()
|
|
||||||
const toggleThreadMute = useToggleThreadMute()
|
|
||||||
const postDeleteMutation = usePostDeleteMutation()
|
const postDeleteMutation = usePostDeleteMutation()
|
||||||
const hiddenPosts = useHiddenPosts()
|
const hiddenPosts = useHiddenPosts()
|
||||||
const {hidePost} = useHiddenPostsApi()
|
const {hidePost} = useHiddenPostsApi()
|
||||||
|
@ -107,9 +104,15 @@ let PostDropdownBtn = ({
|
||||||
const loggedOutWarningPromptControl = useDialogControl()
|
const loggedOutWarningPromptControl = useDialogControl()
|
||||||
const embedPostControl = useDialogControl()
|
const embedPostControl = useDialogControl()
|
||||||
const sendViaChatControl = useDialogControl()
|
const sendViaChatControl = useDialogControl()
|
||||||
|
const postUri = post.uri
|
||||||
|
const postCid = post.cid
|
||||||
|
const postAuthor = post.author
|
||||||
|
|
||||||
const rootUri = record.reply?.root?.uri || postUri
|
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 isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
|
||||||
const isAuthor = postAuthor.did === currentAccount?.did
|
const isAuthor = postAuthor.did === currentAccount?.did
|
||||||
|
|
||||||
|
@ -162,18 +165,22 @@ let PostDropdownBtn = ({
|
||||||
|
|
||||||
const onToggleThreadMute = React.useCallback(() => {
|
const onToggleThreadMute = React.useCallback(() => {
|
||||||
try {
|
try {
|
||||||
const muted = toggleThreadMute(rootUri)
|
if (isThreadMuted) {
|
||||||
if (muted) {
|
unmuteThread()
|
||||||
|
Toast.show(_(msg`You will now receive notifications for this thread`))
|
||||||
|
} else {
|
||||||
|
muteThread()
|
||||||
Toast.show(
|
Toast.show(
|
||||||
_(msg`You will no longer receive notifications for this thread`),
|
_(msg`You will no longer receive notifications for this thread`),
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
Toast.show(_(msg`You will now receive notifications for this thread`))
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
logger.error('Failed to toggle thread mute', {message: e})
|
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 onCopyPostText = React.useCallback(() => {
|
||||||
const str = richTextToString(richText, true)
|
const str = richTextToString(richText, true)
|
||||||
|
|
|
@ -2,39 +2,60 @@ import React from 'react'
|
||||||
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
|
||||||
import {Image} from 'expo-image'
|
import {Image} from 'expo-image'
|
||||||
import {AppBskyEmbedImages} from '@atproto/api'
|
import {AppBskyEmbedImages} from '@atproto/api'
|
||||||
|
import {Trans} from '@lingui/macro'
|
||||||
|
|
||||||
|
import {atoms as a} from '#/alf'
|
||||||
|
import {Text} from '#/components/Typography'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
images: AppBskyEmbedImages.ViewImage[]
|
images: AppBskyEmbedImages.ViewImage[]
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
|
gif?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageHorzList({images, style}: Props) {
|
export function ImageHorzList({images, style, gif}: Props) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.flexRow, style]}>
|
<View style={[a.flex_row, a.gap_xs, style]}>
|
||||||
{images.map(({thumb, alt}) => (
|
{images.map(({thumb, alt}) => (
|
||||||
<Image
|
<View
|
||||||
key={thumb}
|
key={thumb}
|
||||||
source={{uri: thumb}}
|
style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
|
||||||
style={styles.image}
|
<Image
|
||||||
accessible={true}
|
key={thumb}
|
||||||
accessibilityIgnoresInvertColors
|
source={{uri: thumb}}
|
||||||
accessibilityHint={alt}
|
style={[a.flex_1, a.rounded_xs]}
|
||||||
accessibilityLabel=""
|
accessible={true}
|
||||||
/>
|
accessibilityIgnoresInvertColors
|
||||||
|
accessibilityHint={alt}
|
||||||
|
accessibilityLabel=""
|
||||||
|
/>
|
||||||
|
{gif && (
|
||||||
|
<View style={styles.altContainer}>
|
||||||
|
<Text style={styles.alt}>
|
||||||
|
<Trans>GIF</Trans>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
flexRow: {
|
altContainer: {
|
||||||
flexDirection: 'row',
|
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||||
gap: 5,
|
borderRadius: 6,
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
position: 'absolute',
|
||||||
|
right: 5,
|
||||||
|
bottom: 5,
|
||||||
|
zIndex: 2,
|
||||||
},
|
},
|
||||||
image: {
|
alt: {
|
||||||
maxWidth: 100,
|
color: 'white',
|
||||||
aspectRatio: 1,
|
fontSize: 7,
|
||||||
flex: 1,
|
fontWeight: 'bold',
|
||||||
borderRadius: 4,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -319,9 +319,7 @@ let PostCtrls = ({
|
||||||
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
|
||||||
<PostDropdownBtn
|
<PostDropdownBtn
|
||||||
testID="postDropdownBtn"
|
testID="postDropdownBtn"
|
||||||
postAuthor={post.author}
|
post={post}
|
||||||
postCid={post.cid}
|
|
||||||
postUri={post.uri}
|
|
||||||
postFeedContext={feedContext}
|
postFeedContext={feedContext}
|
||||||
record={record}
|
record={record}
|
||||||
richText={richText}
|
richText={richText}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {useFocusEffect} from '@react-navigation/native'
|
||||||
|
|
||||||
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
|
||||||
import {getEntries} from '#/logger/logDump'
|
import {getEntries} from '#/logger/logDump'
|
||||||
|
import {useTickEveryMinute} from '#/state/shell'
|
||||||
import {useSetMinimalShellMode} from '#/state/shell'
|
import {useSetMinimalShellMode} from '#/state/shell'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
|
||||||
|
@ -24,6 +25,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
||||||
const setMinimalShellMode = useSetMinimalShellMode()
|
const setMinimalShellMode = useSetMinimalShellMode()
|
||||||
const [expanded, setExpanded] = React.useState<string[]>([])
|
const [expanded, setExpanded] = React.useState<string[]>([])
|
||||||
const timeAgo = useGetTimeAgo()
|
const timeAgo = useGetTimeAgo()
|
||||||
|
const tick = useTickEveryMinute()
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
React.useCallback(() => {
|
React.useCallback(() => {
|
||||||
|
@ -72,7 +74,7 @@ export function LogScreen({}: NativeStackScreenProps<
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Text type="sm" style={[styles.ts, pal.textLight]}>
|
<Text type="sm" style={[styles.ts, pal.textLight]}>
|
||||||
{timeAgo(entry.timestamp)}
|
{timeAgo(entry.timestamp, tick)}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
{expanded.includes(entry.id) ? (
|
{expanded.includes(entry.id) ? (
|
||||||
|
|
|
@ -34,10 +34,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.12.18":
|
"@atproto/api@^0.12.20":
|
||||||
version "0.12.18"
|
version "0.12.20"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.20.tgz#2cada08c24bc61eb1775ee4c8010c7ed9dc5d6f3"
|
||||||
integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ==
|
integrity sha512-nt7ZKUQL9j2yQ3tmCCueiIuc0FwdxZYn2fXdLYqltuxlaO5DmaqqULMBKeYJLq4GbvVl/G+ikPJccoSaMWDYOg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/common-web" "^0.3.0"
|
"@atproto/common-web" "^0.3.0"
|
||||||
"@atproto/lexicon" "^0.4.0"
|
"@atproto/lexicon" "^0.4.0"
|
||||||
|
|
Loading…
Reference in New Issue