Compare commits

..

4 Commits

Author SHA1 Message Date
Eric Bailey d6c5044c1d format 2024-05-06 15:34:51 -05:00
Eric Bailey c35c840c3d Remove unused error type 2024-05-06 15:05:11 -05:00
Eric Bailey 9054ba6979 Handle failed polling 2024-05-06 15:05:11 -05:00
Eric Bailey 6903b68557 Handle two common errors, provide more clarity around error states 2024-05-06 15:05:10 -05:00
97 changed files with 10965 additions and 18068 deletions
+2 -1
View File
@@ -95,7 +95,8 @@ web-build/
/ios/
# environment variables
.env
.env.*
# Firebase (Android) Google services
# INCLUDED: google-services.json
# INCLUDED: google-services.json
@@ -25,8 +25,6 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
-2
View File
@@ -25,8 +25,6 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -54,9 +54,7 @@ jobs:
uses: actions/cache@v4
with:
path: last-successful-commit-hash.txt
key: last-successful-deployment-commit-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
last-successful-deployment-commit-${{ github.ref_name }}-
key: last-successful-deployment-commit-${{ github.ref_name }}
- name: Add the last successful deployment commit to the output
id: last-successful-commit
@@ -70,7 +68,7 @@ jobs:
- name: 🕵️ Get the base commit
id: base-commit
run: |
if ${{ inputs.channel == 'production' }}; then
if [ -z "${{ inputs.channel == 'production' }}" ]; then
echo base-commit=$(git show-ref -s ${{ inputs.runtimeVersion }}) >> "$GITHUB_OUTPUT"
else
echo base-commit=${{ steps.last-successful-commit.base-commit }} >> "$GITHUB_OUTPUT"
@@ -186,8 +184,6 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
@@ -259,8 +255,6 @@ jobs:
- name: ⬇️ Checkout
uses: actions/checkout@v4
with:
fetch-depth: 5
- name: 🔧 Setup Node
uses: actions/setup-node@v4
+30 -1
View File
@@ -3,7 +3,7 @@ import {RichText} from '@atproto/api'
import {parseEmbedPlayerFromUrl} from 'lib/strings/embed-player'
import {cleanError} from '../../src/lib/strings/errors'
import {createFullHandle, makeValidHandle} from '../../src/lib/strings/handles'
import {enforceLen} from '../../src/lib/strings/helpers'
import {enforceLen, pluralize} from '../../src/lib/strings/helpers'
import {detectLinkables} from '../../src/lib/strings/rich-text-detection'
import {shortenLinks} from '../../src/lib/strings/rich-text-manip'
import {ago} from '../../src/lib/strings/time'
@@ -127,6 +127,35 @@ describe('detectLinkables', () => {
})
})
describe('pluralize', () => {
const inputs: [number, string, string?][] = [
[1, 'follower'],
[1, 'member'],
[100, 'post'],
[1000, 'repost'],
[10000, 'upvote'],
[100000, 'other'],
[2, 'man', 'men'],
]
const outputs = [
'follower',
'member',
'posts',
'reposts',
'upvotes',
'others',
'men',
]
it('correctly pluralizes a set of words', () => {
for (let i = 0; i < inputs.length; i++) {
const input = inputs[i]
const output = pluralize(...input)
expect(output).toEqual(outputs[i])
}
})
})
describe('makeRecordUri', () => {
const inputs: [string, string, string][] = [
['alice.test', 'app.bsky.feed.post', '3jk7x4irgv52r'],
-22
View File
@@ -91,28 +91,6 @@ module.exports = function (config) {
entitlements: {
'com.apple.security.application-groups': 'group.app.bsky',
},
privacyManifests: {
NSPrivacyAccessedAPITypes: [
{
NSPrivacyAccessedAPIType:
'NSPrivacyAccessedAPICategoryFileTimestamp',
NSPrivacyAccessedAPITypeReasons: ['C617.1', '3B52.1', '0A2A.1'],
},
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryDiskSpace',
NSPrivacyAccessedAPITypeReasons: ['E174.1', '85F4.1'],
},
{
NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryBootTime',
NSPrivacyAccessedAPITypeReasons: ['35F9.1'],
},
{
NSPrivacyAccessedAPIType:
'NSPrivacyAccessedAPICategoryUserDefaults',
NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
},
],
},
},
androidStatusBar: {
barStyle: 'light-content',
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415Z M10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 623 B

+7 -21
View File
@@ -47,14 +47,6 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
}
#preload {
width: 100px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea {
font: inherit;
@@ -270,19 +262,13 @@
</head>
<body>
{%- block body_all %}
<div id="root">
<div id="preload">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
</div>
</div>
<noscript>
<h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
{% block noscript_extra %}{% endblock %}
</noscript>
<div id="root"></div>
<noscript>
<h1 lang="en">JavaScript Required</h1>
<p lang="en">This is a heavily interactive web application, and JavaScript is required. Simple HTML interfaces are possible, but that is not what this is.
<p lang="en">Learn more about Bluesky at <a href="https://bsky.social">bsky.social</a> and <a href="https://atproto.com">atproto.com</a>.
{% block noscript_extra %}{% endblock %}
</noscript>
{% endblock -%}
</body>
</html>
@@ -1,6 +1,5 @@
import {requireNativeViewManager} from 'expo-modules-core'
import * as React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
+1 -5
View File
@@ -60,8 +60,6 @@
"@expo/webpack-config": "^19.0.0",
"@floating-ui/dom": "^1.6.3",
"@floating-ui/react-dom": "^2.0.8",
"@formatjs/intl-locale": "^3.4.3",
"@formatjs/intl-pluralrules": "^5.2.10",
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1",
@@ -100,7 +98,6 @@
"@tiptap/react": "^2.0.0-beta.220",
"@tiptap/suggestion": "^2.0.0-beta.220",
"@types/invariant": "^2.2.37",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.16.2",
"@zxing/text-encoding": "^0.9.0",
"array.prototype.findlast": "^1.2.3",
@@ -112,7 +109,7 @@
"email-validator": "^2.0.4",
"emoji-mart": "^5.5.2",
"eventemitter3": "^5.0.1",
"expo": "^50.0.17",
"expo": "^50.0.8",
"expo-application": "^5.8.3",
"expo-build-properties": "^0.11.1",
"expo-camera": "~14.0.4",
@@ -152,7 +149,6 @@
"lodash.samplesize": "^4.2.0",
"lodash.set": "^4.3.2",
"lodash.shuffle": "^4.2.0",
"lodash.throttle": "^4.1.1",
"mobx": "^6.6.1",
"mobx-react-lite": "^3.4.0",
"mobx-utils": "^6.0.6",
+19 -22
View File
@@ -16,7 +16,6 @@ import {useQueryClient} from '@tanstack/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {MessagesEventBusProvider} from '#/state/messages/events'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
@@ -96,27 +95,25 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<MessagesEventBusProvider>
<PushNotificationsListener>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</MessagesEventBusProvider>
<PushNotificationsListener>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</PushNotificationsListener>
</QueryProvider>
</React.Fragment>
</RootSiblingParent>
+16 -19
View File
@@ -9,7 +9,6 @@ import {useLingui} from '@lingui/react'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {MessagesEventBusProvider} from '#/state/messages/events'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
@@ -84,24 +83,22 @@ function InnerApp() {
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<MessagesEventBusProvider>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</MessagesEventBusProvider>
<StatsigProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</StatsigProvider>
</QueryProvider>
</React.Fragment>
<ToastContainer />
+5 -2
View File
@@ -1,6 +1,6 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AppBskyLabelerDefs} from '@atproto/api'
@@ -13,6 +13,7 @@ import {RichText} from '#/components/RichText'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '../icons/Chevron'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
type LabelingServiceProps = {
labeler: AppBskyLabelerDefs.LabelerViewDetailed
@@ -68,7 +69,9 @@ export function LikeCount({count}: {count: number}) {
t.atoms.text_contrast_medium,
{fontWeight: '500'},
]}>
<Plural value={count} one="Liked by # user" other="Liked by # users" />
<Trans>
Liked by {count} {pluralize(count, 'user')}
</Trans>
</Text>
)
}
+15 -23
View File
@@ -2,12 +2,13 @@ import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
import {flip, offset, shift, size, useFloating} from '@floating-ui/react-dom'
import {msg, plural} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {pluralize} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePrefetchProfileQuery, useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
@@ -370,14 +371,7 @@ function Inner({
const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
})
const pluralizedFollowings = plural(profile.followsCount || 0, {
one: 'following',
other: 'following',
})
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
const profileURL = makeProfileLink({
did: profile.did,
handle: profile.handle,
@@ -404,9 +398,7 @@ function Inner({
color={profileShadow.viewer?.following ? 'secondary' : 'primary'}
variant="solid"
label={
profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)
profileShadow.viewer?.following ? _('Following') : _('Follow')
}
style={[a.rounded_full]}
onPress={profileShadow.viewer?.following ? unfollow : follow}>
@@ -415,9 +407,7 @@ function Inner({
icon={profileShadow.viewer?.following ? Check : Plus}
/>
<ButtonText>
{profileShadow.viewer?.following
? _(msg`Following`)
: _(msg`Follow`)}
{profileShadow.viewer?.following ? _('Following') : _('Follow')}
</ButtonText>
</Button>
)}
@@ -444,20 +434,22 @@ function Inner({
label={`${followers} ${pluralizedFollowers}`}
style={[t.atoms.text]}
onPress={hide}>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{followers} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowers}
</Text>
</Trans>
</InlineLinkText>
<InlineLinkText
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}
style={[t.atoms.text]}
onPress={hide}>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>
{pluralizedFollowings}
</Text>
<Trans>
<Text style={[a.text_md, a.font_bold]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium]}>following</Text>
</Trans>
</InlineLinkText>
</View>
+1 -5
View File
@@ -62,16 +62,12 @@ export function TitleText({children}: React.PropsWithChildren<{}>) {
)
}
export function DescriptionText({
children,
selectable,
}: React.PropsWithChildren<{selectable?: boolean}>) {
export function DescriptionText({children}: React.PropsWithChildren<{}>) {
const t = useTheme()
const {descriptionId} = React.useContext(Context)
return (
<Text
nativeID={descriptionId}
selectable={selectable}
style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high, a.pb_lg]}>
{children}
</Text>
-4
View File
@@ -4,10 +4,6 @@ export const EmojiSad_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M6.343 6.343a8 8 0 1 1 11.314 11.314A8 8 0 0 1 6.343 6.343ZM19.071 4.93c-3.905-3.905-10.237-3.905-14.142 0-3.905 3.905-3.905 10.237 0 14.142 3.905 3.905 10.237 3.905 14.142 0 3.905-3.905 3.905-10.237 0-14.142Zm-3.537 9.535a5 5 0 0 0-7.07 0 1 1 0 1 0 1.413 1.415 3 3 0 0 1 4.243 0 1 1 0 0 0 1.414-1.415ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5ZM9.25 11c.69 0 1.25-.672 1.25-1.5S9.94 8 9.25 8 8 8.672 8 9.5 8.56 11 9.25 11Z',
})
export const EmojiSmile_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M17.657 6.343A8 8 0 1 0 6.343 17.657 8 8 0 0 0 17.657 6.343ZM4.929 4.93c3.905-3.905 10.237-3.905 14.142 0 3.905 3.905 3.905 10.237 0 14.142-3.905 3.905-10.237 3.905-14.142 0-3.905-3.905-3.905-10.237 0-14.142Zm3.536 9.192a1 1 0 0 1 1.414 0 3 3 0 0 0 4.243 0 1 1 0 0 1 1.414 1.415 5 5 0 0 1-7.071 0 1 1 0 0 1 0-1.415ZM10.5 9.5c0 .828-.56 1.5-1.25 1.5S8 10.328 8 9.5 8.56 8 9.25 8s1.25.672 1.25 1.5ZM16 9.5c0 .828-.56 1.5-1.25 1.5s-1.25-.672-1.25-1.5.56-1.5 1.25-1.5S16 8.672 16 9.5Z',
})
export const EmojiArc_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12Zm8-5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm4 0a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0V8a1 1 0 0 1 1-1Zm-5.894 7.803a1 1 0 0 1 1.341-.447c1.719.859 3.387.859 5.106 0a1 1 0 1 1 .894 1.788c-2.281 1.141-4.613 1.141-6.894 0a1 1 0 0 1-.447-1.341Z',
})
+6 -12
View File
@@ -1,7 +1,7 @@
import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedDefs, ComAtprotoLabelDefs} from '@atproto/api'
import {msg, Plural} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useSession} from '#/state/session'
@@ -39,6 +39,7 @@ export function LabelsOnMe({
return null
}
const labelTarget = isAccount ? _(msg`account`) : _(msg`content`)
return (
<View style={[a.flex_row, style]}>
<LabelsOnMeDialog control={control} subject={details} labels={labels} />
@@ -53,18 +54,11 @@ export function LabelsOnMe({
}}>
<ButtonIcon position="left" icon={CircleInfo} />
<ButtonText style={[a.leading_snug]}>
{isAccount ? (
<Plural
value={labels.length}
one="# label has been placed on this account"
other="# labels has been placed on this account"
/>
{labels.length}{' '}
{labels.length === 1 ? (
<Trans>label has been placed on this {labelTarget}</Trans>
) : (
<Plural
value={labels.length}
one="# label has been placed on this content"
other="# labels has been placed on this content"
/>
<Trans>labels have been placed on this {labelTarget}</Trans>
)}
</ButtonText>
</Button>
@@ -190,7 +190,7 @@ function AppealForm({
},
reason: details,
})
Toast.show(_(msg`Appeal submitted`))
Toast.show(_(msg`Appeal submitted.`))
} finally {
control.close()
}
@@ -121,20 +121,20 @@ function ModerationDetailsDialogInner({
<>
<Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? (
<Trans>This label was applied by the author.</Trans>
) : (
<Trans>
This label was applied by{' '}
<Trans>
This label was applied by{' '}
{modcause.source.type === 'user' ? (
<Trans>the author</Trans>
) : (
<InlineLinkText
to={makeProfileLink({did: modcause.label.src, handle: ''})}
onPress={() => control.close()}
style={a.text_md}>
{desc.source}
</InlineLinkText>
.
</Trans>
)}
)}
.
</Trans>
</Text>
</>
)}
+2 -1
View File
@@ -6,7 +6,8 @@ export const IS_TESTFLIGHT = process.env.EXPO_PUBLIC_ENV === 'testflight'
// This is the commit hash that the current bundle was made from. The user can see the commit hash in the app's settings
// along with the other version info. Useful for debugging/reporting.
export const BUNDLE_IDENTIFIER = process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? ''
export const BUNDLE_IDENTIFIER =
process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ?? 'dev'
// This will always be in the format of YYMMDD, so that it always increases for each build. This should only be used
// for Statsig reporting and shouldn't be used to identify a specific bundle.
-36
View File
@@ -1,36 +0,0 @@
// Kind of a hack. We needed some way to distinguish these.
const USER_ALT_PREFIX = 'Alt: '
const DEFAULT_ALT_PREFIX = 'ALT: '
export function createGIFDescription(
tenorDescription: string,
preferredAlt: string = '',
) {
preferredAlt = preferredAlt.trim()
if (preferredAlt !== '') {
return USER_ALT_PREFIX + preferredAlt
} else {
return DEFAULT_ALT_PREFIX + tenorDescription
}
}
export function parseAltFromGIFDescription(description: string): {
isPreferred: boolean
alt: string
} {
if (description.startsWith(USER_ALT_PREFIX)) {
return {
isPreferred: true,
alt: description.replace(USER_ALT_PREFIX, ''),
}
} else if (description.startsWith(DEFAULT_ALT_PREFIX)) {
return {
isPreferred: false,
alt: description.replace(DEFAULT_ALT_PREFIX, ''),
}
}
return {
isPreferred: false,
alt: description,
}
}
+1 -2
View File
@@ -1,9 +1,8 @@
import {
Image as RNImage,
openCamera as openCameraFn,
openCropper as openCropperFn,
Image as RNImage,
} from 'react-native-image-crop-picker'
import {CameraOpts, CropperOptions} from './types'
export {openPicker} from './picker.shared'
+1 -6
View File
@@ -1,8 +1,7 @@
/// <reference lib="dom" />
import {Image as RNImage} from 'react-native-image-crop-picker'
import {CameraOpts, CropperOptions} from './types'
import {Image as RNImage} from 'react-native-image-crop-picker'
export {openPicker} from './picker.shared'
import {unstable__openModal} from '#/state/modals'
@@ -17,10 +16,6 @@ export async function openCropper(opts: CropperOptions): Promise<RNImage> {
unstable__openModal({
name: 'crop-image',
uri: opts.path,
dimensions:
opts.height && opts.width
? {width: opts.width, height: opts.height}
: undefined,
onSelect: (img?: RNImage) => {
if (img) {
resolve(img)
+10
View File
@@ -1,3 +1,13 @@
export function pluralize(n: number, base: string, plural?: string): string {
if (n === 1) {
return base
}
if (plural) {
return plural
}
return base + 's'
}
export function enforceLen(
str: string,
len: number,
-20
View File
@@ -1,7 +1,3 @@
import '@formatjs/intl-locale/polyfill'
import '@formatjs/intl-pluralrules/polyfill'
import '@formatjs/intl-pluralrules/locale-data/en'
import {useEffect} from 'react'
import {i18n} from '@lingui/core'
@@ -33,82 +29,66 @@ export async function dynamicActivate(locale: AppLanguage) {
switch (locale) {
case AppLanguage.ca: {
i18n.loadAndActivate({locale, messages: messagesCa})
await import('@formatjs/intl-pluralrules/locale-data/ca')
break
}
case AppLanguage.de: {
i18n.loadAndActivate({locale, messages: messagesDe})
await import('@formatjs/intl-pluralrules/locale-data/de')
break
}
case AppLanguage.es: {
i18n.loadAndActivate({locale, messages: messagesEs})
await import('@formatjs/intl-pluralrules/locale-data/es')
break
}
case AppLanguage.fi: {
i18n.loadAndActivate({locale, messages: messagesFi})
await import('@formatjs/intl-pluralrules/locale-data/fi')
break
}
case AppLanguage.fr: {
i18n.loadAndActivate({locale, messages: messagesFr})
await import('@formatjs/intl-pluralrules/locale-data/fr')
break
}
case AppLanguage.ga: {
i18n.loadAndActivate({locale, messages: messagesGa})
await import('@formatjs/intl-pluralrules/locale-data/ga')
break
}
case AppLanguage.hi: {
i18n.loadAndActivate({locale, messages: messagesHi})
await import('@formatjs/intl-pluralrules/locale-data/hi')
break
}
case AppLanguage.id: {
i18n.loadAndActivate({locale, messages: messagesId})
await import('@formatjs/intl-pluralrules/locale-data/id')
break
}
case AppLanguage.it: {
i18n.loadAndActivate({locale, messages: messagesIt})
await import('@formatjs/intl-pluralrules/locale-data/it')
break
}
case AppLanguage.ja: {
i18n.loadAndActivate({locale, messages: messagesJa})
await import('@formatjs/intl-pluralrules/locale-data/ja')
break
}
case AppLanguage.ko: {
i18n.loadAndActivate({locale, messages: messagesKo})
await import('@formatjs/intl-pluralrules/locale-data/ko')
break
}
case AppLanguage.pt_BR: {
i18n.loadAndActivate({locale, messages: messagesPt_BR})
await import('@formatjs/intl-pluralrules/locale-data/pt')
break
}
case AppLanguage.tr: {
i18n.loadAndActivate({locale, messages: messagesTr})
await import('@formatjs/intl-pluralrules/locale-data/tr')
break
}
case AppLanguage.uk: {
i18n.loadAndActivate({locale, messages: messagesUk})
await import('@formatjs/intl-pluralrules/locale-data/uk')
break
}
case AppLanguage.zh_CN: {
i18n.loadAndActivate({locale, messages: messagesZh_CN})
await import('@formatjs/intl-pluralrules/locale-data/zh')
break
}
case AppLanguage.zh_TW: {
i18n.loadAndActivate({locale, messages: messagesZh_TW})
await import('@formatjs/intl-pluralrules/locale-data/zh')
break
}
default: {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -1,9 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
@@ -204,16 +205,10 @@ function msToString(ms: number | undefined): string | undefined {
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
return `${estimatedTimeHrs} ${pluralize(estimatedTimeHrs, 'hour')}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
return `${estimatedTimeMins} ${pluralize(estimatedTimeMins, 'minute')}`
}
return undefined
}
@@ -18,10 +18,10 @@ export function MessageListError({
const {_} = useLingui()
const message = React.useMemo(() => {
return {
[ConvoItemError.Network]: _(
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.ResumeFailed]: _(
msg`There was an issue connecting to the chat.`,
),
[ConvoItemError.HistoryFailed]: _(msg`Failed to load past messages.`),
[ConvoItemError.PollFailed]: _(
msg`This chat was disconnected due to a network error.`,
),
+2 -16
View File
@@ -18,7 +18,6 @@ import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {CenteredView} from 'view/com/util/Views'
import {MessagesList} from '#/screens/Messages/Conversation/MessagesList'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {ListMaybePlaceholder} from '#/components/Lists'
import {Text} from '#/components/Typography'
@@ -52,21 +51,8 @@ function Inner() {
}
if (chat.status === ConvoStatus.Error) {
// TODO
return (
<View>
<CenteredView style={{flex: 1}} sideBorders>
<Text>Something went wrong</Text>
<Button
label="Retry"
onPress={() => {
chat.error.retry()
}}>
<ButtonText>Retry</ButtonText>
</Button>
</CenteredView>
</View>
)
// TODO error
return null
}
/*
@@ -2,7 +2,7 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {Trans, msg} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {FeedSourceInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
@@ -109,7 +109,7 @@ function PrimaryFeedCardInner({
a.py_xs,
ctx.selected && styles.textSelected,
]}>
<Trans>by @{feed.creatorHandle}</Trans>
by @{feed.creatorHandle}
</Text>
</View>
@@ -189,9 +189,9 @@ export function StepInterests() {
color: t.palette.negative_900,
},
]}>
<Trans>Error:</Trans>{' '}
Error:{' '}
</Text>
{error?.message || _(msg`an unknown error occurred`)}
{error?.message || 'an unknown error occurred'}
</Text>
</View>
) : (
+10 -14
View File
@@ -1,9 +1,10 @@
import React from 'react'
import {View} from 'react-native'
import {AppBskyActorDefs} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {pluralize} from '#/lib/strings/helpers'
import {Shadow} from '#/state/cache/types'
import {makeProfileLink} from 'lib/routes/links'
import {formatCount} from 'view/com/util/numeric/format'
@@ -20,14 +21,7 @@ export function ProfileHeaderMetrics({
const {_} = useLingui()
const following = formatCount(profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower',
other: 'followers',
})
const pluralizedFollowings = plural(profile.followsCount || 0, {
one: 'following',
other: 'following',
})
const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
return (
<View
@@ -48,15 +42,17 @@ export function ProfileHeaderMetrics({
style={[a.flex_row, t.atoms.text]}
to={makeProfileLink(profile, 'follows')}
label={_(msg`${following} following`)}>
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
{pluralizedFollowings}
</Text>
<Trans>
<Text style={[a.font_bold, a.text_md]}>{following} </Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
following
</Text>
</Trans>
</InlineLinkText>
<Text style={[a.font_bold, t.atoms.text, a.text_md]}>
{formatCount(profile.postsCount || 0)}{' '}
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
{pluralize(profile.postsCount || 0, 'post')}
</Text>
</Text>
</View>
@@ -7,10 +7,11 @@ import {
ModerationOpts,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, Plural, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isAppLabeler} from '#/lib/moderation'
import {pluralize} from '#/lib/strings/helpers'
import {logger} from '#/logger'
import {Shadow} from '#/state/cache/types'
import {useModalControls} from '#/state/modals'
@@ -282,10 +283,12 @@ let ProfileHeaderLabeler = ({
},
}}
size="tiny"
label={plural(likeCount, {
one: 'Liked by # user',
other: 'Liked by # users',
})}>
label={_(
msg`Liked by ${likeCount} ${pluralize(
likeCount,
'user',
)}`,
)}>
{({hovered, focused, pressed}) => (
<Text
style={[
@@ -295,11 +298,9 @@ let ProfileHeaderLabeler = ({
(hovered || focused || pressed) &&
t.atoms.text_contrast_high,
]}>
<Plural
value={likeCount}
one="Liked by # user"
other="Liked by # users"
/>
<Trans>
Liked by {likeCount} {pluralize(likeCount, 'user')}
</Trans>
</Text>
)}
</Link>
+1 -5
View File
@@ -95,11 +95,7 @@ function ProfileEndOfFeed() {
const pal = usePalette('default')
return (
<View
style={[
pal.border,
{paddingTop: 32, paddingBottom: 32, borderTopWidth: 1},
]}>
<View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
<Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
<Trans>End of feed</Trans>
</Text>
+5 -7
View File
@@ -151,13 +151,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
]}>
<View style={[a.gap_sm, a.pb_3xl]}>
<Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
<Trans>
Step {state.activeStep + 1} of{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Trans>
<Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
{state.serviceDescription &&
!state.serviceDescription.phoneVerificationRequired
? '2'
: '3'}
</Text>
<Text style={[a.text_3xl, a.font_bold]}>
{state.activeStep === SignupStep.INFO ? (
-151
View File
@@ -1,151 +0,0 @@
import React from 'react'
import {AppState, AppStateStatus} from 'react-native'
import {AppBskyFeedDefs, BskyAgent} from '@atproto/api'
import throttle from 'lodash.throttle'
import {PROD_DEFAULT_FEED} from '#/lib/constants'
import {logger} from '#/logger'
import {
FeedDescriptor,
FeedPostSliceItem,
isFeedPostSlice,
} from '#/state/queries/post-feed'
import {useAgent} from './session'
type StateContext = {
enabled: boolean
onItemSeen: (item: any) => void
sendInteraction: (interaction: AppBskyFeedDefs.Interaction) => void
}
const stateContext = React.createContext<StateContext>({
enabled: false,
onItemSeen: (_item: any) => {},
sendInteraction: (_interaction: AppBskyFeedDefs.Interaction) => {},
})
export function useFeedFeedback(feed: FeedDescriptor, hasSession: boolean) {
const {getAgent} = useAgent()
const enabled = isDiscoverFeed(feed) && hasSession
const queue = React.useRef<Set<string>>(new Set())
const history = React.useRef<
// Use a WeakSet so that we don't need to clear it.
// This assumes that referential identity of slice items maps 1:1 to feed (re)fetches.
WeakSet<FeedPostSliceItem | AppBskyFeedDefs.Interaction>
>(new WeakSet())
const sendToFeedNoDelay = React.useCallback(() => {
const proxyAgent = getAgent().withProxy(
// @ts-ignore TODO need to update withProxy() to support this key -prf
'bsky_fg',
// TODO when we start sending to other feeds, we need to grab their DID -prf
'did:web:discover.bsky.app',
) as BskyAgent
const interactions = Array.from(queue.current).map(toInteraction)
queue.current.clear()
proxyAgent.app.bsky.feed
.sendInteractions({interactions})
.catch((e: any) => {
logger.warn('Failed to send feed interactions', {error: e})
})
}, [getAgent])
const sendToFeed = React.useMemo(
() =>
throttle(sendToFeedNoDelay, 15e3, {
leading: false,
trailing: true,
}),
[sendToFeedNoDelay],
)
React.useEffect(() => {
if (!enabled) {
return
}
const sub = AppState.addEventListener('change', (state: AppStateStatus) => {
if (state === 'background') {
sendToFeed.flush()
}
})
return () => sub.remove()
}, [enabled, sendToFeed])
const onItemSeen = React.useCallback(
(slice: any) => {
if (!enabled) {
return
}
if (!isFeedPostSlice(slice)) {
return
}
for (const postItem of slice.items) {
if (!history.current.has(postItem)) {
history.current.add(postItem)
queue.current.add(
toString({
item: postItem.uri,
event: 'app.bsky.feed.defs#interactionSeen',
feedContext: postItem.feedContext,
}),
)
sendToFeed()
}
}
},
[enabled, sendToFeed],
)
const sendInteraction = React.useCallback(
(interaction: AppBskyFeedDefs.Interaction) => {
if (!enabled) {
return
}
if (!history.current.has(interaction)) {
history.current.add(interaction)
queue.current.add(toString(interaction))
sendToFeed()
}
},
[enabled, sendToFeed],
)
return React.useMemo(() => {
return {
enabled,
// pass this method to the <List> onItemSeen
onItemSeen,
// call on various events
// queues the event to be sent with the throttled sendToFeed call
sendInteraction,
}
}, [enabled, onItemSeen, sendInteraction])
}
export const FeedFeedbackProvider = stateContext.Provider
export function useFeedFeedbackContext() {
return React.useContext(stateContext)
}
// TODO
// We will introduce a permissions framework for 3p feeds to
// take advantage of the feed feedback API. Until that's in
// place, we're hardcoding it to the discover feed.
// -prf
function isDiscoverFeed(feed: FeedDescriptor) {
return feed === `feedgen|${PROD_DEFAULT_FEED('whats-hot')}`
}
function toString(interaction: AppBskyFeedDefs.Interaction): string {
return `${interaction.item}|${interaction.event}|${
interaction.feedContext || ''
}`
}
function toInteraction(str: string): AppBskyFeedDefs.Interaction {
const [item, event, feedContext] = str.split('|')
return {item, event, feedContext}
}
+223 -471
View File
@@ -2,7 +2,6 @@ import {AppBskyActorDefs} from '@atproto/api'
import {
BskyAgent,
ChatBskyConvoDefs,
ChatBskyConvoGetLog,
ChatBskyConvoSendMessage,
} from '@atproto-labs/api'
import {nanoid} from 'nanoid/non-secure'
@@ -19,6 +18,7 @@ export type ConvoParams = {
export enum ConvoStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Resuming = 'resuming',
Ready = 'ready',
Error = 'error',
Backgrounded = 'backgrounded',
@@ -27,50 +27,14 @@ export enum ConvoStatus {
export enum ConvoItemError {
HistoryFailed = 'historyFailed',
ResumeFailed = 'resumeFailed',
PollFailed = 'pollFailed',
Network = 'network',
}
export enum ConvoErrorCode {
export enum ConvoError {
InitFailed = 'initFailed',
}
export type ConvoError = {
code: ConvoErrorCode
exception?: Error
retry: () => void
}
export enum ConvoDispatchEvent {
Init = 'init',
Ready = 'ready',
Resume = 'resume',
Background = 'background',
Suspend = 'suspend',
Error = 'error',
}
export type ConvoDispatch =
| {
event: ConvoDispatchEvent.Init
}
| {
event: ConvoDispatchEvent.Ready
}
| {
event: ConvoDispatchEvent.Resume
}
| {
event: ConvoDispatchEvent.Background
}
| {
event: ConvoDispatchEvent.Suspend
}
| {
event: ConvoDispatchEvent.Error
payload: ConvoError
}
export type ConvoItem =
| {
type: 'message' | 'pending-message'
@@ -169,6 +133,20 @@ export type ConvoState =
) => Promise<void>
fetchMessageHistory: () => Promise<void>
}
| {
status: ConvoStatus.Resuming
items: ConvoItem[]
convo: ChatBskyConvoDefs.ConvoView
error: undefined
sender: AppBskyActorDefs.ProfileViewBasic
recipients: AppBskyActorDefs.ProfileViewBasic[]
isFetchingHistory: boolean
deleteMessage: (messageId: string) => Promise<void>
sendMessage: (
message: ChatBskyConvoSendMessage.InputSchema['message'],
) => Promise<void>
fetchMessageHistory: () => Promise<void>
}
| {
status: ConvoStatus.Error
items: []
@@ -182,12 +160,9 @@ export type ConvoState =
fetchMessageHistory: undefined
}
const ACTIVE_POLL_INTERVAL = 1e3
const ACTIVE_POLL_INTERVAL = 2e3
const BACKGROUND_POLL_INTERVAL = 10e3
// TODO temporary
let DEBUG_ACTIVE_CHAT: string | undefined
export function isConvoItemMessage(
item: ConvoItem,
): item is ConvoItem & {type: 'message'} {
@@ -200,16 +175,14 @@ export function isConvoItemMessage(
}
export class Convo {
private id: string
private agent: BskyAgent
private __tempFromUserDid: string
private status: ConvoStatus = ConvoStatus.Uninitialized
private pollInterval = ACTIVE_POLL_INTERVAL
private status: ConvoStatus = ConvoStatus.Uninitialized
private error:
| {
code: ConvoErrorCode
code: ConvoError
exception?: Error
retry: () => void
}
@@ -217,6 +190,7 @@ export class Convo {
private historyCursor: string | undefined | null = undefined
private isFetchingHistory = false
private eventsCursor: string | undefined = undefined
private pollingFailure = false
private pastMessages: Map<
string,
@@ -234,9 +208,8 @@ export class Convo {
private footerItems: Map<string, ConvoItem> = new Map()
private headerItems: Map<string, ConvoItem> = new Map()
private pendingEventIngestion: Promise<void> | undefined
private isProcessingPendingMessages = false
private pendingPoll: Promise<void> | undefined
private nextPoll: NodeJS.Timeout | undefined
convoId: string
convo: ChatBskyConvoDefs.ConvoView | undefined
@@ -245,7 +218,6 @@ export class Convo {
snapshot: ConvoState | undefined
constructor(params: ConvoParams) {
this.id = nanoid(3)
this.convoId = params.convoId
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
@@ -255,14 +227,6 @@ export class Convo {
this.sendMessage = this.sendMessage.bind(this)
this.deleteMessage = this.deleteMessage.bind(this)
this.fetchMessageHistory = this.fetchMessageHistory.bind(this)
if (DEBUG_ACTIVE_CHAT) {
logger.error(`Convo: another chat was already active`, {
convoId: this.convoId,
})
} else {
DEBUG_ACTIVE_CHAT = this.convoId
}
}
private commit() {
@@ -307,6 +271,7 @@ export class Convo {
}
case ConvoStatus.Suspended:
case ConvoStatus.Backgrounded:
case ConvoStatus.Resuming:
case ConvoStatus.Ready: {
return {
status: this.status,
@@ -352,309 +317,122 @@ export class Convo {
}
}
dispatch(action: ConvoDispatch) {
const prevStatus = this.status
async init() {
logger.debug('Convo: init', {}, logger.DebugContext.convo)
switch (this.status) {
case ConvoStatus.Uninitialized: {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Initializing
this.setup()
break
}
if (
this.status === ConvoStatus.Uninitialized ||
this.status === ConvoStatus.Error
) {
try {
this.status = ConvoStatus.Initializing
this.commit()
await this.refreshConvo()
this.status = ConvoStatus.Ready
this.commit()
await this.fetchMessageHistory()
this.pollEvents()
} catch (e: any) {
logger.error('Convo: failed to init')
this.error = {
exception: e,
code: ConvoError.InitFailed,
retry: () => {
this.error = undefined
this.init()
},
}
break
this.status = ConvoStatus.Error
this.commit()
}
case ConvoStatus.Initializing: {
switch (action.event) {
case ConvoDispatchEvent.Ready: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.fetchMessageHistory().then(() => {
this.restartPoll()
})
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
this.pollInterval = BACKGROUND_POLL_INTERVAL
this.fetchMessageHistory().then(() => {
this.restartPoll()
})
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
case ConvoStatus.Ready: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.refreshConvo()
this.restartPoll()
break
}
case ConvoDispatchEvent.Background: {
this.status = ConvoStatus.Backgrounded
this.pollInterval = BACKGROUND_POLL_INTERVAL
this.restartPoll()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
this.cancelNextPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
this.cancelNextPoll()
break
}
}
break
}
case ConvoStatus.Backgrounded: {
switch (action.event) {
case ConvoDispatchEvent.Resume: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
// TODO truncate history if needed
this.restartPoll()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
this.cancelNextPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
this.cancelNextPoll()
break
}
}
break
}
case ConvoStatus.Suspended: {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
// TODO truncate history if needed
this.restartPoll()
break
}
case ConvoDispatchEvent.Resume: {
this.status = ConvoStatus.Ready
this.pollInterval = ACTIVE_POLL_INTERVAL
this.refreshConvo()
this.restartPoll()
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
case ConvoStatus.Error: {
switch (action.event) {
case ConvoDispatchEvent.Init: {
this.reset()
break
}
case ConvoDispatchEvent.Resume: {
this.reset()
break
}
case ConvoDispatchEvent.Suspend: {
this.status = ConvoStatus.Suspended
break
}
case ConvoDispatchEvent.Error: {
this.status = ConvoStatus.Error
this.error = action.payload
break
}
}
break
}
default:
break
} else {
logger.warn(`Convo: cannot init from ${this.status}`)
}
}
logger.debug(
`Convo: dispatch '${action.event}'`,
{
id: this.id,
prev: prevStatus,
next: this.status,
},
logger.DebugContext.convo,
)
async resume() {
logger.debug('Convo: resume', {}, logger.DebugContext.convo)
if (
this.status === ConvoStatus.Suspended ||
this.status === ConvoStatus.Backgrounded
) {
const fromStatus = this.status
try {
this.status = ConvoStatus.Resuming
this.commit()
await this.refreshConvo()
this.status = ConvoStatus.Ready
this.commit()
// throw new Error('UNCOMMENT TO TEST RESUME FAILURE')
this.pollInterval = ACTIVE_POLL_INTERVAL
this.pollEvents()
} catch (e) {
logger.error('Convo: failed to resume')
this.footerItems.set(ConvoItemError.ResumeFailed, {
type: 'error-recoverable',
key: ConvoItemError.ResumeFailed,
code: ConvoItemError.ResumeFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.ResumeFailed)
this.resume()
},
})
this.status = fromStatus
this.commit()
}
} else {
logger.warn(`Convo: cannot resume from ${this.status}`)
}
}
async background() {
logger.debug('Convo: backgrounded', {}, logger.DebugContext.convo)
this.status = ConvoStatus.Backgrounded
this.pollInterval = BACKGROUND_POLL_INTERVAL
this.commit()
}
private reset() {
this.convo = undefined
this.sender = undefined
this.recipients = undefined
this.snapshot = undefined
this.status = ConvoStatus.Uninitialized
this.error = undefined
this.historyCursor = undefined
this.eventsCursor = undefined
this.pastMessages = new Map()
this.newMessages = new Map()
this.pendingMessages = new Map()
this.deletedMessages = new Set()
this.footerItems = new Map()
this.headerItems = new Map()
this.dispatch({event: ConvoDispatchEvent.Init})
}
private async setup() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
this.convo = convo
this.sender = sender
this.recipients = recipients
/*
* Some validation prior to `Ready` status
*/
if (!this.convo) {
throw new Error('Convo: could not find convo')
}
if (!this.sender) {
throw new Error('Convo: could not find sender in convo')
}
if (!this.recipients) {
throw new Error('Convo: could not find recipients in convo')
}
// await new Promise(y => setTimeout(y, 2000))
// throw new Error('UNCOMMENT TO TEST INIT FAILURE')
this.dispatch({event: ConvoDispatchEvent.Ready})
} catch (e: any) {
logger.error('Convo: setup() failed')
this.dispatch({
event: ConvoDispatchEvent.Error,
payload: {
exception: e,
code: ConvoErrorCode.InitFailed,
retry: () => {
this.reset()
},
},
})
}
}
init() {
this.dispatch({event: ConvoDispatchEvent.Init})
}
resume() {
this.dispatch({event: ConvoDispatchEvent.Resume})
}
background() {
this.dispatch({event: ConvoDispatchEvent.Background})
}
suspend() {
this.dispatch({event: ConvoDispatchEvent.Suspend})
DEBUG_ACTIVE_CHAT = undefined
}
private pendingFetchConvo:
| Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: AppBskyActorDefs.ProfileViewBasic | undefined
recipients: AppBskyActorDefs.ProfileViewBasic[]
}>
| undefined
async fetchConvo() {
if (this.pendingFetchConvo) return this.pendingFetchConvo
this.pendingFetchConvo = new Promise<{
convo: ChatBskyConvoDefs.ConvoView
sender: AppBskyActorDefs.ProfileViewBasic | undefined
recipients: AppBskyActorDefs.ProfileViewBasic[]
}>(async (resolve, reject) => {
try {
const response = await this.agent.api.chat.bsky.convo.getConvo(
{
convoId: this.convoId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const convo = response.data.convo
resolve({
convo,
sender: convo.members.find(m => m.did === this.__tempFromUserDid),
recipients: convo.members.filter(
m => m.did !== this.__tempFromUserDid,
),
})
} catch (e) {
reject(e)
} finally {
this.pendingFetchConvo = undefined
}
})
return this.pendingFetchConvo
async suspend() {
logger.debug('Convo: suspended', {}, logger.DebugContext.convo)
this.status = ConvoStatus.Suspended
this.commit()
}
async refreshConvo() {
try {
const {convo, sender, recipients} = await this.fetchConvo()
// throw new Error('UNCOMMENT TO TEST REFRESH FAILURE')
this.convo = convo || this.convo
this.sender = sender || this.sender
this.recipients = recipients || this.recipients
} catch (e: any) {
logger.error(`Convo: failed to refresh convo`)
this.footerItems.set(ConvoItemError.Network, {
type: 'error-recoverable',
key: ConvoItemError.Network,
code: ConvoItemError.Network,
retry: () => {
this.footerItems.delete(ConvoItemError.Network)
this.resume()
const response = await this.agent.api.chat.bsky.convo.getConvo(
{
convoId: this.convoId,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
})
this.commit()
},
)
this.convo = response.data.convo
this.sender = this.convo.members.find(m => m.did === this.__tempFromUserDid)
this.recipients = this.convo.members.filter(
m => m.did !== this.__tempFromUserDid,
)
/*
* Prevent invalid states
*/
if (!this.sender) {
throw new Error('Convo: could not find sender in convo')
}
if (!this.recipients) {
throw new Error('Convo: could not find recipients in convo')
}
}
@@ -739,142 +517,116 @@ export class Convo {
}
}
private restartPoll() {
this.cancelNextPoll()
this.pollLatestEvents()
}
private async pollEvents() {
if (
this.status === ConvoStatus.Ready ||
this.status === ConvoStatus.Backgrounded
) {
if (this.pendingEventIngestion) return
private cancelNextPoll() {
if (this.nextPoll) clearTimeout(this.nextPoll)
}
/*
* Represents a failed state, which is retryable.
*/
if (this.pollingFailure) return
private pollLatestEvents() {
/*
* Uncomment to view poll events
*/
logger.debug('Convo: poll events', {id: this.id}, logger.DebugContext.convo)
try {
this.fetchLatestEvents().then(({events}) => {
this.applyLatestEvents(events)
})
this.nextPoll = setTimeout(() => {
this.pollLatestEvents()
setTimeout(async () => {
this.pendingEventIngestion = this.ingestLatestEvents()
await this.pendingEventIngestion
this.pendingEventIngestion = undefined
this.pollEvents()
}, this.pollInterval)
}
}
async ingestLatestEvents() {
try {
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
let needsCommit = false
for (const log of logs) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof log.rev === 'string') {
/*
* We only care about new events
*/
if (log.rev > (this.eventsCursor = this.eventsCursor || log.rev)) {
/*
* Update rev regardless of if it's a log type we care about or not
*/
this.eventsCursor = log.rev
/*
* This is VERY important. We don't want to insert any messages from
* your other chats.
*/
if (log.convoId !== this.convoId) continue
if (
ChatBskyConvoDefs.isLogCreateMessage(log) &&
ChatBskyConvoDefs.isMessageView(log.message)
) {
if (this.newMessages.has(log.message.id)) {
// Trust the log as the source of truth on ordering
this.newMessages.delete(log.message.id)
}
this.newMessages.set(log.message.id, log.message)
needsCommit = true
} else if (
ChatBskyConvoDefs.isLogDeleteMessage(log) &&
ChatBskyConvoDefs.isDeletedMessageView(log.message)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
*/
if (this.pastMessages.has(log.message.id)) {
/*
* For now, we remove deleted messages from the thread, if we receive one.
*
* To support them, it'd look something like this:
* this.pastMessages.set(log.message.id, log.message)
*/
this.pastMessages.delete(log.message.id)
this.newMessages.delete(log.message.id)
this.deletedMessages.delete(log.message.id)
needsCommit = true
}
}
}
}
}
if (needsCommit) {
this.commit()
}
} catch (e: any) {
logger.error('Convo: poll events failed')
this.cancelNextPoll()
logger.error('Convo: failed to poll events')
this.pollingFailure = true
this.footerItems.set(ConvoItemError.PollFailed, {
type: 'error-recoverable',
key: ConvoItemError.PollFailed,
code: ConvoItemError.PollFailed,
retry: () => {
this.footerItems.delete(ConvoItemError.PollFailed)
this.pollingFailure = false
this.commit()
this.pollLatestEvents()
this.pollEvents()
},
})
this.commit()
}
}
private pendingFetchLatestEvents:
| Promise<{
events: ChatBskyConvoGetLog.OutputSchema['logs']
}>
| undefined
async fetchLatestEvents() {
if (this.pendingFetchLatestEvents) return this.pendingFetchLatestEvents
this.pendingFetchLatestEvents = new Promise<{
events: ChatBskyConvoGetLog.OutputSchema['logs']
}>(async (resolve, reject) => {
try {
// throw new Error('UNCOMMENT TO TEST POLL FAILURE')
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.eventsCursor,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs} = response.data
resolve({events: logs})
} catch (e) {
reject(e)
} finally {
this.pendingFetchLatestEvents = undefined
}
})
return this.pendingFetchLatestEvents
}
private applyLatestEvents(events: ChatBskyConvoGetLog.OutputSchema['logs']) {
let needsCommit = false
for (const ev of events) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof ev.rev === 'string') {
/*
* We only care about new events
*/
if (ev.rev > (this.eventsCursor = this.eventsCursor || ev.rev)) {
/*
* Update rev regardless of if it's a ev type we care about or not
*/
this.eventsCursor = ev.rev
/*
* This is VERY important. We don't want to insert any messages from
* your other chats.
*/
if (ev.convoId !== this.convoId) continue
if (
ChatBskyConvoDefs.isLogCreateMessage(ev) &&
ChatBskyConvoDefs.isMessageView(ev.message)
) {
if (this.newMessages.has(ev.message.id)) {
// Trust the ev as the source of truth on ordering
this.newMessages.delete(ev.message.id)
}
this.newMessages.set(ev.message.id, ev.message)
needsCommit = true
} else if (
ChatBskyConvoDefs.isLogDeleteMessage(ev) &&
ChatBskyConvoDefs.isDeletedMessageView(ev.message)
) {
/*
* Update if we have this in state. If we don't, don't worry about it.
*/
if (this.pastMessages.has(ev.message.id)) {
/*
* For now, we remove deleted messages from the thread, if we receive one.
*
* To support them, it'd look something like this:
* this.pastMessages.set(ev.message.id, ev.message)
*/
this.pastMessages.delete(ev.message.id)
this.newMessages.delete(ev.message.id)
this.deletedMessages.delete(ev.message.id)
needsCommit = true
}
}
}
}
}
if (needsCommit) {
this.commit()
}
}
-466
View File
@@ -1,466 +0,0 @@
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
import EventEmitter from 'eventemitter3'
import {nanoid} from 'nanoid/non-secure'
import {logger} from '#/logger'
import {
MessagesEventBusDispatch,
MessagesEventBusDispatchEvent,
MessagesEventBusError,
MessagesEventBusErrorCode,
MessagesEventBusParams,
MessagesEventBusState,
MessagesEventBusStatus,
} from '#/state/messages/events/types'
const LOGGER_CONTEXT = 'MessagesEventBus'
const ACTIVE_POLL_INTERVAL = 60e3
const BACKGROUND_POLL_INTERVAL = 60e3
export class MessagesEventBus {
private id: string
private agent: BskyAgent
private __tempFromUserDid: string
private emitter = new EventEmitter()
private status: MessagesEventBusStatus = MessagesEventBusStatus.Uninitialized
private pollInterval = ACTIVE_POLL_INTERVAL
private error: MessagesEventBusError | undefined
private latestRev: string | undefined = undefined
snapshot: MessagesEventBusState | undefined
constructor(params: MessagesEventBusParams) {
this.id = nanoid(3)
this.agent = params.agent
this.__tempFromUserDid = params.__tempFromUserDid
this.subscribe = this.subscribe.bind(this)
this.getSnapshot = this.getSnapshot.bind(this)
this.init = this.init.bind(this)
this.suspend = this.suspend.bind(this)
this.resume = this.resume.bind(this)
this.setPollInterval = this.setPollInterval.bind(this)
this.trail = this.trail.bind(this)
this.trailConvo = this.trailConvo.bind(this)
}
private commit() {
this.snapshot = undefined
this.subscribers.forEach(subscriber => subscriber())
}
private subscribers: (() => void)[] = []
subscribe(subscriber: () => void) {
if (this.subscribers.length === 0) this.init()
this.subscribers.push(subscriber)
return () => {
this.subscribers = this.subscribers.filter(s => s !== subscriber)
if (this.subscribers.length === 0) this.suspend()
}
}
getSnapshot(): MessagesEventBusState {
if (!this.snapshot) this.snapshot = this.generateSnapshot()
// logger.debug(`${LOGGER_CONTEXT}: snapshotted`, {}, logger.DebugContext.convo)
return this.snapshot
}
private generateSnapshot(): MessagesEventBusState {
switch (this.status) {
case MessagesEventBusStatus.Initializing: {
return {
status: MessagesEventBusStatus.Initializing,
rev: undefined,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Ready: {
return {
status: this.status,
rev: this.latestRev!,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Suspended: {
return {
status: this.status,
rev: this.latestRev,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
case MessagesEventBusStatus.Error: {
return {
status: MessagesEventBusStatus.Error,
rev: this.latestRev,
error: this.error || {
code: MessagesEventBusErrorCode.Unknown,
retry: () => {
this.init()
},
},
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
default: {
return {
status: MessagesEventBusStatus.Uninitialized,
rev: undefined,
error: undefined,
setPollInterval: this.setPollInterval,
trail: this.trail,
trailConvo: this.trailConvo,
}
}
}
}
dispatch(action: MessagesEventBusDispatch) {
const prevStatus = this.status
switch (this.status) {
case MessagesEventBusStatus.Uninitialized: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Init: {
this.status = MessagesEventBusStatus.Initializing
this.setup()
break
}
}
break
}
case MessagesEventBusStatus.Initializing: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Ready: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
break
}
}
break
}
case MessagesEventBusStatus.Ready: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
this.stopPoll()
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Backgrounded: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Suspend: {
this.status = MessagesEventBusStatus.Suspended
this.stopPoll()
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Suspended: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume: {
this.status = MessagesEventBusStatus.Ready
this.setPollInterval(ACTIVE_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Background: {
this.status = MessagesEventBusStatus.Backgrounded
this.setPollInterval(BACKGROUND_POLL_INTERVAL)
break
}
case MessagesEventBusDispatchEvent.Error: {
this.status = MessagesEventBusStatus.Error
this.error = action.payload
this.stopPoll()
break
}
}
break
}
case MessagesEventBusStatus.Error: {
switch (action.event) {
case MessagesEventBusDispatchEvent.Resume:
case MessagesEventBusDispatchEvent.Init: {
this.status = MessagesEventBusStatus.Initializing
this.error = undefined
this.latestRev = undefined
this.setup()
break
}
}
break
}
default:
break
}
logger.debug(
`${LOGGER_CONTEXT}: dispatch '${action.event}'`,
{
id: this.id,
prev: prevStatus,
next: this.status,
},
logger.DebugContext.convo,
)
this.commit()
}
private async setup() {
logger.debug(`${LOGGER_CONTEXT}: setup`, {}, logger.DebugContext.convo)
try {
await this.initializeLatestRev()
this.dispatch({event: MessagesEventBusDispatchEvent.Ready})
} catch (e: any) {
logger.error(e, {
context: `${LOGGER_CONTEXT}: setup failed`,
})
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
payload: {
exception: e,
code: MessagesEventBusErrorCode.InitFailed,
retry: () => {
this.init()
},
},
})
}
}
init() {
logger.debug(`${LOGGER_CONTEXT}: init`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Init})
}
background() {
logger.debug(`${LOGGER_CONTEXT}: background`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Background})
}
suspend() {
logger.debug(`${LOGGER_CONTEXT}: suspend`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Suspend})
}
resume() {
logger.debug(`${LOGGER_CONTEXT}: resume`, {}, logger.DebugContext.convo)
this.dispatch({event: MessagesEventBusDispatchEvent.Resume})
}
setPollInterval(interval: number) {
this.pollInterval = interval
this.resetPoll()
}
trail(handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void) {
this.emitter.on('events', handler)
return () => {
this.emitter.off('events', handler)
}
}
trailConvo(
convoId: string,
handler: (events: ChatBskyConvoGetLog.OutputSchema['logs']) => void,
) {
const handle = (events: ChatBskyConvoGetLog.OutputSchema['logs']) => {
const convoEvents = events.filter(ev => {
if (typeof ev.convoId === 'string' && ev.convoId === convoId) {
return ev.convoId === convoId
}
return false
})
if (convoEvents.length > 0) {
handler(convoEvents)
}
}
this.emitter.on('events', handle)
return () => {
this.emitter.off('events', handle)
}
}
private async initializeLatestRev() {
logger.debug(
`${LOGGER_CONTEXT}: initialize latest rev`,
{},
logger.DebugContext.convo,
)
const response = await this.agent.api.chat.bsky.convo.listConvos(
{
limit: 1,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {convos} = response.data
for (const convo of convos) {
if (convo.rev > (this.latestRev = this.latestRev || convo.rev)) {
this.latestRev = convo.rev
}
}
}
/*
* Polling
*/
private isPolling = false
private pollIntervalRef: NodeJS.Timeout | undefined
private resetPoll() {
this.stopPoll()
this.startPoll()
}
private startPoll() {
if (!this.isPolling) this.poll()
this.pollIntervalRef = setInterval(() => {
if (this.isPolling) return
this.poll()
}, this.pollInterval)
}
private stopPoll() {
if (this.pollIntervalRef) clearInterval(this.pollIntervalRef)
}
private async poll() {
if (this.isPolling) return
this.isPolling = true
logger.debug(`${LOGGER_CONTEXT}: poll`, {}, logger.DebugContext.convo)
try {
const response = await this.agent.api.chat.bsky.convo.getLog(
{
cursor: this.latestRev,
},
{
headers: {
Authorization: this.__tempFromUserDid,
},
},
)
const {logs: events} = response.data
let needsEmit = false
let batch: ChatBskyConvoGetLog.OutputSchema['logs'] = []
for (const ev of events) {
/*
* If there's a rev, we should handle it. If there's not a rev, we don't
* know what it is.
*/
if (typeof ev.rev === 'string') {
/*
* We only care about new events
*/
if (ev.rev > (this.latestRev = this.latestRev || ev.rev)) {
/*
* Update rev regardless of if it's a ev type we care about or not
*/
this.latestRev = ev.rev
needsEmit = true
batch.push(ev)
}
}
}
if (needsEmit) {
try {
this.emitter.emit('events', batch)
} catch (e: any) {
logger.error(e, {
context: `${LOGGER_CONTEXT}: process latest events`,
})
}
}
} catch (e: any) {
logger.error(e, {context: `${LOGGER_CONTEXT}: poll events failed`})
this.dispatch({
event: MessagesEventBusDispatchEvent.Error,
payload: {
exception: e,
code: MessagesEventBusErrorCode.PollFailed,
retry: () => {
this.init()
},
},
})
} finally {
this.isPolling = false
}
}
}
-67
View File
@@ -1,67 +0,0 @@
import React from 'react'
import {AppState} from 'react-native'
import {BskyAgent} from '@atproto-labs/api'
import {isWeb} from '#/platform/detection'
import {MessagesEventBus} from '#/state/messages/events/agent'
import {MessagesEventBusState} from '#/state/messages/events/types'
import {useAgent} from '#/state/session'
import {useDmServiceUrlStorage} from '#/screens/Messages/Temp/useDmServiceUrlStorage'
import {IS_DEV} from '#/env'
const MessagesEventBusContext =
React.createContext<MessagesEventBusState | null>(null)
export function useMessagesEventBus() {
const ctx = React.useContext(MessagesEventBusContext)
if (!ctx) {
throw new Error('useChat must be used within a ChatProvider')
}
return ctx
}
export function MessagesEventBusProvider({
children,
}: {
children: React.ReactNode
}) {
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [bus] = React.useState(
() =>
new MessagesEventBus({
agent: new BskyAgent({
service: serviceUrl,
}),
__tempFromUserDid: getAgent().session?.did!,
}),
)
const service = React.useSyncExternalStore(bus.subscribe, bus.getSnapshot)
if (isWeb && IS_DEV) {
// @ts-ignore
window.messagesEventBus = service
}
React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (nextAppState === 'active') {
bus.resume()
} else {
bus.background()
}
}
const sub = AppState.addEventListener('change', handleAppStateChange)
return () => {
sub.remove()
}
}, [bus])
return (
<MessagesEventBusContext.Provider value={service}>
{children}
</MessagesEventBusContext.Provider>
)
}
-111
View File
@@ -1,111 +0,0 @@
import {BskyAgent, ChatBskyConvoGetLog} from '@atproto-labs/api'
export type MessagesEventBusParams = {
agent: BskyAgent
__tempFromUserDid: string
}
export enum MessagesEventBusStatus {
Uninitialized = 'uninitialized',
Initializing = 'initializing',
Ready = 'ready',
Error = 'error',
Backgrounded = 'backgrounded',
Suspended = 'suspended',
}
export enum MessagesEventBusDispatchEvent {
Init = 'init',
Ready = 'ready',
Error = 'error',
Background = 'background',
Suspend = 'suspend',
Resume = 'resume',
}
export enum MessagesEventBusErrorCode {
Unknown = 'unknown',
InitFailed = 'initFailed',
PollFailed = 'pollFailed',
}
export type MessagesEventBusError = {
code: MessagesEventBusErrorCode
exception?: Error
retry: () => void
}
export type MessagesEventBusDispatch =
| {
event: MessagesEventBusDispatchEvent.Init
}
| {
event: MessagesEventBusDispatchEvent.Ready
}
| {
event: MessagesEventBusDispatchEvent.Background
}
| {
event: MessagesEventBusDispatchEvent.Suspend
}
| {
event: MessagesEventBusDispatchEvent.Resume
}
| {
event: MessagesEventBusDispatchEvent.Error
payload: MessagesEventBusError
}
export type TrailHandler = (
events: ChatBskyConvoGetLog.OutputSchema['logs'],
) => void
export type MessagesEventBusState =
| {
status: MessagesEventBusStatus.Uninitialized
rev: undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Initializing
rev: undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Ready
rev: string
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Backgrounded
rev: string | undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Suspended
rev: string | undefined
error: undefined
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
| {
status: MessagesEventBusStatus.Error
rev: string | undefined
error: MessagesEventBusError
setPollInterval: (interval: number) => void
trail: (handler: TrailHandler) => () => void
trailConvo: (convoId: string, handler: TrailHandler) => () => void
}
+1 -21
View File
@@ -1,7 +1,6 @@
import React, {useContext, useState, useSyncExternalStore} from 'react'
import {AppState} from 'react-native'
import {BskyAgent} from '@atproto-labs/api'
import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {useFocusEffect} from '@react-navigation/native'
import {Convo, ConvoParams, ConvoState} from '#/state/messages/convo'
import {useAgent} from '#/state/session'
@@ -21,7 +20,6 @@ export function ChatProvider({
children,
convoId,
}: Pick<ConvoParams, 'convoId'> & {children: React.ReactNode}) {
const isScreenFocused = useIsFocused()
const {serviceUrl} = useDmServiceUrlStorage()
const {getAgent} = useAgent()
const [convo] = useState(
@@ -46,23 +44,5 @@ export function ChatProvider({
}, [convo]),
)
React.useEffect(() => {
const handleAppStateChange = (nextAppState: string) => {
if (isScreenFocused) {
if (nextAppState === 'active') {
convo.resume()
} else {
convo.background()
}
}
}
const sub = AppState.addEventListener('change', handleAppStateChange)
return () => {
sub.remove()
}
}, [convo, isScreenFocused])
return <ChatContext.Provider value={service}>{children}</ChatContext.Provider>
}
-1
View File
@@ -47,7 +47,6 @@ export interface EditImageModal {
export interface CropImageModal {
name: 'crop-image'
uri: string
dimensions?: {width: number; height: number}
onSelect: (img?: RNImage) => void
}
+1 -5
View File
@@ -24,11 +24,7 @@ export function useActorAutocompleteQuery(
const moderationOpts = useModerationOpts()
const {getAgent} = useAgent()
prefix = prefix.toLowerCase().trim()
if (prefix.endsWith('.')) {
// Going from "foo" to "foo." should not clear matches.
prefix = prefix.slice(0, -1)
}
prefix = prefix.toLowerCase()
return useQuery<AppBskyActorDefs.ProfileViewBasic[]>({
staleTime: STALE.MINUTES.ONE,
-10
View File
@@ -70,12 +70,10 @@ export interface FeedPostSliceItem {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason?: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource
feedContext: string | undefined
moderation: ModerationDecision
}
export interface FeedPostSlice {
_isFeedPostSlice: boolean
_reactKey: string
rootUri: string
isThread: boolean
@@ -278,7 +276,6 @@ export function usePostFeedQuery(
return {
_reactKey: slice._reactKey,
_isFeedPostSlice: true,
rootUri: slice.rootItem.post.uri,
isThread:
slice.items.length > 1 &&
@@ -303,7 +300,6 @@ export function usePostFeedQuery(
i === 0 && slice.source
? slice.source
: item.reason,
feedContext: item.feedContext,
moderation: moderations[i],
}
}
@@ -511,9 +507,3 @@ export function resetProfilePostsQueries(
})
}, timeout)
}
export function isFeedPostSlice(v: any): v is FeedPostSlice {
return (
v && typeof v === 'object' && '_isFeedPostSlice' in v && v._isFeedPostSlice
)
}
+12 -28
View File
@@ -18,10 +18,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {observer} from 'mobx-react-lite'
import {
createGIFDescription,
parseAltFromGIFDescription,
} from '#/lib/gif-alt-text'
import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
@@ -215,25 +211,11 @@ export const ComposePost = observer(function ComposePost({
[gallery, track],
)
const isAltTextRequiredAndMissing = useMemo(() => {
if (!requireAltTextEnabled) return false
if (gallery.needsAltText) return true
if (extGif) {
if (!extLink?.meta?.description) return true
const parsedAlt = parseAltFromGIFDescription(extLink.meta.description)
if (!parsedAlt.isPreferred) return true
}
return false
}, [gallery.needsAltText, extLink, extGif, requireAltTextEnabled])
const onPressPublish = async () => {
if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) {
return
}
if (isAltTextRequiredAndMissing) {
if (requireAltTextEnabled && gallery.needsAltText) {
return
}
@@ -316,8 +298,10 @@ export const ComposePost = observer(function ComposePost({
}
const canPost = useMemo(
() => graphemeLength <= MAX_GRAPHEME_LENGTH && !isAltTextRequiredAndMissing,
[graphemeLength, isAltTextRequiredAndMissing],
() =>
graphemeLength <= MAX_GRAPHEME_LENGTH &&
(!requireAltTextEnabled || !gallery.needsAltText),
[graphemeLength, requireAltTextEnabled, gallery.needsAltText],
)
const selectTextInputPlaceholder = replyTo
? _(msg`Write your reply`)
@@ -344,7 +328,7 @@ export const ComposePost = observer(function ComposePost({
image: gif.media_formats.preview.url,
likelyType: LikelyType.HTML,
title: gif.content_description,
description: createGIFDescription(gif.content_description),
description: '',
},
})
setExtGif(gif)
@@ -359,11 +343,11 @@ export const ComposePost = observer(function ComposePost({
? {
...ext,
meta: {
...ext.meta,
description: createGIFDescription(
ext.meta.title ?? '',
altText,
),
...ext?.meta,
description:
altText.trim().length === 0
? ''
: `Alt text: ${altText.trim()}`,
},
}
: ext,
@@ -449,7 +433,7 @@ export const ComposePost = observer(function ComposePost({
</>
)}
</View>
{isAltTextRequiredAndMissing && (
{requireAltTextEnabled && gallery.needsAltText && (
<View style={[styles.reminderLine, pal.viewLight]}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
+4 -12
View File
@@ -6,7 +6,6 @@ import {useLingui} from '@lingui/react'
import {ExternalEmbedDraft} from '#/lib/api'
import {HITSLOP_10, MAX_ALT_TEXT} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {
EmbedPlayerParams,
parseEmbedPlayerFromUrl,
@@ -60,7 +59,6 @@ export function GifAltText({
if (!gif || !params) return null
const parsedAlt = parseAltFromGIFDescription(link.description)
return (
<>
<TouchableOpacity
@@ -82,7 +80,7 @@ export function GifAltText({
a.align_center,
{backgroundColor: 'rgba(0, 0, 0, 0.75)'},
]}>
{parsedAlt.isPreferred ? (
{link.description ? (
<Check size="xs" fill={t.palette.white} style={a.ml_xs} />
) : (
<Plus size="sm" fill={t.palette.white} />
@@ -104,7 +102,7 @@ export function GifAltText({
onSubmit={onPressSubmit}
link={link}
params={params}
initialValue={parsedAlt.isPreferred ? parsedAlt.alt : ''}
initalValue={link.description.replace('Alt text: ', '')}
key={link.uri}
/>
</Dialog.Outer>
@@ -116,16 +114,15 @@ function AltTextInner({
onSubmit,
link,
params,
initialValue: initalValue,
initalValue,
}: {
onSubmit: (text: string) => void
link: AppBskyEmbedExternal.ViewExternal
params: EmbedPlayerParams
initialValue: string
initalValue: string
}) {
const {_} = useLingui()
const [altText, setAltText] = useState(initalValue)
const control = Dialog.useDialogContext()
const onPressSubmit = useCallback(() => {
onSubmit(altText)
@@ -150,11 +147,6 @@ function AltTextInner({
multiline
numberOfLines={3}
autoFocus
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
</View>
+14 -18
View File
@@ -9,7 +9,6 @@ import {getRootNavigation, getTabState, TabState} from '#/lib/routes/helpers'
import {logEvent, useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {truncateAndInvalidate} from '#/state/queries/util'
@@ -52,7 +51,6 @@ export function FeedPage({
const setMinimalShellMode = useSetMinimalShellMode()
const {screen, track} = useAnalytics()
const headerOffset = useHeaderOffset()
const feedFeedback = useFeedFeedback(feed, hasSession)
const scrollElRef = React.useRef<ListMethods>(null)
const [hasNew, setHasNew] = React.useState(false)
const gate = useGate()
@@ -115,22 +113,20 @@ export function FeedPage({
return (
<View testID={testID} style={s.h100pct}>
<MainScrollProvider>
<FeedFeedbackProvider value={feedFeedback}>
<Feed
testID={testID ? `${testID}-feed` : undefined}
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
headerOffset={headerOffset}
/>
</FeedFeedbackProvider>
<Feed
testID={testID ? `${testID}-feed` : undefined}
enabled={isPageFocused}
feed={feed}
feedParams={feedParams}
pollInterval={POLL_FREQ}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
onHasNew={setHasNew}
renderEmptyState={renderEmptyState}
renderEndOfFeed={renderEndOfFeed}
headerOffset={headerOffset}
/>
</MainScrollProvider>
{(isScrolledDown || adjustedHasNew) && (
<LoadLatestBtn
+6 -6
View File
@@ -6,11 +6,12 @@ import {RichText} from '#/components/RichText'
import {usePalette} from 'lib/hooks/usePalette'
import {s} from 'lib/styles'
import {UserAvatar} from '../util/UserAvatar'
import {pluralize} from 'lib/strings/helpers'
import {AtUri} from '@atproto/api'
import * as Toast from 'view/com/util/Toast'
import {sanitizeHandle} from 'lib/strings/handles'
import {logger} from '#/logger'
import {Trans, msg, Plural} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePinFeedMutation,
@@ -264,11 +265,10 @@ export function FeedSourceCardLoaded({
{showLikes && feed.type === 'feed' ? (
<Text type="sm-medium" style={[pal.text, pal.textLight]}>
<Plural
value={feed.likeCount || 0}
one="Liked by # user"
other="Liked by # users"
/>
<Trans>
Liked by {feed.likeCount || 0}{' '}
{pluralize(feed.likeCount || 0, 'user')}
</Trans>
</Text>
) : null}
</Pressable>
+1 -1
View File
@@ -78,7 +78,7 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) {
try {
await saveImageToMediaLibrary({uri})
Toast.show(_(msg`Saved to your camera roll`))
Toast.show(_(msg`Saved to your camera roll.`))
} catch (e: any) {
Toast.show(_(msg`Failed to save image: ${String(e)}`))
}
+1 -3
View File
@@ -507,9 +507,7 @@ function CustomHandleForm({
<Text type="xl-medium" style={[s.white, s.textCenter]}>
{canSave
? _(msg`Update to ${handle}`)
: isDNSForm
? _(msg`Verify DNS Record`)
: _(msg`Verify Text File`)}
: _(msg`Verify ${isDNSForm ? 'DNS Record' : 'Text File'}`)}
</Text>
)}
</Button>
+19 -19
View File
@@ -84,26 +84,26 @@ export function Component({}: {}) {
<ScrollView style={[pal.view]} keyboardShouldPersistTaps="handled">
<View style={[styles.titleContainer, pal.view]}>
<Text type="title-xl" style={[s.textCenter, pal.text]}>
<Trans>
Delete Account{' '}
<Text type="title-xl" style={[pal.text, s.bold]}>
"
</Text>
<Text
type="title-xl"
numberOfLines={1}
style={[
isMobile ? styles.titleMobile : styles.titleDesktop,
pal.text,
s.bold,
]}>
{currentAccount?.handle}
</Text>
<Text type="title-xl" style={[pal.text, s.bold]}>
"
</Text>
</Trans>
<Trans>Delete Account</Trans>
</Text>
<View style={[pal.view, s.flexRow]}>
<Text type="title-xl" style={[pal.text, s.bold]}>
{' "'}
</Text>
<Text
type="title-xl"
numberOfLines={1}
style={[
isMobile ? styles.titleMobile : styles.titleDesktop,
pal.text,
s.bold,
]}>
{currentAccount?.handle}
</Text>
<Text type="title-xl" style={[pal.text, s.bold]}>
{'"'}
</Text>
</View>
</View>
{!isEmailSent ? (
<>
+3 -3
View File
@@ -131,10 +131,10 @@ export function Component({
) : (
<View>
<Text style={[pal.textLight]}>
<Text type="md-bold" style={[pal.textLight, s.mr5]}>
<Trans>Not Applicable.</Trans>
</Text>
<Trans>
<Text type="md-bold" style={[pal.textLight]}>
Not Applicable.
</Text>{' '}
This warning is only available for posts with media attached.
</Trans>
</Text>
@@ -14,13 +14,11 @@ import {Dimensions} from 'lib/media/types'
import {getDataUriSize} from 'lib/media/util'
import {gradients, s} from 'lib/styles'
import {Text} from 'view/com/util/text/Text'
import {calculateDimensions} from './cropImageUtil'
enum AspectRatio {
Square = 'square',
Wide = 'wide',
Tall = 'tall',
Custom = 'custom',
}
const DIMS: Record<string, Dimensions> = {
@@ -33,24 +31,17 @@ export const snapPoints = ['0%']
export function Component({
uri,
dimensions,
onSelect,
}: {
uri: string
dimensions?: Dimensions
onSelect: (img?: RNImage) => void
}) {
const {closeModal} = useModalControls()
const pal = usePalette('default')
const {_} = useLingui()
const defaultAspectStyle = dimensions
? AspectRatio.Custom
: AspectRatio.Square
const [as, setAs] = React.useState<AspectRatio>(defaultAspectStyle)
const [as, setAs] = React.useState<AspectRatio>(AspectRatio.Square)
const [scale, setScale] = React.useState<number>(1)
const editorRef = React.useRef<ImageEditor>(null)
const imageEditorWidth = dimensions ? dimensions.width : DIMS[as].width
const imageEditorHeight = dimensions ? dimensions.height : DIMS[as].height
const doSetAs = (v: AspectRatio) => () => setAs(v)
@@ -66,8 +57,8 @@ export function Component({
path: dataUri,
mime: 'image/jpeg',
size: getDataUriSize(dataUri),
width: imageEditorWidth,
height: imageEditorHeight,
width: DIMS[as].width,
height: DIMS[as].height,
})
} else {
onSelect(undefined)
@@ -82,18 +73,7 @@ export function Component({
cropperStyle = styles.cropperWide
} else if (as === AspectRatio.Tall) {
cropperStyle = styles.cropperTall
} else if (as === AspectRatio.Custom) {
const cropperDimensions = calculateDimensions(
550,
imageEditorHeight,
imageEditorWidth,
)
cropperStyle = {
width: cropperDimensions.width,
height: cropperDimensions.height,
}
}
return (
<View>
<View style={[styles.cropper, pal.borderDark, cropperStyle]}>
@@ -101,8 +81,8 @@ export function Component({
ref={editorRef}
style={styles.imageEditor}
image={uri}
width={imageEditorWidth}
height={imageEditorHeight}
width={DIMS[as].width}
height={DIMS[as].height}
scale={scale}
border={0}
/>
@@ -117,40 +97,36 @@ export function Component({
maximumValue={3}
containerStyle={styles.slider}
/>
{as === AspectRatio.Custom ? null : (
<>
<TouchableOpacity
onPress={doSetAs(AspectRatio.Wide)}
accessibilityRole="button"
accessibilityLabel={_(msg`Wide`)}
accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
<RectWideIcon
size={24}
style={as === AspectRatio.Wide ? s.blue3 : pal.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={doSetAs(AspectRatio.Tall)}
accessibilityRole="button"
accessibilityLabel={_(msg`Tall`)}
accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
<RectTallIcon
size={24}
style={as === AspectRatio.Tall ? s.blue3 : pal.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={doSetAs(AspectRatio.Square)}
accessibilityRole="button"
accessibilityLabel={_(msg`Square`)}
accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
<SquareIcon
size={24}
style={as === AspectRatio.Square ? s.blue3 : pal.text}
/>
</TouchableOpacity>
</>
)}
<TouchableOpacity
onPress={doSetAs(AspectRatio.Wide)}
accessibilityRole="button"
accessibilityLabel={_(msg`Wide`)}
accessibilityHint={_(msg`Sets image aspect ratio to wide`)}>
<RectWideIcon
size={24}
style={as === AspectRatio.Wide ? s.blue3 : pal.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={doSetAs(AspectRatio.Tall)}
accessibilityRole="button"
accessibilityLabel={_(msg`Tall`)}
accessibilityHint={_(msg`Sets image aspect ratio to tall`)}>
<RectTallIcon
size={24}
style={as === AspectRatio.Tall ? s.blue3 : pal.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={doSetAs(AspectRatio.Square)}
accessibilityRole="button"
accessibilityLabel={_(msg`Square`)}
accessibilityHint={_(msg`Sets image aspect ratio to square`)}>
<SquareIcon
size={24}
style={as === AspectRatio.Square ? s.blue3 : pal.text}
/>
</TouchableOpacity>
</View>
<View style={styles.btns}>
<TouchableOpacity
@@ -1,13 +0,0 @@
export const calculateDimensions = (
maxWidth: number,
originalHeight: number,
originalWidth: number,
) => {
const aspectRatio = originalWidth / originalHeight
const newHeight = maxWidth / aspectRatio
const newWidth = maxWidth
return {
width: newWidth,
height: newHeight,
}
}
+4 -6
View File
@@ -22,7 +22,7 @@ import {
FontAwesomeIconStyle,
Props,
} from '@fortawesome/react-native-fontawesome'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
@@ -33,6 +33,7 @@ import {HeartIconSolid} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {pluralize} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {colors, s} from 'lib/styles'
import {isWeb} from 'platform/detection'
@@ -175,7 +176,6 @@ let FeedItem = ({
return null
}
let formattedCount = authors.length > 1 ? formatCount(authors.length - 1) : ''
return (
<Link
testID={`feedItem-by-${item.notification.author.handle}`}
@@ -236,10 +236,8 @@ let FeedItem = ({
<Trans>and</Trans>{' '}
</Text>
<Text style={[pal.text, s.bold]}>
{plural(authors.length - 1, {
one: `${formattedCount} other`,
other: `${formattedCount} others`,
})}
{formatCount(authors.length - 1)}{' '}
{pluralize(authors.length - 1, 'other')}
</Text>
</>
) : undefined}
+4 -8
View File
@@ -8,7 +8,7 @@ import {
RichText as RichTextAPI,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
@@ -24,7 +24,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {makeProfileLink} from 'lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {countLines} from 'lib/strings/helpers'
import {countLines, pluralize} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
@@ -336,11 +336,7 @@ let PostThreadItemLoaded = ({
<Text type="xl-bold" style={pal.text}>
{formatCount(post.repostCount)}
</Text>{' '}
<Plural
value={post.repostCount}
one="repost"
other="reposts"
/>
{pluralize(post.repostCount, 'repost')}
</Text>
</Link>
) : null}
@@ -356,7 +352,7 @@ let PostThreadItemLoaded = ({
<Text type="xl-bold" style={pal.text}>
{formatCount(post.likeCount)}
</Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" />
{pluralize(post.likeCount, 'like')}
</Text>
</Link>
) : null}
-3
View File
@@ -17,7 +17,6 @@ import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {listenPostCreated} from '#/state/events'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {STALE} from '#/state/queries'
import {
FeedDescriptor,
@@ -89,7 +88,6 @@ let Feed = ({
const queryClient = useQueryClient()
const {currentAccount} = useSession()
const initialNumToRender = useInitialNumToRender()
const feedFeedback = useFeedFeedbackContext()
const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef<number>(Date.now())
@@ -355,7 +353,6 @@ let Feed = ({
}
initialNumToRender={initialNumToRender}
windowSize={11}
onItemSeen={feedFeedback.onItemSeen}
/>
</View>
)
+4 -57
View File
@@ -16,7 +16,6 @@ import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useComposerControls} from '#/state/shell/composer'
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
import {MAX_POST_LINES} from 'lib/constants'
@@ -46,7 +45,6 @@ export function FeedItem({
post,
record,
reason,
feedContext,
moderation,
isThreadChild,
isThreadLastChild,
@@ -55,7 +53,6 @@ export function FeedItem({
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
feedContext: string | undefined
moderation: ModerationDecision
isThreadChild?: boolean
isThreadLastChild?: boolean
@@ -81,7 +78,6 @@ export function FeedItem({
post={postShadowed}
record={record}
reason={reason}
feedContext={feedContext}
richText={richText}
moderation={moderation}
isThreadChild={isThreadChild}
@@ -97,7 +93,6 @@ let FeedItemInner = ({
post,
record,
reason,
feedContext,
richText,
moderation,
isThreadChild,
@@ -107,7 +102,6 @@ let FeedItemInner = ({
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
reason: AppBskyFeedDefs.ReasonRepost | ReasonFeedSource | undefined
feedContext: string | undefined
richText: RichTextAPI
moderation: ModerationDecision
isThreadChild?: boolean
@@ -122,7 +116,6 @@ let FeedItemInner = ({
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey)
}, [post.uri, post.author])
const {sendInteraction} = useFeedFeedbackContext()
const replyAuthorDid = useMemo(() => {
if (!record?.reply) {
@@ -133,11 +126,6 @@ let FeedItemInner = ({
}, [record?.reply])
const onPressReply = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionReply',
feedContext,
})
openComposer({
replyTo: {
uri: post.uri,
@@ -148,40 +136,11 @@ let FeedItemInner = ({
moderation,
},
})
}, [post, record, openComposer, moderation, sendInteraction, feedContext])
const onOpenAuthor = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughAuthor',
feedContext,
})
}, [sendInteraction, post, feedContext])
const onOpenReposter = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughReposter',
feedContext,
})
}, [sendInteraction, post, feedContext])
const onOpenEmbed = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughEmbed',
feedContext,
})
}, [sendInteraction, post, feedContext])
}, [post, record, openComposer, moderation])
const onBeforePress = React.useCallback(() => {
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#clickthroughItem',
feedContext,
})
precacheProfile(queryClient, post.author)
}, [queryClient, post, sendInteraction, feedContext])
}, [queryClient, post.author])
const outerStyles = [
styles.outer,
@@ -248,8 +207,7 @@ let FeedItemInner = ({
msg`Reposted by ${sanitizeDisplayName(
reason.by.displayName || reason.by.handle,
)}`,
)}
onBeforePress={onOpenReposter}>
)}>
<FontAwesomeIcon
icon="retweet"
style={{
@@ -277,7 +235,6 @@ let FeedItemInner = ({
moderation.ui('displayName'),
)}
href={makeProfileLink(reason.by)}
onBeforePress={onOpenReposter}
/>
</ProfileHoverCard>
</Trans>
@@ -294,7 +251,6 @@ let FeedItemInner = ({
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
onBeforePress={onOpenAuthor}
/>
{isThreadParent && (
<View
@@ -316,7 +272,6 @@ let FeedItemInner = ({
authorHasWarning={!!post.author.labels?.length}
timestamp={post.indexedAt}
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
{!isThreadChild && replyAuthorDid !== '' && (
<View style={[s.flexRow, s.mb2, s.alignCenter]}>
@@ -353,7 +308,6 @@ let FeedItemInner = ({
richText={richText}
postEmbed={post.embed}
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
/>
<PostCtrls
post={post}
@@ -361,7 +315,6 @@ let FeedItemInner = ({
richText={richText}
onPressReply={onPressReply}
logContext="FeedItem"
feedContext={feedContext}
/>
</View>
</View>
@@ -375,13 +328,11 @@ let PostContent = ({
richText,
postEmbed,
postAuthor,
onOpenEmbed,
}: {
moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
@@ -422,11 +373,7 @@ let PostContent = ({
) : undefined}
{postEmbed ? (
<View style={[a.pb_sm]}>
<PostEmbeds
embed={postEmbed}
moderation={moderation}
onOpen={onOpenEmbed}
/>
<PostEmbeds embed={postEmbed} moderation={moderation} />
</View>
) : null}
</ContentHider>
+5 -10
View File
@@ -1,15 +1,14 @@
import React, {memo} from 'react'
import {StyleSheet, View} from 'react-native'
import Svg, {Circle, Line} from 'react-native-svg'
import {AtUri} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {FeedPostSlice} from '#/state/queries/post-feed'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {AtUri} from '@atproto/api'
import {Link} from '../util/Link'
import {Text} from '../util/text/Text'
import Svg, {Circle, Line} from 'react-native-svg'
import {FeedItem} from './FeedItem'
import {usePalette} from 'lib/hooks/usePalette'
import {makeProfileLink} from 'lib/routes/links'
import {Trans} from '@lingui/macro'
let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
if (slice.isThread && slice.items.length > 3) {
@@ -21,7 +20,6 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[0].post}
record={slice.items[0].record}
reason={slice.items[0].reason}
feedContext={slice.items[0].feedContext}
moderation={slice.items[0].moderation}
isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)}
@@ -31,7 +29,6 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[1].post}
record={slice.items[1].record}
reason={slice.items[1].reason}
feedContext={slice.items[1].feedContext}
moderation={slice.items[1].moderation}
isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)}
@@ -42,7 +39,6 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[last].post}
record={slice.items[last].record}
reason={slice.items[last].reason}
feedContext={slice.items[last].feedContext}
moderation={slice.items[last].moderation}
isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)}
@@ -60,7 +56,6 @@ let FeedSlice = ({slice}: {slice: FeedPostSlice}): React.ReactNode => {
post={slice.items[i].post}
record={slice.items[i].record}
reason={slice.items[i].reason}
feedContext={slice.items[i].feedContext}
moderation={slice.items[i].moderation}
isThreadParent={isThreadParentAt(slice.items, i)}
isThreadChild={isThreadChildAt(slice.items, i)}
+7 -9
View File
@@ -6,16 +6,16 @@ import {
AppBskyGraphDefs,
AtUri,
} from '@atproto/api'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {Trans} from '@lingui/macro'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {colors} from '#/lib/styles'
import {TextLink} from '../util/Link'
import {Text} from '../util/text/Text'
import {TextLink} from '../util/Link'
import {makeProfileLink, makeListLink} from '#/lib/routes/links'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {colors} from '#/lib/styles'
export function WhoCanReply({
post,
@@ -143,7 +143,6 @@ function Rule({
<Trans>
users followed by{' '}
<TextLink
type="sm"
href={makeProfileLink(post.author)}
text={`@${post.author.handle}`}
style={pal.link}
@@ -158,7 +157,6 @@ function Rule({
return (
<Trans>
<TextLink
type="sm"
href={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name}
style={pal.link}
+1 -1
View File
@@ -220,7 +220,6 @@ export const TextLink = memo(function TextLink({
)
},
[
onBeforePress,
onPress,
closeModal,
openModal,
@@ -230,6 +229,7 @@ export const TextLink = memo(function TextLink({
disableMismatchWarning,
navigationAction,
openLink,
onBeforePress,
],
)
const hrefAttrs = useMemo(() => {
+1 -24
View File
@@ -1,5 +1,5 @@
import React, {memo} from 'react'
import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
import {FlatListProps, RefreshControl} from 'react-native'
import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
@@ -23,7 +23,6 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
containWeb?: boolean
}
export type ListRef = React.MutableRefObject<FlatList_INTERNAL | null>
@@ -35,7 +34,6 @@ function ListImpl<ItemT>(
onScrolledDownChange,
refreshing,
onRefresh,
onItemSeen,
headerOffset,
style,
...props
@@ -75,25 +73,6 @@ function ListImpl<ItemT>(
},
})
const [onViewableItemsChanged, viewabilityConfig] = React.useMemo(() => {
if (!onItemSeen) {
return [undefined, undefined]
}
return [
(info: {viewableItems: Array<ViewToken>; changed: Array<ViewToken>}) => {
for (const item of info.changed) {
if (item.isViewable) {
onItemSeen(item.item)
}
}
},
{
itemVisiblePercentThreshold: 40,
minimumViewTime: 2e3,
},
]
}, [onItemSeen])
let refreshControl
if (refreshing !== undefined || onRefresh !== undefined) {
refreshControl = (
@@ -123,8 +102,6 @@ function ListImpl<ItemT>(
refreshControl={refreshControl}
onScroll={scrollHandler}
scrollEventThrottle={1}
onViewableItemsChanged={onViewableItemsChanged}
viewabilityConfig={viewabilityConfig}
style={style}
ref={ref}
/>
+32 -119
View File
@@ -20,17 +20,11 @@ export type ListProps<ItemT> = Omit<
headerOffset?: number
refreshing?: boolean
onRefresh?: () => void
onItemSeen?: (item: ItemT) => void
desktopFixedHeight: any // TODO: Better types.
containWeb?: boolean
}
export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
const ON_ITEM_SEEN_WAIT_DURATION = 2e3 // post must be "seen" 2 seconds before capturing
const ON_ITEM_SEEN_INTERSECTION_OPTS = {
rootMargin: '-200px 0px -200px 0px',
} // post must be 200px visible to be "seen"
function ListImpl<ItemT>(
{
ListHeaderComponent,
@@ -49,7 +43,6 @@ function ListImpl<ItemT>(
onRefresh: _unsupportedOnRefresh,
onScrolledDownChange,
onContentSizeChange,
onItemSeen,
renderItem,
extraData,
style,
@@ -99,24 +92,12 @@ function ListImpl<ItemT>(
if (!element) return
return {
get scrollWidth() {
return element.scrollWidth
},
get scrollHeight() {
return element.scrollHeight
},
get clientWidth() {
return element.clientWidth
},
get clientHeight() {
return element.clientHeight
},
get scrollY() {
return element.scrollTop
},
get scrollX() {
return element.scrollLeft
},
scrollWidth: element.scrollWidth,
scrollHeight: element.scrollHeight,
clientWidth: element.clientWidth,
clientHeight: element.clientHeight,
scrollY: element.scrollTop,
scrollX: element.scrollLeft,
scrollTo(options?: ScrollToOptions) {
element.scrollTo(options)
},
@@ -132,24 +113,12 @@ function ListImpl<ItemT>(
}
} else {
return {
get scrollWidth() {
return document.documentElement.scrollWidth
},
get scrollHeight() {
return document.documentElement.scrollHeight
},
get clientWidth() {
return window.innerWidth
},
get clientHeight() {
return window.innerHeight
},
get scrollY() {
return window.scrollY
},
get scrollX() {
return window.scrollX
},
scrollWidth: document.documentElement.scrollWidth,
scrollHeight: document.documentElement.scrollHeight,
clientWidth: window.innerWidth,
clientHeight: window.innerHeight,
scrollY: window.scrollY,
scrollX: window.scrollX,
scrollTo(options: ScrollToOptions) {
window.scrollTo(options)
},
@@ -166,7 +135,7 @@ function ListImpl<ItemT>(
}
}, [containWeb])
const nativeRef = React.useRef<HTMLDivElement>(null)
const nativeRef = React.useRef(null)
React.useImperativeHandle(
ref,
() =>
@@ -288,15 +257,9 @@ function ListImpl<ItemT>(
return (
<View
{...props}
style={[
style,
containWeb && {
flex: 1,
// @ts-expect-error web only
'overflow-y': 'scroll',
},
]}
ref={nativeRef as any}>
// @ts-ignore web only
style={[style, containWeb && {flex: 1, 'overflow-y': 'scroll'}]}
ref={nativeRef}>
<Visibility
onVisibleChange={setIsInsideVisibleTree}
style={
@@ -314,34 +277,30 @@ function ListImpl<ItemT>(
pal.border,
]}>
<Visibility
root={containWeb ? nativeRef : null}
root={containWeb ? nativeRef.current : null}
onVisibleChange={handleAboveTheFoldVisibleChange}
style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
/>
{onStartReached && (
<Visibility
root={containWeb ? nativeRef : null}
root={containWeb ? nativeRef.current : null}
onVisibleChange={onHeadVisibilityChange}
topMargin={(onStartReachedThreshold ?? 0) * 100 + '%'}
/>
)}
{header}
{(data as Array<ItemT>).map((item, index) => {
const key = keyExtractor!(item, index)
return (
<Row<ItemT>
key={key}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
onItemSeen={onItemSeen}
/>
)
})}
{(data as Array<ItemT>).map((item, index) => (
<Row<ItemT>
key={keyExtractor!(item, index)}
item={item}
index={index}
renderItem={renderItem}
extraData={extraData}
/>
))}
{onEndReached && (
<Visibility
root={containWeb ? nativeRef : null}
root={containWeb ? nativeRef.current : null}
onVisibleChange={onTailVisibilityChange}
bottomMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
/>
@@ -383,7 +342,6 @@ let Row = function RowImpl<ItemT>({
index,
renderItem,
extraData: _unused,
onItemSeen,
}: {
item: ItemT
index: number
@@ -392,57 +350,12 @@ let Row = function RowImpl<ItemT>({
| undefined
| ((data: {index: number; item: any; separators: any}) => React.ReactNode)
extraData: any
onItemSeen: ((item: any) => void) | undefined
}): React.ReactNode {
const rowRef = React.useRef(null)
const intersectionTimeout = React.useRef<NodeJS.Timer | undefined>(undefined)
const handleIntersection = useNonReactiveCallback(
(entries: IntersectionObserverEntry[]) => {
batchedUpdates(() => {
if (!onItemSeen) {
return
}
entries.forEach(entry => {
if (entry.isIntersecting) {
if (!intersectionTimeout.current) {
intersectionTimeout.current = setTimeout(() => {
intersectionTimeout.current = undefined
onItemSeen!(item)
}, ON_ITEM_SEEN_WAIT_DURATION)
}
} else {
if (intersectionTimeout.current) {
clearTimeout(intersectionTimeout.current)
intersectionTimeout.current = undefined
}
}
})
})
},
)
React.useEffect(() => {
if (!onItemSeen) {
return
}
const observer = new IntersectionObserver(
handleIntersection,
ON_ITEM_SEEN_INTERSECTION_OPTS,
)
const row: Element | null = rowRef.current!
observer.observe(row)
return () => {
observer.unobserve(row)
}
}, [handleIntersection, onItemSeen])
if (!renderItem) {
return null
}
return (
<View style={styles.row} ref={rowRef}>
<View style={styles.row}>
{renderItem({item, index, separators: null as any})}
</View>
)
@@ -450,13 +363,13 @@ let Row = function RowImpl<ItemT>({
Row = React.memo(Row)
let Visibility = ({
root,
root = null,
topMargin = '0px',
bottomMargin = '0px',
onVisibleChange,
style,
}: {
root?: React.RefObject<HTMLDivElement> | null
root?: Element | null
topMargin?: string
bottomMargin?: string
onVisibleChange: (isVisible: boolean) => void
@@ -480,7 +393,7 @@ let Visibility = ({
React.useEffect(() => {
const observer = new IntersectionObserver(handleIntersection, {
root: root?.current ?? null,
root,
rootMargin: `${topMargin} 0px ${bottomMargin} 0px`,
})
const tail: Element | null = tailRef.current!
+4 -10
View File
@@ -28,7 +28,6 @@ interface PostMetaOpts {
avatarSize?: number
displayNameType?: TypographyVariant
displayNameStyle?: StyleProp<TextStyle>
onOpenAuthor?: () => void
style?: StyleProp<ViewStyle>
}
@@ -44,12 +43,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
: undefined
const queryClient = useQueryClient()
const onOpenAuthor = opts.onOpenAuthor
const onBeforePressAuthor = useCallback(() => {
precacheProfile(queryClient, opts.author)
onOpenAuthor?.()
}, [queryClient, opts.author, onOpenAuthor])
const onBeforePressPost = useCallback(() => {
const onBeforePress = useCallback(() => {
precacheProfile(queryClient, opts.author)
}, [queryClient, opts.author])
@@ -83,7 +77,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
</>
}
href={profileLink}
onBeforePress={onBeforePressAuthor}
onBeforePress={onBeforePress}
onPointerEnter={onPointerEnter}
/>
<TextLinkOnWebOnly
@@ -92,7 +86,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
style={[pal.textLight, {flexShrink: 4}]}
text={'\xa0' + sanitizeHandle(handle, '@')}
href={profileLink}
onBeforePress={onBeforePressAuthor}
onBeforePress={onBeforePress}
onPointerEnter={onPointerEnter}
anchorNoUnderline
/>
@@ -118,7 +112,7 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
title={niceDate(opts.timestamp)}
accessibilityHint=""
href={opts.postHref}
onBeforePress={onBeforePressPost}
onBeforePress={onBeforePress}
/>
)}
</TimeElapsed>
+9 -19
View File
@@ -8,7 +8,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger'
import {usePalette} from 'lib/hooks/usePalette'
import {
useCameraPermission,
@@ -50,7 +49,6 @@ interface EditableUserAvatarProps extends BaseUserAvatarProps {
interface PreviewableUserAvatarProps extends BaseUserAvatarProps {
moderation?: ModerationUI
onBeforePress?: () => void
profile: AppBskyActorDefs.ProfileViewBasic
}
@@ -284,21 +282,15 @@ let EditableUserAvatar = ({
return
}
try {
const croppedImage = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
height: item.height,
width: item.width,
path: item.path,
})
const croppedImage = await openCropper({
mediaType: 'photo',
cropperCircleOverlay: true,
height: item.height,
width: item.width,
path: item.path,
})
onSelectNewAvatar(croppedImage)
} catch (e: any) {
if (!String(e).includes('Canceled')) {
logger.error('Failed to crop banner', {error: e})
}
}
onSelectNewAvatar(croppedImage)
}, [onSelectNewAvatar, requestPhotoAccessIfNeeded])
const onRemoveAvatar = React.useCallback(() => {
@@ -383,16 +375,14 @@ export {EditableUserAvatar}
let PreviewableUserAvatar = ({
moderation,
profile,
onBeforePress,
...rest
}: PreviewableUserAvatarProps): React.ReactNode => {
const {_} = useLingui()
const queryClient = useQueryClient()
const onPress = React.useCallback(() => {
onBeforePress?.()
precacheProfile(queryClient, profile)
}, [profile, queryClient, onBeforePress])
}, [profile, queryClient])
return (
<ProfileHoverCard did={profile.did}>
+19 -26
View File
@@ -1,30 +1,29 @@
import React from 'react'
import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {Image} from 'expo-image'
import {ModerationUI} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {Image} from 'expo-image'
import {useLingui} from '@lingui/react'
import {msg, Trans} from '@lingui/macro'
import {logger} from '#/logger'
import {usePalette} from 'lib/hooks/usePalette'
import {
useCameraPermission,
usePhotoLibraryPermission,
} from 'lib/hooks/usePermissions'
import {colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {useTheme as useAlfTheme, tokens} from '#/alf'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
import {
usePhotoLibraryPermission,
useCameraPermission,
} from 'lib/hooks/usePermissions'
import {usePalette} from 'lib/hooks/usePalette'
import {isAndroid, isNative} from 'platform/detection'
import {Image as RNImage} from 'react-native-image-crop-picker'
import {EventStopper} from 'view/com/util/EventStopper'
import {tokens, useTheme as useAlfTheme} from '#/alf'
import * as Menu from '#/components/Menu'
import {
Camera_Filled_Stroke2_Corner0_Rounded as CameraFilled,
Camera_Stroke2_Corner0_Rounded as Camera,
} from '#/components/icons/Camera'
import {StreamingLive_Stroke2_Corner0_Rounded as Library} from '#/components/icons/StreamingLive'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import * as Menu from '#/components/Menu'
import {openCamera, openCropper, openPicker} from '../../../lib/media/picker'
export function UserBanner({
type,
@@ -65,20 +64,14 @@ export function UserBanner({
return
}
try {
onSelectNewBanner?.(
await openCropper({
mediaType: 'photo',
path: items[0].path,
width: 3000,
height: 1000,
}),
)
} catch (e: any) {
if (!String(e).includes('Canceled')) {
logger.error('Failed to crop banner', {error: e})
}
}
onSelectNewBanner?.(
await openCropper({
mediaType: 'photo',
path: items[0].path,
width: 3000,
height: 1000,
}),
)
}, [onSelectNewBanner, requestPhotoAccessIfNeeded])
const onRemoveBanner = React.useCallback(() => {
+2 -49
View File
@@ -18,7 +18,6 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
import {getTranslatorLink} from '#/locale/helpers'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
import {useLanguagePrefs} from '#/state/preferences'
import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
@@ -37,10 +36,6 @@ import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard'
import {CodeBrackets_Stroke2_Corner0_Rounded as CodeBrackets} from '#/components/icons/CodeBrackets'
import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
@@ -58,7 +53,6 @@ let PostDropdownBtn = ({
postAuthor,
postCid,
postUri,
postFeedContext,
record,
richText,
style,
@@ -69,7 +63,6 @@ let PostDropdownBtn = ({
postAuthor: AppBskyActorDefs.ProfileViewBasic
postCid: string
postUri: string
postFeedContext: string | undefined
record: AppBskyFeedPost.Record
richText: RichTextAPI
style?: StyleProp<ViewStyle>
@@ -88,7 +81,6 @@ let PostDropdownBtn = ({
const postDeleteMutation = usePostDeleteMutation()
const hiddenPosts = useHiddenPosts()
const {hidePost} = useHiddenPostsApi()
const feedFeedback = useFeedFeedbackContext()
const openLink = useOpenLink()
const navigation = useNavigation()
const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
@@ -191,24 +183,6 @@ let PostDropdownBtn = ({
shareUrl(url)
}, [href])
const onPressShowMore = React.useCallback(() => {
feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestMore',
item: postUri,
feedContext: postFeedContext,
})
Toast.show('Feedback sent!')
}, [feedFeedback, postUri, postFeedContext])
const onPressShowLess = React.useCallback(() => {
feedFeedback.sendInteraction({
event: 'app.bsky.feed.defs#requestLess',
item: postUri,
feedContext: postFeedContext,
})
Toast.show('Feedback sent!')
}, [feedFeedback, postUri, postFeedContext])
const canEmbed = isWeb && gtMobile && !hideInPWI
return (
@@ -288,32 +262,10 @@ let PostDropdownBtn = ({
)}
</Menu.Group>
{hasSession && feedFeedback.enabled && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postDropdownShowMoreBtn"
label={_(msg`Show more like this`)}
onPress={onPressShowMore}>
<Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSmile} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownShowLessBtn"
label={_(msg`Show less like this`)}
onPress={onPressShowLess}>
<Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText>
<Menu.ItemIcon icon={EmojiSad} position="right" />
</Menu.Item>
</Menu.Group>
</>
)}
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
<Menu.Item
testID="postDropdownMuteThreadBtn"
@@ -356,6 +308,7 @@ let PostDropdownBtn = ({
{hasSession && (
<>
<Menu.Divider />
<Menu.Group>
{!isAuthor && (
<Menu.Item
+11 -63
View File
@@ -12,18 +12,18 @@ import {
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
import {msg, plural} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10, HITSLOP_20} from '#/lib/constants'
import {CommentBottomArrow, HeartIcon, HeartIconSolid} from '#/lib/icons'
import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {pluralize} from '#/lib/strings/helpers'
import {toShareUrl} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles'
import {useTheme} from '#/lib/ThemeContext'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useModalControls} from '#/state/modals'
import {
usePostLikeMutationQueue,
@@ -44,7 +44,6 @@ let PostCtrls = ({
post,
record,
richText,
feedContext,
style,
onPressReply,
logContext,
@@ -53,7 +52,6 @@ let PostCtrls = ({
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
richText: RichTextAPI
feedContext?: string | undefined
style?: StyleProp<ViewStyle>
onPressReply: () => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
@@ -69,7 +67,6 @@ let PostCtrls = ({
)
const requireAuth = useRequireAuth()
const loggedOutWarningPromptControl = useDialogControl()
const {sendInteraction} = useFeedFeedbackContext()
const playHaptic = useHaptics()
const shouldShowLoggedOutWarning = React.useMemo(() => {
@@ -89,11 +86,6 @@ let PostCtrls = ({
try {
if (!post.viewer?.like) {
playHaptic()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionLike',
feedContext,
})
await queueLike()
} else {
await queueUnlike()
@@ -103,26 +95,13 @@ let PostCtrls = ({
throw e
}
}
}, [
playHaptic,
post.uri,
post.viewer?.like,
queueLike,
queueUnlike,
sendInteraction,
feedContext,
])
}, [playHaptic, post.viewer?.like, queueLike, queueUnlike])
const onRepost = useCallback(async () => {
closeModal()
try {
if (!post.viewer?.repost) {
playHaptic()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionRepost',
feedContext,
})
await queueRepost()
} else {
await queueUnrepost()
@@ -132,24 +111,10 @@ let PostCtrls = ({
throw e
}
}
}, [
closeModal,
post.uri,
post.viewer?.repost,
playHaptic,
queueRepost,
queueUnrepost,
sendInteraction,
feedContext,
])
}, [closeModal, post.viewer?.repost, playHaptic, queueRepost, queueUnrepost])
const onQuote = useCallback(() => {
closeModal()
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionQuote',
feedContext,
})
openComposer({
quote: {
uri: post.uri,
@@ -169,8 +134,6 @@ let PostCtrls = ({
post.indexedAt,
record.text,
playHaptic,
sendInteraction,
feedContext,
])
const onShare = useCallback(() => {
@@ -178,12 +141,7 @@ let PostCtrls = ({
const href = makeProfileLink(post.author, 'post', urip.rkey)
const url = toShareUrl(href)
shareUrl(url)
sendInteraction({
item: post.uri,
event: 'app.bsky.feed.defs#interactionShare',
feedContext,
})
}, [post.uri, post.author, sendInteraction, feedContext])
}, [post.uri, post.author])
return (
<View style={[styles.ctrls, style]}>
@@ -201,10 +159,9 @@ let PostCtrls = ({
}
}}
accessibilityRole="button"
accessibilityLabel={plural(post.replyCount || 0, {
one: 'Reply (# reply)',
other: 'Reply (# replies)',
})}
accessibilityLabel={`Reply (${post.replyCount} ${
post.replyCount === 1 ? 'reply' : 'replies'
})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<CommentBottomArrow
@@ -236,17 +193,9 @@ let PostCtrls = ({
requireAuth(() => onPressToggleLike())
}}
accessibilityRole="button"
accessibilityLabel={
post.viewer?.like
? plural(post.likeCount || 0, {
one: 'Unlike (# like)',
other: 'Unlike (# likes)',
})
: plural(post.likeCount || 0, {
one: 'Like (# like)',
other: 'Like (# likes)',
})
}
accessibilityLabel={`${
post.viewer?.like ? _(msg`Unlike`) : _(msg`Like`)
} (${post.likeCount} ${pluralize(post.likeCount || 0, 'like')})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
{post.viewer?.like ? (
@@ -311,7 +260,6 @@ let PostCtrls = ({
postAuthor={post.author}
postCid={post.cid}
postUri={post.uri}
postFeedContext={feedContext}
record={record}
richText={richText}
style={styles.btnPad}
@@ -4,10 +4,11 @@ import {RepostIcon} from 'lib/icons'
import {s, colors} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {Text} from '../text/Text'
import {pluralize} from 'lib/strings/helpers'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals'
import {useRequireAuth} from '#/state/session'
import {msg, plural} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
interface Props {
@@ -58,7 +59,7 @@ let RepostButton = ({
isReposted
? _(msg`Undo repost`)
: _(msg({message: 'Repost', context: 'action'}))
} (${plural(repostCount || 0, {one: '# repost', other: '# reposts'})})`}
} (${repostCount} ${pluralize(repostCount || 0, 'repost')})`}
accessibilityHint=""
hitSlop={big ? HITSLOP_20 : HITSLOP_10}>
<RepostIcon
@@ -19,12 +19,10 @@ import {Text} from '../text/Text'
export const ExternalLinkEmbed = ({
link,
onOpen,
style,
hideAlt,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
hideAlt?: boolean
}) => {
@@ -46,7 +44,7 @@ export const ExternalLinkEmbed = ({
return (
<View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}>
<LinkWrapper link={link} onOpen={onOpen} style={style}>
<LinkWrapper link={link} style={style}>
{link.thumb && !embedPlayerParams ? (
<Image
style={{
@@ -99,12 +97,10 @@ export const ExternalLinkEmbed = ({
function LinkWrapper({
link,
onOpen,
style,
children,
}: {
link: AppBskyEmbedExternal.ViewExternal
onOpen?: () => void
style?: StyleProp<ViewStyle>
children: React.ReactNode
}) {
@@ -129,7 +125,6 @@ function LinkWrapper({
style,
]}
hoverStyle={t.atoms.border_contrast_high}
onBeforePress={onOpen}
onLongPress={onShareExternal}>
{children}
</Link>
+5 -9
View File
@@ -6,7 +6,6 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'
import {parseAltFromGIFDescription} from '#/lib/gif-alt-text'
import {isWeb} from '#/platform/detection'
import {EmbedPlayerParams} from 'lib/strings/embed-player'
import {useAutoplayDisabled} from 'state/preferences'
@@ -117,11 +116,6 @@ export function GifEmbed({
playerRef.current?.toggleAsync()
}, [])
const parsedAlt = React.useMemo(
() => parseAltFromGIFDescription(link.description),
[link],
)
return (
<View
style={[a.rounded_sm, a.overflow_hidden, a.mt_sm, {maxWidth: '100%'}]}>
@@ -146,10 +140,12 @@ export function GifEmbed({
onPlayerStateChange={onPlayerStateChange}
ref={playerRef}
accessibilityHint={_(msg`Animated GIF`)}
accessibilityLabel={parsedAlt.alt}
accessibilityLabel={link.description.replace('Alt text: ', '')}
/>
{!hideAlt && parsedAlt.isPreferred && <AltText text={parsedAlt.alt} />}
{!hideAlt && link.description.startsWith('Alt text: ') && (
<AltText text={link.description.replace('Alt text: ', '')} />
)}
</View>
</View>
)
@@ -178,7 +174,7 @@ function AltText({text}: {text: string}) {
<Prompt.TitleText>
<Trans>Alt Text</Trans>
</Prompt.TitleText>
<Prompt.DescriptionText selectable>{text}</Prompt.DescriptionText>
<Prompt.DescriptionText>{text}</Prompt.DescriptionText>
<Prompt.Actions>
<Prompt.Action
onPress={control.close}
+2 -17
View File
@@ -42,11 +42,9 @@ import {PostEmbeds} from '.'
export function MaybeQuoteEmbed({
embed,
onOpen,
style,
}: {
embed: AppBskyEmbedRecord.View
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
@@ -59,7 +57,6 @@ export function MaybeQuoteEmbed({
<QuoteEmbedModerated
viewRecord={embed.record}
postRecord={embed.record.value}
onOpen={onOpen}
style={style}
/>
)
@@ -88,12 +85,10 @@ export function MaybeQuoteEmbed({
function QuoteEmbedModerated({
viewRecord,
postRecord,
onOpen,
style,
}: {
viewRecord: AppBskyEmbedRecord.ViewRecord
postRecord: AppBskyFeedPost.Record
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const moderationOpts = useModerationOpts()
@@ -113,25 +108,16 @@ function QuoteEmbedModerated({
embeds: viewRecord.embeds,
}
return (
<QuoteEmbed
quote={quote}
moderation={moderation}
onOpen={onOpen}
style={style}
/>
)
return <QuoteEmbed quote={quote} moderation={moderation} style={style} />
}
export function QuoteEmbed({
quote,
moderation,
onOpen,
style,
}: {
quote: ComposerOptsQuote
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const queryClient = useQueryClient()
@@ -164,8 +150,7 @@ export function QuoteEmbed({
const onBeforePress = React.useCallback(() => {
precacheProfile(queryClient, quote.author)
onOpen?.()
}, [queryClient, quote.author, onOpen])
}, [queryClient, quote.author])
return (
<ContentHider modui={moderation?.ui('contentList')}>
+4 -10
View File
@@ -38,12 +38,10 @@ type Embed =
export function PostEmbeds({
embed,
moderation,
onOpen,
style,
}: {
embed?: Embed
moderation?: ModerationDecision
onOpen?: () => void
style?: StyleProp<ViewStyle>
}) {
const pal = usePalette('default')
@@ -54,12 +52,8 @@ export function PostEmbeds({
if (AppBskyEmbedRecordWithMedia.isView(embed)) {
return (
<View style={style}>
<PostEmbeds
embed={embed.media}
moderation={moderation}
onOpen={onOpen}
/>
<MaybeQuoteEmbed embed={embed.record} onOpen={onOpen} />
<PostEmbeds embed={embed.media} moderation={moderation} />
<MaybeQuoteEmbed embed={embed.record} />
</View>
)
}
@@ -86,7 +80,7 @@ export function PostEmbeds({
// quote post
// =
return <MaybeQuoteEmbed embed={embed} style={style} onOpen={onOpen} />
return <MaybeQuoteEmbed embed={embed} style={style} />
}
// image embed
@@ -157,7 +151,7 @@ export function PostEmbeds({
const link = embed.external
return (
<ContentHider modui={moderation?.ui('contentMedia')}>
<ExternalLinkEmbed link={link} onOpen={onOpen} style={style} />
<ExternalLinkEmbed link={link} style={style} />
</ContentHider>
)
}
-1
View File
@@ -804,7 +804,6 @@ function MockPostFeedItem({
record={post.record as AppBskyFeedPost.Record}
moderation={moderation}
reason={undefined}
feedContext={''}
/>
)
}
+1 -1
View File
@@ -740,7 +740,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
paddingHorizontal: 18,
paddingHorizontal: 16,
paddingVertical: 12,
},
@@ -12,7 +12,7 @@ import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {ViewHeader} from 'view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import debounce from 'lodash.debounce'
import {Trans, msg, Plural} from '@lingui/macro'
import {Trans, msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
usePreferencesQuery,
@@ -27,6 +27,7 @@ function RepliesThresholdInput({
initialValue: number
}) {
const pal = usePalette('default')
const {_} = useLingui()
const [value, setValue] = useState(initialValue)
const {mutate: setFeedViewPref} = useSetFeedViewPreferencesMutation()
const preValue = React.useRef(initialValue)
@@ -63,12 +64,13 @@ function RepliesThresholdInput({
thumbTintColor={colors.blue3}
/>
<Text type="xs" style={pal.text}>
<Plural
value={value}
_0="Show all replies"
one="Show replies with at least # like"
other="Show replies with at least # likes"
/>
{value === 0
? _(msg`Show all replies`)
: _(
msg`Show replies with at least ${value} ${
value > 1 ? `likes` : `like`
}`,
)}
</Text>
</View>
)
+14 -22
View File
@@ -1,6 +1,6 @@
import React, {useCallback, useMemo} from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {msg, Plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -10,7 +10,6 @@ import {HITSLOP_20} from '#/lib/constants'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
import {FeedDescriptor} from '#/state/queries/post-feed'
@@ -36,6 +35,7 @@ import {makeCustomFeedLink} from 'lib/routes/links'
import {CommonNavigatorParams} from 'lib/routes/types'
import {NavigationProp} from 'lib/routes/types'
import {shareUrl} from 'lib/sharing'
import {pluralize} from 'lib/strings/helpers'
import {makeRecordUri} from 'lib/strings/url-helpers'
import {toShareUrl} from 'lib/strings/url-helpers'
import {s} from 'lib/styles'
@@ -463,8 +463,6 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const queryClient = useQueryClient()
const isScreenFocused = useIsFocused()
const {hasSession} = useSession()
const feedFeedback = useFeedFeedback(feed, hasSession)
const onScrollToTop = useCallback(() => {
scrollElRef.current?.scrollToOffset({
@@ -492,19 +490,17 @@ const FeedSection = React.forwardRef<SectionRef, FeedSectionProps>(
return (
<View>
<FeedFeedbackProvider value={feedFeedback}>
<Feed
enabled={isFocused}
feed={feed}
pollInterval={60e3}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
</FeedFeedbackProvider>
<Feed
enabled={isFocused}
feed={feed}
pollInterval={60e3}
disablePoll={hasNew}
scrollElRef={scrollElRef}
onHasNew={setHasNew}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
headerOffset={headerHeight}
/>
{(isScrolledDown || hasNew) && (
<LoadLatestBtn
onPress={onScrollToTop}
@@ -601,11 +597,7 @@ function AboutSection({
label={_(msg`View users who like this feed`)}
to={makeCustomFeedLink(feedOwnerDid, feedRkey, 'liked-by')}
style={[t.atoms.text_contrast_medium, a.font_bold]}>
<Plural
value={likeCount}
one="Liked by # user"
other="Liked by # users"
/>
{_(msg`Liked by ${likeCount} ${pluralize(likeCount, 'user')}`)}
</InlineLinkText>
)}
</View>
+2 -8
View File
@@ -22,15 +22,9 @@ export function ListContained() {
<>
<View style={{width: '100%', height: 300}}>
<ScrollProvider
onScroll={e => {
onScroll={() => {
'worklet'
console.log(
JSON.stringify({
contentOffset: e.contentOffset,
layoutMeasurement: e.layoutMeasurement,
contentSize: e.contentSize,
}),
)
console.log('onScroll')
}}>
<List
data={data}
+7 -17
View File
@@ -13,7 +13,7 @@ import {
FontAwesomeIcon,
FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome'
import {msg, Plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {StackActions, useNavigation} from '@react-navigation/native'
@@ -42,6 +42,7 @@ import {
} from 'lib/icons'
import {getTabState, TabState} from 'lib/routes/helpers'
import {NavigationProp} from 'lib/routes/types'
import {pluralize} from 'lib/strings/helpers'
import {colors, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection'
@@ -89,26 +90,15 @@ let DrawerProfileCard = ({
@{account.handle}
</Text>
<Text type="xl" style={[pal.textLight, styles.profileCardFollowers]}>
<Trans>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)}
</Text>{' '}
<Plural
value={profile?.followersCount || 0}
one="follower"
other="followers"
/>
</Trans>{' '}
&middot;{' '}
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)}
</Text>{' '}
{pluralize(profile?.followersCount || 0, 'follower')} &middot;{' '}
<Trans>
<Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followsCount ?? 0)}
</Text>{' '}
<Plural
value={profile?.followsCount || 0}
one="following"
other="following"
/>
following
</Trans>
</Text>
</TouchableOpacity>
+1 -15
View File
@@ -51,14 +51,6 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif;
}
#preload {
width: 100px;
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
/* Buttons and inputs have a font set by UA, so we'll have to reset that */
button, input, textarea {
font: inherit;
@@ -309,13 +301,7 @@
</div>
</form>
</noscript>
<!-- The root element for your Expo app. -->
<div id="root">
<div id="preload">
<!-- Bluesky SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 360 320"><path fill="#0085ff" d="M180 142c-16.3-31.7-60.7-90.8-102-120C38.5-5.9 23.4-1 13.5 3.4 2.1 8.6 0 26.2 0 36.5c0 10.4 5.7 84.8 9.4 97.2 12.2 41 55.7 55 95.7 50.5-58.7 8.6-110.8 30-42.4 106.1 75.1 77.9 103-16.7 117.3-64.6 14.3 48 30.8 139 116 64.6 64-64.6 17.6-97.5-41.1-106.1 40 4.4 83.5-9.5 95.7-50.5 3.7-12.4 9.4-86.8 9.4-97.2 0-10.3-2-27.9-13.5-33C336.5-1 321.5-6 282 22c-41.3 29.2-85.7 88.3-102 120Z"/></svg>
</div>
</div>
<div id="root"></div>
</body>
</html>
+67 -110
View File
@@ -2958,15 +2958,15 @@
mv "~2"
safe-json-stringify "~1"
"@expo/cli@0.17.10":
version "0.17.10"
resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.17.10.tgz#7dd5e2b4a01f5d29698c431729a19878fbd806f5"
integrity sha512-Jw2wY+lsavP9GRqwwLqF/SvB7w2GZ4sWBMcBKTZ8F0lWjwmLGAUt4WYquf20agdmnY/oZUHvWNkrz/t3SflhnA==
"@expo/cli@0.17.8":
version "0.17.8"
resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.17.8.tgz#4abe0d8c604b73a6e1d0a10f34e993cbf1cbad42"
integrity sha512-yfkoghCltbGPDbRI71Qu3puInjXx4wO82+uhW82qbWLvosfIN7ep5Gr0Lq54liJpvlUG6M0IXM1GiGqcCyP12w==
dependencies:
"@babel/runtime" "^7.20.0"
"@expo/code-signing-certificates" "0.0.5"
"@expo/config" "~8.5.0"
"@expo/config-plugins" "~7.9.0"
"@expo/config-plugins" "~7.8.0"
"@expo/devcert" "^1.0.0"
"@expo/env" "~0.2.2"
"@expo/image-utils" "^0.4.0"
@@ -2975,7 +2975,7 @@
"@expo/osascript" "^2.0.31"
"@expo/package-manager" "^1.1.1"
"@expo/plist" "^0.1.0"
"@expo/prebuild-config" "6.8.1"
"@expo/prebuild-config" "6.7.4"
"@expo/rudder-sdk-node" "1.1.1"
"@expo/spawn-async" "1.5.0"
"@expo/xcpretty" "^4.3.0"
@@ -3070,10 +3070,10 @@
xcode "^3.0.1"
xml2js "0.6.0"
"@expo/config-plugins@7.9.1", "@expo/config-plugins@~7.9.0":
version "7.9.1"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.9.1.tgz#fe4f7e4f9d4e87f2dcf2344ffdc59eb466dd5d2e"
integrity sha512-ICt6Jed1J0tPYMQrJ8K5Qusgih2I6pZ2PU4VSvxsN3T4n97L13XpYV1vyq1Uc/HMl3UhOwldipmgpEbCfeDqsQ==
"@expo/config-plugins@7.8.4":
version "7.8.4"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.4.tgz#533b5d536c1dc8b5544d64878b51bda28f2e1a1f"
integrity sha512-hv03HYxb/5kX8Gxv/BTI8TLc9L06WzqAfHRRXdbar4zkLcP2oTzvsLEF4/L/TIpD3rsnYa0KU42d0gWRxzPCJg==
dependencies:
"@expo/config-types" "^50.0.0-alpha.1"
"@expo/fingerprint" "^0.6.0"
@@ -3114,6 +3114,29 @@
xcode "^3.0.1"
xml2js "0.4.23"
"@expo/config-plugins@~7.8.2":
version "7.8.2"
resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-7.8.2.tgz#c00ce93c4d6c2cb9e345ed9cd56ceeea05ab8ddb"
integrity sha512-XM2eXA5EvcpmXFCui48+bVy8GTskYSjPf2yC+LliYv8PDcedu7+pdgmbnvH4eZCyHfTMO8/UiF+w8e5WgOEj5A==
dependencies:
"@expo/config-types" "^50.0.0-alpha.1"
"@expo/fingerprint" "^0.6.0"
"@expo/json-file" "~8.3.0"
"@expo/plist" "^0.1.0"
"@expo/sdk-runtime-versions" "^1.0.0"
"@react-native/normalize-color" "^2.0.0"
chalk "^4.1.2"
debug "^4.3.1"
find-up "~5.0.0"
getenv "^1.0.0"
glob "7.1.6"
resolve-from "^5.0.0"
semver "^7.5.3"
slash "^3.0.0"
slugify "^1.6.6"
xcode "^3.0.1"
xml2js "0.6.0"
"@expo/config-types@^47.0.0":
version "47.0.0"
resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-47.0.0.tgz#99eeabe0bba7a776e0f252b78beb0c574692c38d"
@@ -3124,13 +3147,13 @@
resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-50.0.0.tgz#b534d3ec997ec60f8af24f6ad56244c8afc71a0b"
integrity sha512-0kkhIwXRT6EdFDwn+zTg9R2MZIAEYGn1MVkyRohAd+C9cXOb5RA8WLQi7vuxKF9m1SMtNAUrf0pO+ENK0+/KSw==
"@expo/config@8.5.6":
version "8.5.6"
resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.6.tgz#e37ba437a1718ed4629e1dd130a7aace25312b89"
integrity sha512-wF5awSg6MNn1cb1lIgjnhOn5ov2TEUTnkAVCsOl0QqDwcP+YIerteSFwjn9V52UZvg58L+LKxpCuGbw5IHavbg==
"@expo/config@8.5.4":
version "8.5.4"
resolved "https://registry.yarnpkg.com/@expo/config/-/config-8.5.4.tgz#bb5eb06caa36e4e35dc8c7647fae63e147b830ca"
integrity sha512-ggOLJPHGzJSJHVBC1LzwXwR6qUn8Mw7hkc5zEKRIdhFRuIQ6s2FE4eOvP87LrNfDF7eZGa6tJQYsiHSmZKG+8Q==
dependencies:
"@babel/code-frame" "~7.10.4"
"@expo/config-plugins" "~7.9.0"
"@expo/config-plugins" "~7.8.2"
"@expo/config-types" "^50.0.0"
"@expo/json-file" "^8.2.37"
getenv "^1.0.0"
@@ -3295,10 +3318,10 @@
json5 "^2.2.2"
write-file-atomic "^2.3.0"
"@expo/metro-config@0.17.7":
version "0.17.7"
resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.17.7.tgz#c877a9558f3b97447cc9cf382971403834d84b46"
integrity sha512-3vAdinAjMeRwdhGWWLX6PziZdAPvnyJ6KVYqnJErHHqH0cA6dgAENT3Vq6PEM1H2HgczKr2d5yG9AMgwy848ow==
"@expo/metro-config@0.17.6":
version "0.17.6"
resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.17.6.tgz#f1f4ef056aa357c1dba3841de465f5d319f17216"
integrity sha512-WaC1C+sLX/Wa7irwUigLhng3ckmXIEQefZczB8DfYmleV6uhfWWo2kz/HijFBpV7FKs2cW6u8J/aBQpFkxlcqg==
dependencies:
"@babel/core" "^7.20.0"
"@babel/generator" "^7.20.5"
@@ -3422,22 +3445,6 @@
semver "7.5.3"
xml2js "0.6.0"
"@expo/prebuild-config@6.8.1":
version "6.8.1"
resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-6.8.1.tgz#5d562b1d6b2e5e4727a3c61acf1a4ed6117b94d8"
integrity sha512-ptK9e0dcj1eYlAWV+fG+QkuAWcLAT1AmtEbj++tn7ZjEj8+LkXRM73LCOEGaF0Er8i8ZWNnaVsgGW4vjgP5ZsA==
dependencies:
"@expo/config" "~8.5.0"
"@expo/config-plugins" "~7.9.0"
"@expo/config-types" "^50.0.0-alpha.1"
"@expo/image-utils" "^0.4.0"
"@expo/json-file" "^8.2.37"
debug "^4.3.1"
fs-extra "^9.0.0"
resolve-from "^5.0.0"
semver "7.5.3"
xml2js "0.6.0"
"@expo/rudder-sdk-node@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@expo/rudder-sdk-node/-/rudder-sdk-node-1.1.1.tgz#6aa575f346833eb6290282118766d4919c808c6a"
@@ -3582,54 +3589,6 @@
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2"
integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==
"@formatjs/ecma402-abstract@1.18.0":
version "1.18.0"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.18.0.tgz#e2120e7101020140661b58430a7ff4262705a2f2"
integrity sha512-PEVLoa3zBevWSCZzPIM/lvPCi8P5l4G+NXQMc/CjEiaCWgyHieUoo0nM7Bs0n/NbuQ6JpXEolivQ9pKSBHaDlA==
dependencies:
"@formatjs/intl-localematcher" "0.5.2"
tslib "^2.4.0"
"@formatjs/intl-enumerator@1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-enumerator/-/intl-enumerator-1.4.3.tgz#8d278c273485d7c6219916509fbd51ce3142064d"
integrity sha512-0NpTmAQnDokPoB5aVtXvOdtrUq/uEuPPhBUAr57TYYDjI5MwfFXt8F6JCm6s6CPI0inL8+nxPLjjqH0qyNnP4Q==
dependencies:
tslib "^2.4.0"
"@formatjs/intl-getcanonicallocales@2.3.0":
version "2.3.0"
resolved "https://registry.yarnpkg.com/@formatjs/intl-getcanonicallocales/-/intl-getcanonicallocales-2.3.0.tgz#b6c6fa1c664e30a61f27fa6399a76159d82a5842"
integrity sha512-BOXbLwqQ7nKua/l7tKqDLRN84WupDXFDhGJQMFvsMVA2dKuOdRaWTxWpL3cJ7qPkoNw11Jf+Xpj4OSPBBvW0eQ==
dependencies:
tslib "^2.4.0"
"@formatjs/intl-locale@^3.4.3":
version "3.4.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-locale/-/intl-locale-3.4.3.tgz#fdd2a3978b03aa76965abbca86526bb1d02973b6"
integrity sha512-g/35yMikkkRmLYmqE4W74gvZyKa768oC9OmUFzfLmH3CVYF3v2kvAZI0WsxWLbxYj8TT7wBDeLIL3aIlRw4Osw==
dependencies:
"@formatjs/ecma402-abstract" "1.18.0"
"@formatjs/intl-enumerator" "1.4.3"
"@formatjs/intl-getcanonicallocales" "2.3.0"
tslib "^2.4.0"
"@formatjs/intl-localematcher@0.5.2":
version "0.5.2"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.5.2.tgz#5fcf029fd218905575e5080fa33facdcb623d532"
integrity sha512-txaaE2fiBMagLrR4jYhxzFO6wEdEG4TPMqrzBAcbr4HFUYzH/YC+lg6OIzKCHm8WgDdyQevxbAAV1OgcXctuGw==
dependencies:
tslib "^2.4.0"
"@formatjs/intl-pluralrules@^5.2.10":
version "5.2.10"
resolved "https://registry.yarnpkg.com/@formatjs/intl-pluralrules/-/intl-pluralrules-5.2.10.tgz#379fc06133625df0cae715c1d902001974ff3279"
integrity sha512-wfJypePrbOByaZVPP1moLXHgS9LeAvi9coP95XZX7ySVrwdDGPnxz9Pw+o7J1o8AjLxjiqGrvAi74key5zzIjQ==
dependencies:
"@formatjs/ecma402-abstract" "1.18.0"
"@formatjs/intl-localematcher" "0.5.2"
tslib "^2.4.0"
"@fortawesome/fontawesome-common-types@6.4.2":
version "6.4.2"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.2.tgz#1766039cad33f8ad87f9467b98e0d18fbc8f01c5"
@@ -7729,13 +7688,6 @@
dependencies:
"@types/lodash" "*"
"@types/lodash.throttle@^4.1.9":
version "4.1.9"
resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5"
integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==
dependencies:
"@types/lodash" "*"
"@types/lodash@*":
version "4.14.197"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.197.tgz#e95c5ddcc814ec3e84c891910a01e0c8a378c54b"
@@ -9013,10 +8965,10 @@ babel-preset-expo@^10.0.0:
babel-plugin-react-native-web "~0.18.10"
react-refresh "0.14.0"
babel-preset-expo@~10.0.2:
version "10.0.2"
resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-10.0.2.tgz#5aae992b8c85dce6cf98334c9991d3052c567950"
integrity sha512-hg06qdSTK7MjKmFXSiq6cFoIbI3n3uT8a3NI2EZoISWhu+tedCj4DQduwi+3adFuRuYvAwECI0IYn/5iGh5zWQ==
babel-preset-expo@~10.0.1:
version "10.0.1"
resolved "https://registry.yarnpkg.com/babel-preset-expo/-/babel-preset-expo-10.0.1.tgz#a0e7ad0119f46e58cb3f0738c3ca0c6e97b69c11"
integrity sha512-uWIGmLfbP3dS5+8nesxaW6mQs41d4iP7X82ZwRdisB/wAhKQmuJM9Y1jQe4006uNYkw6Phf2TT03ykLVro7KuQ==
dependencies:
"@babel/plugin-proposal-decorators" "^7.12.9"
"@babel/plugin-transform-export-namespace-from" "^7.22.11"
@@ -11924,7 +11876,7 @@ expo-eas-client@~0.11.0:
resolved "https://registry.yarnpkg.com/expo-eas-client/-/expo-eas-client-0.11.0.tgz#0f25aa497849cade7ebef55c0631093a87e58b07"
integrity sha512-99W0MUGe3U4/MY1E9UeJ4uKNI39mN8/sOGA0Le8XC47MTbwbLoVegHR3C5y2fXLwLn7EpfNxAn5nlxYjY3gD2A==
expo-file-system@^16.0.9, expo-file-system@~16.0.9:
expo-file-system@^16.0.9:
version "16.0.9"
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.9.tgz#cbd6c4b228b60a6b6c71fd1b91fe57299fb24da7"
integrity sha512-3gRPvKVv7/Y7AdD9eHMIdfg5YbUn2zbwKofjsloTI5sEC57SLUFJtbLvUCz9Pk63DaSQ7WIE1JM0EASyvuPbuw==
@@ -11934,6 +11886,11 @@ expo-file-system@~16.0.0:
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.1.tgz#326b7c2f6e53e1a0eaafc9769578aafb3f9c9f43"
integrity sha512-/U6ufN2wRPgg4m2a9sqbL3dThqQsysT022qulEXWnUTmNaqnzYSk9ihjDWqoqjXLi9slQLsyok5t6CNzhM7HPw==
expo-file-system@~16.0.8:
version "16.0.8"
resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-16.0.8.tgz#13c79a8e06e42a8e76e9297df6920597a011d989"
integrity sha512-yDbVT0TUKd7ewQjaY5THum2VRFx2n/biskGhkUmLh3ai21xjIVtaeIzHXyv9ir537eVgt4ReqDNWi7jcXjdUcA==
expo-font@~11.10.3:
version "11.10.3"
resolved "https://registry.yarnpkg.com/expo-font/-/expo-font-11.10.3.tgz#a3115ebda8e09bd7cb8052619a4bbe606f0c17f4"
@@ -12027,10 +11984,10 @@ expo-modules-autolinking@1.10.3:
find-up "^5.0.0"
fs-extra "^9.1.0"
expo-modules-core@1.11.13:
version "1.11.13"
resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.11.13.tgz#a8e63ad844e966dce78dea40b50839af6c3bc518"
integrity sha512-2H5qrGUvmLzmJNPDOnovH1Pfk5H/S/V0BifBmOQyDc9aUh9LaDwkqnChZGIXv8ZHDW8JRlUW0QqyWxTggkbw1A==
expo-modules-core@1.11.12:
version "1.11.12"
resolved "https://registry.yarnpkg.com/expo-modules-core/-/expo-modules-core-1.11.12.tgz#d5c7b3ed7ab57d4fb6885a0d8e10287dcf1ffe5f"
integrity sha512-/e8g4kis0pFLer7C0PLyx98AfmztIM6gU9jLkYnB1pU9JAfQf904XEi3bmszO7uoteBQwSL6FLp1m3TePKhDaA==
dependencies:
invariant "^2.2.4"
@@ -12133,24 +12090,24 @@ expo-web-browser@~12.8.2:
compare-urls "^2.0.0"
url "^0.11.0"
expo@^50.0.17:
version "50.0.17"
resolved "https://registry.yarnpkg.com/expo/-/expo-50.0.17.tgz#ab0998d7e7c18e8d12efd9091f9688978b0e89ed"
integrity sha512-eD8Nh10BgVwecU7EVyogx7X314ajxVpJdFwkXhi341AD61S2WPX31NMHW82XGXas6dbDjdbgtaOMo5H/vylB7Q==
expo@^50.0.8:
version "50.0.14"
resolved "https://registry.yarnpkg.com/expo/-/expo-50.0.14.tgz#ddcae86aa0ba8d1be3da9ad1bdda23fa539dc97d"
integrity sha512-yLPdxCMVAbmeEIpzzyAuJ79wvr6ToDDtQmuLDMAgWtjqP8x3CGddXxUe07PpKEQgzwJabdHvCLP5Bv94wMFIjQ==
dependencies:
"@babel/runtime" "^7.20.0"
"@expo/cli" "0.17.10"
"@expo/config" "8.5.6"
"@expo/config-plugins" "7.9.1"
"@expo/metro-config" "0.17.7"
"@expo/cli" "0.17.8"
"@expo/config" "8.5.4"
"@expo/config-plugins" "7.8.4"
"@expo/metro-config" "0.17.6"
"@expo/vector-icons" "^14.0.0"
babel-preset-expo "~10.0.2"
babel-preset-expo "~10.0.1"
expo-asset "~9.0.2"
expo-file-system "~16.0.9"
expo-file-system "~16.0.8"
expo-font "~11.10.3"
expo-keep-awake "~12.8.2"
expo-modules-autolinking "1.10.3"
expo-modules-core "1.11.13"
expo-modules-core "1.11.12"
fbemitter "^3.0.0"
whatwg-url-without-unicode "8.0.0-3"