Merge remote-tracking branch 'upstream/main' into Improve-notification-localization

This commit is contained in:
Minseo Lee
2024-09-03 17:54:27 +09:00
84 changed files with 2441 additions and 1034 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
"eslint-plugin-simple-import-sort": "^12.0.0", "eslint-plugin-simple-import-sort": "^12.0.0",
"postcss": "^8.4.38", "postcss": "^8.4.38",
"tailwindcss": "^3.4.3", "tailwindcss": "^3.4.3",
"typescript": "^4.0.5", "typescript": "^5.5.4",
"vite": "^5.2.8", "vite": "^5.2.8",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }
+4 -4
View File
@@ -11,7 +11,7 @@ import likeIcon from '../../assets/heart2_filled_stroke2_corner0_rounded.svg'
import logo from '../../assets/logo.svg' import logo from '../../assets/logo.svg'
import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg' import repostIcon from '../../assets/repost_stroke2_corner2_rounded.svg'
import {CONTENT_LABELS} from '../labels' import {CONTENT_LABELS} from '../labels'
import {getRkey, niceDate} from '../utils' import {getRkey, niceDate, prettyNumber} from '../utils'
import {Container} from './container' import {Container} from './container'
import {Embed} from './embed' import {Embed} from './embed'
import {Link} from './link' import {Link} from './link'
@@ -78,7 +78,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
<img src={likeIcon} className="w-5 h-5" /> <img src={likeIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px"> <p className="font-bold text-neutral-500 mb-px">
{post.likeCount} {prettyNumber(post.likeCount)}
</p> </p>
</div> </div>
)} )}
@@ -86,7 +86,7 @@ export function Post({thread}: Props) {
<div className="flex items-center gap-2 cursor-pointer"> <div className="flex items-center gap-2 cursor-pointer">
<img src={repostIcon} className="w-5 h-5" /> <img src={repostIcon} className="w-5 h-5" />
<p className="font-bold text-neutral-500 mb-px"> <p className="font-bold text-neutral-500 mb-px">
{post.repostCount} {prettyNumber(post.repostCount)}
</p> </p>
</div> </div>
)} )}
@@ -97,7 +97,7 @@ export function Post({thread}: Props) {
<div className="flex-1" /> <div className="flex-1" />
<p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline"> <p className="cursor-pointer text-brand font-bold hover:underline hidden min-[450px]:inline">
{post.replyCount {post.replyCount
? `Read ${post.replyCount} ${ ? `Read ${prettyNumber(post.replyCount)} ${
post.replyCount > 1 ? 'replies' : 'reply' post.replyCount > 1 ? 'replies' : 'reply'
} on Bluesky` } on Bluesky`
: `View on Bluesky`} : `View on Bluesky`}
+10
View File
@@ -16,3 +16,13 @@ export function getRkey({uri}: {uri: string}): string {
const at = new AtUri(uri) const at = new AtUri(uri)
return at.rkey return at.rkey
} }
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
roundingMode: 'trunc',
})
export function prettyNumber(number: number) {
return formatter.format(number)
}
+1 -1
View File
@@ -20,5 +20,5 @@
"jsxFragmentFactory": "Fragment", "jsxFragmentFactory": "Fragment",
"downlevelIteration": true "downlevelIteration": true
}, },
"include": ["src"] "include": ["src", "vite.config.ts"]
} }
+1 -1
View File
@@ -6,5 +6,5 @@
"strict": true, "strict": true,
"outDir": "dist" "outDir": "dist"
}, },
"include": ["snippet"], "include": ["snippet"]
} }
+4 -4
View File
@@ -4024,10 +4024,10 @@ typed-array-length@^1.0.6:
is-typed-array "^1.1.13" is-typed-array "^1.1.13"
possible-typed-array-names "^1.0.0" possible-typed-array-names "^1.0.0"
typescript@^4.0.5: typescript@^5.5.4:
version "4.9.5" version "5.5.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==
uint8arrays@3.0.0: uint8arrays@3.0.0:
version "3.0.0" version "3.0.0"
+10 -11
View File
@@ -2,7 +2,6 @@ const path = require('path')
const fs = require('fs') const fs = require('fs')
const projectRoot = path.join(__dirname, '..') const projectRoot = path.join(__dirname, '..')
const webBuildJs = path.join(projectRoot, 'web-build', 'static', 'js')
const templateFile = path.join( const templateFile = path.join(
projectRoot, projectRoot,
'bskyweb', 'bskyweb',
@@ -10,18 +9,18 @@ const templateFile = path.join(
'scripts.html', 'scripts.html',
) )
const jsFiles = fs.readdirSync(webBuildJs).filter(name => name.endsWith('.js')) const {entrypoints} = require(path.join(
jsFiles.sort((a, b) => { projectRoot,
// make sure main is written last 'web-build/asset-manifest.json',
if (a.startsWith('main')) return 1 ))
if (b.startsWith('main')) return -1
return a.localeCompare(b)
})
console.log(`Found ${jsFiles.length} js files in web-build`) console.log(`Found ${entrypoints.length} entrypoints`)
console.log(`Writing ${templateFile}`) console.log(`Writing ${templateFile}`)
const outputFile = jsFiles const outputFile = entrypoints
.map(name => `<script defer="defer" src="/static/js/${name}"></script>`) .map(name => {
const file = path.basename(name)
return `<script defer="defer" src="/static/js/${file}"></script>`
})
.join('\n') .join('\n')
fs.writeFileSync(templateFile, outputFile) fs.writeFileSync(templateFile, outputFile)
+14 -14
View File
@@ -175,25 +175,25 @@ function App() {
<A11yProvider> <A11yProvider>
<KeyboardProvider enabled={false} statusBarTranslucent={true}> <KeyboardProvider enabled={false} statusBarTranslucent={true}>
<SessionProvider> <SessionProvider>
<ShellStateProvider> <PrefsStateProvider>
<PrefsStateProvider> <I18nProvider>
<InvitesStateProvider> <ShellStateProvider>
<ModalStateProvider> <InvitesStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<I18nProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
<StarterPackProvider> <StarterPackProvider>
<InnerApp /> <InnerApp />
</StarterPackProvider> </StarterPackProvider>
</PortalProvider> </PortalProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</ShellStateProvider> </PrefsStateProvider>
</SessionProvider> </SessionProvider>
</KeyboardProvider> </KeyboardProvider>
</A11yProvider> </A11yProvider>
+14 -14
View File
@@ -153,25 +153,25 @@ function App() {
return ( return (
<A11yProvider> <A11yProvider>
<SessionProvider> <SessionProvider>
<ShellStateProvider> <PrefsStateProvider>
<PrefsStateProvider> <I18nProvider>
<InvitesStateProvider> <ShellStateProvider>
<ModalStateProvider> <InvitesStateProvider>
<DialogStateProvider> <ModalStateProvider>
<LightboxStateProvider> <DialogStateProvider>
<I18nProvider> <LightboxStateProvider>
<PortalProvider> <PortalProvider>
<StarterPackProvider> <StarterPackProvider>
<InnerApp /> <InnerApp />
</StarterPackProvider> </StarterPackProvider>
</PortalProvider> </PortalProvider>
</I18nProvider> </LightboxStateProvider>
</LightboxStateProvider> </DialogStateProvider>
</DialogStateProvider> </ModalStateProvider>
</ModalStateProvider> </InvitesStateProvider>
</InvitesStateProvider> </ShellStateProvider>
</PrefsStateProvider> </I18nProvider>
</ShellStateProvider> </PrefsStateProvider>
</SessionProvider> </SessionProvider>
</A11yProvider> </A11yProvider>
) )
+1
View File
@@ -490,6 +490,7 @@ function MyProfileTabNavigator() {
getComponent={() => ProfileScreen} getComponent={() => ProfileScreen}
initialParams={{ initialParams={{
name: 'me', name: 'me',
hideBackButton: true,
}} }}
/> />
{commonScreens(MyProfileTab as typeof HomeTab)} {commonScreens(MyProfileTab as typeof HomeTab)}
+9
View File
@@ -853,6 +853,7 @@ export const atoms = {
mr_auto: { mr_auto: {
marginRight: 'auto', marginRight: 'auto',
}, },
/* /*
* Pointer events & user select * Pointer events & user select
*/ */
@@ -871,6 +872,7 @@ export const atoms = {
user_select_all: { user_select_all: {
userSelect: 'all', userSelect: 'all',
}, },
/* /*
* Text decoration * Text decoration
*/ */
@@ -880,4 +882,11 @@ export const atoms = {
strike_through: { strike_through: {
textDecorationLine: 'line-through', textDecorationLine: 'line-through',
}, },
/*
* Display
*/
hidden: {
display: 'none',
},
} as const } as const
+10 -53
View File
@@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {Dimensions} from 'react-native' import {useMediaQuery} from 'react-responsive'
import {createThemes, defaultTheme} from '#/alf/themes' import {createThemes, defaultTheme} from '#/alf/themes'
import {Theme, ThemeName} from '#/alf/types' import {Theme, ThemeName} from '#/alf/types'
@@ -12,52 +12,15 @@ export * from '#/alf/util/flatten'
export * from '#/alf/util/platform' export * from '#/alf/util/platform'
export * from '#/alf/util/themeSelector' export * from '#/alf/util/themeSelector'
type BreakpointName = keyof typeof breakpoints
/*
* Breakpoints
*/
const breakpoints: {
[key: string]: number
} = {
gtPhone: 500,
gtMobile: 800,
gtTablet: 1300,
}
function getActiveBreakpoints({width}: {width: number}) {
const active: (keyof typeof breakpoints)[] = Object.keys(breakpoints).filter(
breakpoint => width >= breakpoints[breakpoint],
)
return {
active: active[active.length - 1],
gtPhone: active.includes('gtPhone'),
gtMobile: active.includes('gtMobile'),
gtTablet: active.includes('gtTablet'),
}
}
/* /*
* Context * Context
*/ */
export const Context = React.createContext<{ export const Context = React.createContext<{
themeName: ThemeName themeName: ThemeName
theme: Theme theme: Theme
breakpoints: {
active: BreakpointName | undefined
gtPhone: boolean
gtMobile: boolean
gtTablet: boolean
}
}>({ }>({
themeName: 'light', themeName: 'light',
theme: defaultTheme, theme: defaultTheme,
breakpoints: {
active: undefined,
gtPhone: false,
gtMobile: false,
gtTablet: false,
},
}) })
export function ThemeProvider({ export function ThemeProvider({
@@ -74,18 +37,6 @@ export function ThemeProvider({
}) })
}, []) }, [])
const theme = themes[themeName] const theme = themes[themeName]
const [breakpoints, setBreakpoints] = React.useState(() =>
getActiveBreakpoints({width: Dimensions.get('window').width}),
)
React.useEffect(() => {
const listener = Dimensions.addEventListener('change', ({window}) => {
const bp = getActiveBreakpoints({width: window.width})
if (bp.active !== breakpoints.active) setBreakpoints(bp)
})
return listener.remove
}, [breakpoints, setBreakpoints])
return ( return (
<Context.Provider <Context.Provider
@@ -93,9 +44,8 @@ export function ThemeProvider({
() => ({ () => ({
themeName: themeName, themeName: themeName,
theme: theme, theme: theme,
breakpoints,
}), }),
[theme, themeName, breakpoints], [theme, themeName],
)}> )}>
{children} {children}
</Context.Provider> </Context.Provider>
@@ -107,5 +57,12 @@ export function useTheme() {
} }
export function useBreakpoints() { export function useBreakpoints() {
return React.useContext(Context).breakpoints const gtPhone = useMediaQuery({minWidth: 500})
const gtMobile = useMediaQuery({minWidth: 800})
const gtTablet = useMediaQuery({minWidth: 1300})
return {
gtPhone,
gtMobile,
gtTablet,
}
} }
+56 -4
View File
@@ -1,18 +1,21 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ScrollView} from 'react-native-gesture-handler' import {ScrollView} from 'react-native-gesture-handler'
import {AppBskyFeedDefs, AtUri} from '@atproto/api' import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {NavigationProp} from '#/lib/routes/types' import {NavigationProp} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useGetPopularFeedsQuery} from '#/state/queries/feed' import {useGetPopularFeedsQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {useProfilesQuery} from '#/state/queries/profile' import {useProfilesQuery} from '#/state/queries/profile'
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useProgressGuide} from '#/state/shell/progress-guide' import {useProgressGuide} from '#/state/shell/progress-guide'
import * as userActionHistory from '#/state/userActionHistory' import * as userActionHistory from '#/state/userActionHistory'
@@ -173,14 +176,63 @@ function useExperimentalSuggestedUsersQuery() {
} }
} }
export function SuggestedFollows() { export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const t = useTheme() const gate = useGate()
const {_} = useLingui() const [feedType, feedUri] = feed.split('|')
if (feedType === 'author') {
if (gate('show_follow_suggestions_in_profile')) {
return <SuggestedFollowsProfile did={feedUri} />
} else {
return null
}
} else {
return <SuggestedFollowsHome />
}
}
export function SuggestedFollowsProfile({did}: {did: string}) {
const {
isLoading: isSuggestionsLoading,
data,
error,
} = useSuggestedFollowsByActorQuery({
did,
})
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []}
error={error}
/>
)
}
export function SuggestedFollowsHome() {
const { const {
isLoading: isSuggestionsLoading, isLoading: isSuggestionsLoading,
profiles, profiles,
error, error,
} = useExperimentalSuggestedUsersQuery() } = useExperimentalSuggestedUsersQuery()
return (
<ProfileGrid
isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles}
error={error}
/>
)
}
export function ProfileGrid({
isSuggestionsLoading,
error,
profiles,
}: {
isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null
}) {
const t = useTheme()
const {_} = useLingui()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
@@ -377,7 +377,7 @@ function Inner({
hide: () => void hide: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const moderation = React.useMemo( const moderation = React.useMemo(
() => moderateProfile(profile, moderationOpts), () => moderateProfile(profile, moderationOpts),
@@ -393,8 +393,8 @@ function Inner({
profile.viewer?.blocking || profile.viewer?.blocking ||
profile.viewer?.blockedBy || profile.viewer?.blockedBy ||
profile.viewer?.blockingByList profile.viewer?.blockingByList
const following = formatCount(profile.followsCount || 0) const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0) const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, { const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower', one: 'follower',
other: 'followers', other: 'followers',
+18 -14
View File
@@ -59,20 +59,24 @@ export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
<QrCodeInner link={link} /> <QrCodeInner link={link} />
</View> </View>
<View style={[a.flex_row, a.align_center, {gap: 5}]}> <Text
<Text style={[
style={[ a.flex,
a.font_bold, a.flex_row,
a.text_center, a.align_center,
{color: 'white', fontSize: 18}, a.font_bold,
]}> {color: 'white', fontSize: 18, gap: 6},
<Trans>on</Trans> ]}>
</Text> <Trans>
<Logo width={26} fill="white" /> on
<View style={[{marginTop: 5, marginLeft: 2.5}]}> <View style={[a.flex_row, a.align_center, {gap: 6}]}>
<Logotype width={68} fill="white" /> <Logo width={25} fill="white" />
</View> <View style={[{marginTop: 3.5}]}>
</View> <Logotype width={72} fill="white" />
</View>
</View>
</Trans>
</Text>
</View> </View>
</LinearGradientBackground> </LinearGradientBackground>
</ViewShot> </ViewShot>
+3 -3
View File
@@ -43,7 +43,7 @@ function EmbedDialogInner({
timestamp, timestamp,
}: Omit<EmbedDialogProps, 'control'>) { }: Omit<EmbedDialogProps, 'control'>) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const ref = useRef<TextInput>(null) const ref = useRef<TextInput>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@@ -86,9 +86,9 @@ function EmbedDialogInner({
)} (<a href="${escapeHtml(profileHref)}">@${escapeHtml( )} (<a href="${escapeHtml(profileHref)}">@${escapeHtml(
postAuthor.handle, postAuthor.handle,
)}</a>) <a href="${escapeHtml(href)}">${escapeHtml( )}</a>) <a href="${escapeHtml(href)}">${escapeHtml(
niceDate(timestamp), niceDate(i18n, timestamp),
)}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>` )}</a></blockquote><script async src="${EMBED_SCRIPT}" charset="utf-8"></script>`
}, [postUri, postCid, record, timestamp, postAuthor]) }, [i18n, postUri, postCid, record, timestamp, postAuthor])
return ( return (
<Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}> <Dialog.Inner label="Embed post" style={[a.gap_md, {maxWidth: 500}]}>
+6 -5
View File
@@ -11,6 +11,7 @@ import {
ChatBskyConvoDefs, ChatBskyConvoDefs,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {I18n} from '@lingui/core'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@@ -153,14 +154,14 @@ let MessageItemMetadata = ({
) )
const relativeTimestamp = useCallback( const relativeTimestamp = useCallback(
(timestamp: string) => { (i18n: I18n, timestamp: string) => {
const date = new Date(timestamp) const date = new Date(timestamp)
const now = new Date() const now = new Date()
const time = new Intl.DateTimeFormat(undefined, { const time = i18n.date(date, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}).format(date) })
const diff = now.getTime() - date.getTime() const diff = now.getTime() - date.getTime()
@@ -182,13 +183,13 @@ let MessageItemMetadata = ({
return _(msg`Yesterday, ${time}`) return _(msg`Yesterday, ${time}`)
} }
return new Intl.DateTimeFormat(undefined, { return i18n.date(date, {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
day: 'numeric', day: 'numeric',
month: 'numeric', month: 'numeric',
year: 'numeric', year: 'numeric',
}).format(date) })
}, },
[_], [_],
) )
@@ -1,12 +1,12 @@
import React from 'react' import React from 'react'
import {Pressable, View} from 'react-native' import {Pressable, View} from 'react-native'
import {useLingui} from '@lingui/react'
import {android, atoms as a, useTheme, web} from '#/alf' import {android, atoms as a, useTheme, web} from '#/alf'
import * as TextField from '#/components/forms/TextField' import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {localizeDate} from './utils'
// looks like a TextField.Input, but is just a button. It'll do something different on each platform on press // looks like a TextField.Input, but is just a button. It'll do something different on each platform on press
// iOS: open a dialog with an inline date picker // iOS: open a dialog with an inline date picker
@@ -25,6 +25,7 @@ export function DateFieldButton({
isInvalid?: boolean isInvalid?: boolean
accessibilityHint?: string accessibilityHint?: string
}) { }) {
const {i18n} = useLingui()
const t = useTheme() const t = useTheme()
const { const {
@@ -91,7 +92,7 @@ export function DateFieldButton({
t.atoms.text, t.atoms.text,
{lineHeight: a.text_md.fontSize * 1.1875}, {lineHeight: a.text_md.fontSize * 1.1875},
]}> ]}>
{localizeDate(value)} {i18n.date(value, {timeZone: 'UTC'})}
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
-11
View File
@@ -1,16 +1,5 @@
import {getLocales} from 'expo-localization'
const LOCALE = getLocales()[0]
// we need the date in the form yyyy-MM-dd to pass to the input // we need the date in the form yyyy-MM-dd to pass to the input
export function toSimpleDateString(date: Date | string): string { export function toSimpleDateString(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date const _date = typeof date === 'string' ? new Date(date) : date
return _date.toISOString().split('T')[0] return _date.toISOString().split('T')[0]
} }
export function localizeDate(date: Date | string): string {
const _date = typeof date === 'string' ? new Date(date) : date
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: 'UTC',
}).format(_date)
}
+30 -7
View File
@@ -81,7 +81,15 @@ export class FeedViewPostsSlice {
isParentBlocked, isParentBlocked,
isParentNotFound, isParentNotFound,
}) })
if (!reply || reason) { if (!reply) {
if (post.record.reply) {
// This reply wasn't properly hydrated by the AppView.
this.isOrphan = true
this.items[0].isParentNotFound = true
}
return
}
if (reason) {
return return
} }
if ( if (
@@ -366,11 +374,7 @@ export class FeedTuner {
): FeedViewPostsSlice[] => { ): FeedViewPostsSlice[] => {
for (let i = 0; i < slices.length; i++) { for (let i = 0; i < slices.length; i++) {
const slice = slices[i] const slice = slices[i]
if ( if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1) slices.splice(i, 1)
i-- i--
} }
@@ -434,9 +438,13 @@ function areSameAuthor(authors: AuthorContext): boolean {
} }
function shouldDisplayReplyInFollowing( function shouldDisplayReplyInFollowing(
authors: AuthorContext, slice: FeedViewPostsSlice,
userDid: string, userDid: string,
): boolean { ): boolean {
if (slice.isRepost) {
return true
}
const authors = slice.getAuthors()
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
if (!isSelfOrFollowing(author, userDid)) { if (!isSelfOrFollowing(author, userDid)) {
// Only show replies from self or people you follow. // Only show replies from self or people you follow.
@@ -450,6 +458,21 @@ function shouldDisplayReplyInFollowing(
// Always show self-threads. // Always show self-threads.
return true return true
} }
if (
parentAuthor &&
parentAuthor.did !== author.did &&
rootAuthor &&
rootAuthor.did === author.did &&
slice.items.length > 2
) {
// If you follow A, show A -> someone[>0 likes] -> A chains too.
// This is different from cases below because you only know one person.
const parentPost = slice.items[1].post
const parentLikeCount = parentPost.likeCount ?? 0
if (parentLikeCount > 0) {
return true
}
}
// From this point on we need at least one more reason to show it. // From this point on we need at least one more reason to show it.
if ( if (
parentAuthor && parentAuthor &&
+25 -3
View File
@@ -1,4 +1,5 @@
import { import {
AppBskyEmbedDefs,
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
@@ -45,7 +46,12 @@ interface PostOpts {
uri: string uri: string
cid: string cid: string
} }
video?: BlobRef video?: {
blobRef: BlobRef
altText: string
captions: {lang: string; file: File}[]
aspectRatio?: AppBskyEmbedDefs.AspectRatio
}
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: ImageModel[] images?: ImageModel[]
labels?: string[] labels?: string[]
@@ -128,19 +134,35 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
// add video embed if present // add video embed if present
if (opts.video) { if (opts.video) {
const captions = await Promise.all(
opts.video.captions
.filter(caption => caption.lang !== '')
.map(async caption => {
const {data} = await agent.uploadBlob(caption.file, {
encoding: 'text/vtt',
})
return {lang: caption.lang, file: data.blob}
}),
)
if (opts.quote) { if (opts.quote) {
embed = { embed = {
$type: 'app.bsky.embed.recordWithMedia', $type: 'app.bsky.embed.recordWithMedia',
record: embed, record: embed,
media: { media: {
$type: 'app.bsky.embed.video', $type: 'app.bsky.embed.video',
video: opts.video, video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main, } as AppBskyEmbedVideo.Main,
} as AppBskyEmbedRecordWithMedia.Main } as AppBskyEmbedRecordWithMedia.Main
} else { } else {
embed = { embed = {
$type: 'app.bsky.embed.video', $type: 'app.bsky.embed.video',
video: opts.video, video: opts.video.blobRef,
alt: opts.video.altText || undefined,
captions: captions.length === 0 ? undefined : captions,
aspectRatio: opts.video.aspectRatio,
} as AppBskyEmbedVideo.Main } as AppBskyEmbedVideo.Main
} }
} }
+177
View File
@@ -0,0 +1,177 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Easing,
LayoutAnimationConfig,
useReducedMotion,
withTiming,
} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: Easing.out(Easing.cubic),
}
function EnteringUp() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: 18}],
}
return {
animations,
initialValues,
}
}
function EnteringDown() {
'worklet'
const animations = {
opacity: withTiming(1, animationConfig),
transform: [{translateY: withTiming(0, animationConfig)}],
}
const initialValues = {
opacity: 0,
transform: [{translateY: -18}],
}
return {
animations,
initialValues,
}
}
function ExitingUp() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [
{
translateY: withTiming(-18, animationConfig),
},
],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
function ExitingDown() {
'worklet'
const animations = {
opacity: withTiming(0, animationConfig),
transform: [{translateY: withTiming(18, animationConfig)}],
}
const initialValues = {
opacity: 1,
transform: [{translateY: 0}],
}
return {
animations,
initialValues,
}
}
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
// Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
// animation
// The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
// be unnecessary
const [key, setKey] = React.useState(0)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
setKey(prev => prev + 1)
setPrevCount(newPrevCount)
prevIsLiked.current = isLiked
}, [isLiked, likeCount])
const enteringAnimation =
shouldAnimate && shouldRoll
? isLiked
? EnteringUp
: EnteringDown
: undefined
const exitingAnimation =
shouldAnimate && shouldRoll
? isLiked
? ExitingUp
: ExitingDown
: undefined
return (
<LayoutAnimationConfig skipEntering skipExiting>
{likeCount > 0 ? (
<View style={[a.justify_center]}>
<Animated.View entering={enteringAnimation} key={key}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</Animated.View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<Animated.View
entering={exitingAnimation}
// Add 2 to the key so there are never duplicates
key={key + 2}
style={[a.absolute, {width: 50, opacity: 0}]}
aria-disabled={true}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</Animated.View>
) : null}
</View>
) : null}
</LayoutAnimationConfig>
)
}
@@ -0,0 +1,120 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {i18n} from '@lingui/core'
import {decideShouldRoll} from 'lib/custom-animations/util'
import {s} from 'lib/styles'
import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text'
import {atoms as a, useTheme} from '#/alf'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const enteringUpKeyframe = [
{opacity: 0, transform: 'translateY(18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const enteringDownKeyframe = [
{opacity: 0, transform: 'translateY(-18px)'},
{opacity: 1, transform: 'translateY(0)'},
]
const exitingUpKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(-18px)'},
]
const exitingDownKeyframe = [
{opacity: 1, transform: 'translateY(0)'},
{opacity: 0, transform: 'translateY(18px)'},
]
export function CountWheel({
likeCount,
big,
isLiked,
}: {
likeCount: number
big?: boolean
isLiked: boolean
}) {
const t = useTheme()
const shouldAnimate = !useReducedMotion()
const shouldRoll = decideShouldRoll(isLiked, likeCount)
const countView = React.useRef<HTMLDivElement>(null)
const prevCountView = React.useRef<HTMLDivElement>(null)
const [prevCount, setPrevCount] = React.useState(likeCount)
const prevIsLiked = React.useRef(isLiked)
const formattedCount = formatCount(i18n, likeCount)
const formattedPrevCount = formatCount(i18n, prevCount)
React.useEffect(() => {
if (isLiked === prevIsLiked.current) {
return
}
const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
if (shouldAnimate && shouldRoll) {
countView.current?.animate?.(
isLiked ? enteringUpKeyframe : enteringDownKeyframe,
animationConfig,
)
prevCountView.current?.animate?.(
isLiked ? exitingUpKeyframe : exitingDownKeyframe,
animationConfig,
)
setPrevCount(newPrevCount)
}
prevIsLiked.current = isLiked
}, [isLiked, likeCount, shouldAnimate, shouldRoll])
if (likeCount < 1) {
return null
}
return (
<View>
<View
// @ts-expect-error is div
ref={countView}>
<Text
testID="likeCount"
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedCount}
</Text>
</View>
{shouldAnimate && (likeCount > 1 || !isLiked) ? (
<View
style={{position: 'absolute', opacity: 0}}
aria-disabled={true}
// @ts-expect-error is div
ref={prevCountView}>
<Text
style={[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
isLiked
? [a.font_bold, s.likeColor]
: {color: t.palette.contrast_500},
]}>
{formattedPrevCount}
</Text>
</View>
) : null}
</View>
)
}
+135
View File
@@ -0,0 +1,135 @@
import React from 'react'
import {View} from 'react-native'
import Animated, {
Keyframe,
LayoutAnimationConfig,
useReducedMotion,
} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const keyframe = new Keyframe({
0: {
transform: [{scale: 1}],
},
10: {
transform: [{scale: 0.7}],
},
40: {
transform: [{scale: 1.2}],
},
100: {
transform: [{scale: 1}],
},
})
const circle1Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 0.4,
},
40: {
transform: [{scale: 1.5}],
},
95: {
opacity: 0.4,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
const circle2Keyframe = new Keyframe({
0: {
opacity: 0,
transform: [{scale: 0}],
},
10: {
opacity: 1,
},
40: {
transform: [{scale: 0}],
},
95: {
opacity: 1,
},
100: {
opacity: 0,
transform: [{scale: 1.5}],
},
})
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
return (
<View>
<LayoutAnimationConfig skipEntering>
{isLiked ? (
<Animated.View
entering={shouldAnimate ? keyframe.duration(300) : undefined}>
<HeartIconFilled style={s.likeColor} width={size} />
</Animated.View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
{isLiked ? (
<>
<Animated.View
entering={
shouldAnimate ? circle1Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
<Animated.View
entering={
shouldAnimate ? circle2Keyframe.duration(300) : undefined
}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
}}
/>
</>
) : null}
</LayoutAnimationConfig>
</View>
)
}
+117
View File
@@ -0,0 +1,117 @@
import React from 'react'
import {View} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated'
import {s} from 'lib/styles'
import {useTheme} from '#/alf'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
const animationConfig = {
duration: 400,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
fill: 'forwards' as FillMode,
}
const keyframe = [
{transform: 'scale(1)'},
{transform: 'scale(0.7)'},
{transform: 'scale(1.2)'},
{transform: 'scale(1)'},
]
const circle1Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 0.4},
{transform: 'scale(1.5)'},
{opacity: 0.4},
{opacity: 0, transform: 'scale(1.5)'},
]
const circle2Keyframe = [
{opacity: 0, transform: 'scale(0)'},
{opacity: 1},
{transform: 'scale(0)'},
{opacity: 1},
{opacity: 0, transform: 'scale(1.5)'},
]
export function AnimatedLikeIcon({
isLiked,
big,
}: {
isLiked: boolean
big?: boolean
}) {
const t = useTheme()
const size = big ? 22 : 18
const shouldAnimate = !useReducedMotion()
const prevIsLiked = React.useRef(isLiked)
const likeIconRef = React.useRef<HTMLDivElement>(null)
const circle1Ref = React.useRef<HTMLDivElement>(null)
const circle2Ref = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (prevIsLiked.current === isLiked) {
return
}
if (shouldAnimate && isLiked) {
likeIconRef.current?.animate?.(keyframe, animationConfig)
circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
}
prevIsLiked.current = isLiked
}, [shouldAnimate, isLiked])
return (
<View>
{isLiked ? (
// @ts-expect-error is div
<View ref={likeIconRef}>
<HeartIconFilled style={s.likeColor} width={size} />
</View>
) : (
<HeartIconOutline
style={[{color: t.palette.contrast_500}, {pointerEvents: 'none'}]}
width={size}
/>
)}
<View
// @ts-expect-error is div
ref={circle1Ref}
style={{
position: 'absolute',
backgroundColor: s.likeColor.color,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
<View
// @ts-expect-error is div
ref={circle2Ref}
style={{
position: 'absolute',
backgroundColor: t.atoms.bg.backgroundColor,
top: 0,
left: 0,
width: size,
height: size,
zIndex: -1,
pointerEvents: 'none',
borderRadius: size / 2,
opacity: 0,
}}
/>
</View>
)
}
+21
View File
@@ -0,0 +1,21 @@
// It should roll when:
// - We're going from 1 to 0 (roll backwards)
// - The count is anywhere between 1 and 999
// - The count is going up and is a multiple of 100
// - The count is going down and is 1 less than a multiple of 100
export function decideShouldRoll(isSet: boolean, count: number) {
let shouldRoll = false
if (!isSet && count === 0) {
shouldRoll = true
} else if (count > 0 && count < 1000) {
shouldRoll = true
} else if (count > 0) {
const mod = count % 100
if (isSet && mod === 0) {
shouldRoll = true
} else if (!isSet && mod === 99) {
shouldRoll = true
}
}
return shouldRoll
}
+1 -2
View File
@@ -65,7 +65,6 @@ export function useGenerateStarterPackMutation({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const agent = useAgent() const agent = useAgent()
const starterPackString = _(msg`Starter Pack`)
return useMutation<{uri: string; cid: string}, Error, void>({ return useMutation<{uri: string; cid: string}, Error, void>({
mutationFn: async () => { mutationFn: async () => {
@@ -106,7 +105,7 @@ export function useGenerateStarterPackMutation({
25, 25,
true, true,
) )
const starterPackName = `${displayName}'s ${starterPackString}` const starterPackName = _(msg`${displayName}'s Starter Pack`)
const list = await createStarterPackList({ const list = await createStarterPackList({
name: starterPackName, name: starterPackName,
+136 -25
View File
@@ -1,102 +1,213 @@
import {describe, expect, it} from '@jest/globals' import {describe, expect, it} from '@jest/globals'
import {MessageDescriptor} from '@lingui/core'
import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns' import {addDays, subDays, subHours, subMinutes, subSeconds} from 'date-fns'
import {dateDiff} from '../useTimeAgo' import {dateDiff} from '../useTimeAgo'
const lingui: any = (obj: MessageDescriptor) => obj.message
const base = new Date('2024-06-17T00:00:00Z') const base = new Date('2024-06-17T00:00:00Z')
describe('dateDiff', () => { describe('dateDiff', () => {
it(`works with numbers`, () => { it(`works with numbers`, () => {
expect(dateDiff(subDays(base, 3), Number(base), {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, Number(base))).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`works with strings`, () => { it(`works with strings`, () => {
expect(dateDiff(subDays(base, 3), base.toString(), {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, base.toString())).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`works with dates`, () => { it(`works with dates`, () => {
expect(dateDiff(subDays(base, 3), base, {lingui})).toEqual('3d') const earlier = subDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 3,
unit: 'day',
earlier,
later: base,
})
}) })
it(`equal values return now`, () => { it(`equal values return now`, () => {
expect(dateDiff(base, base, {lingui})).toEqual('now') expect(dateDiff(base, base)).toEqual({
value: 0,
unit: 'now',
earlier: base,
later: base,
})
}) })
it(`future dates return now`, () => { it(`future dates return now`, () => {
expect(dateDiff(addDays(base, 3), base, {lingui})).toEqual('now') const earlier = addDays(base, 3)
expect(dateDiff(earlier, base)).toEqual({
value: 0,
unit: 'now',
earlier,
later: base,
})
}) })
it(`values < 5 seconds ago return now`, () => { it(`values < 5 seconds ago return now`, () => {
const then = subSeconds(base, 4) const then = subSeconds(base, 4)
expect(dateDiff(then, base, {lingui})).toEqual('now') expect(dateDiff(then, base)).toEqual({
value: 0,
unit: 'now',
earlier: then,
later: base,
})
}) })
it(`values >= 5 seconds ago return seconds`, () => { it(`values >= 5 seconds ago return seconds`, () => {
const then = subSeconds(base, 5) const then = subSeconds(base, 5)
expect(dateDiff(then, base, {lingui})).toEqual('5s') expect(dateDiff(then, base)).toEqual({
value: 5,
unit: 'second',
earlier: then,
later: base,
})
}) })
it(`values < 1 min return seconds`, () => { it(`values < 1 min return seconds`, () => {
const then = subSeconds(base, 59) const then = subSeconds(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59s') expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'second',
earlier: then,
later: base,
})
}) })
it(`values >= 1 min return minutes`, () => { it(`values >= 1 min return minutes`, () => {
const then = subSeconds(base, 60) const then = subSeconds(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1m') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`minutes round down`, () => { it(`minutes round down`, () => {
const then = subSeconds(base, 119) const then = subSeconds(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1m') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`values < 1 hour return minutes`, () => { it(`values < 1 hour return minutes`, () => {
const then = subMinutes(base, 59) const then = subMinutes(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('59m') expect(dateDiff(then, base)).toEqual({
value: 59,
unit: 'minute',
earlier: then,
later: base,
})
}) })
it(`values >= 1 hour return hours`, () => { it(`values >= 1 hour return hours`, () => {
const then = subMinutes(base, 60) const then = subMinutes(base, 60)
expect(dateDiff(then, base, {lingui})).toEqual('1h') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`hours round down`, () => { it(`hours round down`, () => {
const then = subMinutes(base, 119) const then = subMinutes(base, 119)
expect(dateDiff(then, base, {lingui})).toEqual('1h') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`values < 1 day return hours`, () => { it(`values < 1 day return hours`, () => {
const then = subHours(base, 23) const then = subHours(base, 23)
expect(dateDiff(then, base, {lingui})).toEqual('23h') expect(dateDiff(then, base)).toEqual({
value: 23,
unit: 'hour',
earlier: then,
later: base,
})
}) })
it(`values >= 1 day return days`, () => { it(`values >= 1 day return days`, () => {
const then = subHours(base, 24) const then = subHours(base, 24)
expect(dateDiff(then, base, {lingui})).toEqual('1d') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`days round down`, () => { it(`days round down`, () => {
const then = subHours(base, 47) const then = subHours(base, 47)
expect(dateDiff(then, base, {lingui})).toEqual('1d') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`values < 30 days return days`, () => { it(`values < 30 days return days`, () => {
const then = subDays(base, 29) const then = subDays(base, 29)
expect(dateDiff(then, base, {lingui})).toEqual('29d') expect(dateDiff(then, base)).toEqual({
value: 29,
unit: 'day',
earlier: then,
later: base,
})
}) })
it(`values >= 30 days return months`, () => { it(`values >= 30 days return months`, () => {
const then = subDays(base, 30) const then = subDays(base, 30)
expect(dateDiff(then, base, {lingui})).toEqual('1mo') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`months round down`, () => { it(`months round down`, () => {
const then = subDays(base, 59) const then = subDays(base, 59)
expect(dateDiff(then, base, {lingui})).toEqual('1mo') expect(dateDiff(then, base)).toEqual({
value: 1,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values are rounded by increments of 30`, () => { it(`values are rounded by increments of 30`, () => {
const then = subDays(base, 61) const then = subDays(base, 61)
expect(dateDiff(then, base, {lingui})).toEqual('2mo') expect(dateDiff(then, base)).toEqual({
value: 2,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values < 360 days return months`, () => { it(`values < 360 days return months`, () => {
const then = subDays(base, 359) const then = subDays(base, 359)
expect(dateDiff(then, base, {lingui})).toEqual('11mo') expect(dateDiff(then, base)).toEqual({
value: 11,
unit: 'month',
earlier: then,
later: base,
})
}) })
it(`values >= 360 days return the earlier value`, () => { it(`values >= 360 days return the earlier value`, () => {
const then = subDays(base, 360) const then = subDays(base, 360)
expect(dateDiff(then, base, {lingui})).toEqual(then.toLocaleDateString()) expect(dateDiff(then, base)).toEqual({
value: 12,
unit: 'month',
earlier: then,
later: base,
})
}) })
}) })
+6 -1
View File
@@ -15,5 +15,10 @@ export function useInitialNumToRender({
const finalHeight = const finalHeight =
screenHeight - screenHeightOffset - topInset - bottomBarHeight screenHeight - screenHeightOffset - topInset - bottomBarHeight
return Math.floor(finalHeight / minItemHeight) + 1
const minItems = Math.floor(finalHeight / minItemHeight)
if (minItems < 1) {
return 1
}
return minItems
} }
+153 -61
View File
@@ -1,25 +1,16 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {msg, plural} from '@lingui/macro' import {I18n} from '@lingui/core'
import {I18nContext, useLingui} from '@lingui/react' import {defineMessage, msg, plural} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {differenceInSeconds} from 'date-fns' import {differenceInSeconds} from 'date-fns'
export type TimeAgoOptions = { export type DateDiffFormat = 'long' | 'short'
lingui: I18nContext['_']
format?: 'long' | 'short'
}
export function useGetTimeAgo() { type DateDiff = {
const {_} = useLingui() value: number
return useCallback( unit: 'now' | 'second' | 'minute' | 'hour' | 'day' | 'month'
( earlier: Date
earlier: number | string | Date, later: Date
later: number | string | Date,
options?: Omit<TimeAgoOptions, 'lingui'>,
) => {
return dateDiff(earlier, later, {lingui: _, format: options?.format})
},
[_],
)
} }
const NOW = 5 const NOW = 5
@@ -28,59 +19,160 @@ const HOUR = MINUTE * 60
const DAY = HOUR * 24 const DAY = HOUR * 24
const MONTH_30 = DAY * 30 const MONTH_30 = DAY * 30
export function useGetTimeAgo() {
const {i18n} = useLingui()
return useCallback(
(
earlier: number | string | Date,
later: number | string | Date,
options?: {format: DateDiffFormat},
) => {
const diff = dateDiff(earlier, later)
return formatDateDiff({diff, i18n, format: options?.format})
},
[i18n],
)
}
/** /**
* Returns the difference between `earlier` and `later` dates, formatted as a * Returns the difference between `earlier` and `later` dates, based on
* natural language string. * opinionated rules.
*
* - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - All values round down
*/
export function dateDiff(
earlier: number | string | Date,
later: number | string | Date,
): DateDiff {
let diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
const e = new Date(earlier)
const l = new Date(later)
const diffSeconds = differenceInSeconds(l, e)
if (diffSeconds < NOW) {
diff = {
value: 0,
unit: 'now' as DateDiff['unit'],
}
} else if (diffSeconds < MINUTE) {
diff = {
value: diffSeconds,
unit: 'second' as DateDiff['unit'],
}
} else if (diffSeconds < HOUR) {
const value = Math.floor(diffSeconds / MINUTE)
diff = {
value,
unit: 'minute' as DateDiff['unit'],
}
} else if (diffSeconds < DAY) {
const value = Math.floor(diffSeconds / HOUR)
diff = {
value,
unit: 'hour' as DateDiff['unit'],
}
} else if (diffSeconds < MONTH_30) {
const value = Math.floor(diffSeconds / DAY)
diff = {
value,
unit: 'day' as DateDiff['unit'],
}
} else {
const value = Math.floor(diffSeconds / MONTH_30)
diff = {
value,
unit: 'month' as DateDiff['unit'],
}
}
return {
...diff,
earlier: e,
later: l,
}
}
/**
* Accepts a `DateDiff` and teturns the difference between `earlier` and
* `later` dates, formatted as a natural language string.
* *
* - All month are considered exactly 30 days. * - All month are considered exactly 30 days.
* - Dates assume `earlier` <= `later`, and will otherwise return 'now'. * - Dates assume `earlier` <= `later`, and will otherwise return 'now'.
* - Differences >= 360 days are returned as the "M/D/YYYY" string * - Differences >= 360 days are returned as the "M/D/YYYY" string
* - All values round down * - All values round down
*/ */
export function dateDiff( export function formatDateDiff({
earlier: number | string | Date, diff,
later: number | string | Date, format = 'short',
options: TimeAgoOptions, i18n,
): string { }: {
const _ = options.lingui diff: DateDiff
const format = options?.format || 'short' format?: DateDiffFormat
i18n: I18n
}): string {
const long = format === 'long' const long = format === 'long'
const diffSeconds = differenceInSeconds(new Date(later), new Date(earlier))
if (diffSeconds < NOW) { switch (diff.unit) {
return _(msg`now`) case 'now': {
} else if (diffSeconds < MINUTE) { return i18n._(msg`now`)
return `${diffSeconds}${ }
long ? ` ${plural(diffSeconds, {one: 'second', other: 'seconds'})}` : 's' case 'second': {
}` return long
} else if (diffSeconds < HOUR) { ? i18n._(plural(diff.value, {one: '# second', other: '# seconds'}))
const diff = Math.floor(diffSeconds / MINUTE) : i18n._(
return `${diff}${ defineMessage({
long ? ` ${plural(diff, {one: 'minute', other: 'minutes'})}` : 'm' message: `${diff.value}s`,
}` comment: `How many seconds have passed, displayed in a narrow form`,
} else if (diffSeconds < DAY) { }),
const diff = Math.floor(diffSeconds / HOUR) )
return `${diff}${ }
long ? ` ${plural(diff, {one: 'hour', other: 'hours'})}` : 'h' case 'minute': {
}` return long
} else if (diffSeconds < MONTH_30) { ? i18n._(plural(diff.value, {one: '# minute', other: '# minutes'}))
const diff = Math.floor(diffSeconds / DAY) : i18n._(
return `${diff}${ defineMessage({
long ? ` ${plural(diff, {one: 'day', other: 'days'})}` : 'd' message: `${diff.value}m`,
}` comment: `How many minutes have passed, displayed in a narrow form`,
} else { }),
const diff = Math.floor(diffSeconds / MONTH_30) )
if (diff < 12) { }
return `${diff}${ case 'hour': {
long ? ` ${plural(diff, {one: 'month', other: 'months'})}` : 'mo' return long
}` ? i18n._(plural(diff.value, {one: '# hour', other: '# hours'}))
} else { : i18n._(
const str = new Date(earlier).toLocaleDateString() defineMessage({
message: `${diff.value}h`,
if (long) { comment: `How many hours have passed, displayed in a narrow form`,
return _(msg`on ${str}`) }),
)
}
case 'day': {
return long
? i18n._(plural(diff.value, {one: '# day', other: '# days'}))
: i18n._(
defineMessage({
message: `${diff.value}d`,
comment: `How many days have passed, displayed in a narrow form`,
}),
)
}
case 'month': {
if (diff.value < 12) {
return long
? i18n._(plural(diff.value, {one: '# month', other: '# months'}))
: i18n._(
defineMessage({
message: `${diff.value}mo`,
comment: `How many months have passed, displayed in a narrow form`,
}),
)
} }
return str return i18n.date(new Date(diff.earlier))
} }
} }
} }
+1 -4
View File
@@ -1,9 +1,6 @@
import {getVideoMetaData, Video} from 'react-native-compressor' import {getVideoMetaData, Video} from 'react-native-compressor'
export type CompressedVideo = { import {CompressedVideo} from './types'
uri: string
size: number
}
export async function compressVideo( export async function compressVideo(
file: string, file: string,
+33 -8
View File
@@ -1,12 +1,8 @@
import {VideoTooLargeError} from 'lib/media/video/errors' import {VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from './types'
const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB
export type CompressedVideo = {
uri: string
size: number
}
// doesn't actually compress, but throws if >100MB // doesn't actually compress, but throws if >100MB
export async function compressVideo( export async function compressVideo(
file: string, file: string,
@@ -15,8 +11,9 @@ export async function compressVideo(
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
}, },
): Promise<CompressedVideo> { ): Promise<CompressedVideo> {
const blob = await fetch(file).then(res => res.blob()) const {mimeType, base64} = parseDataUrl(file)
const video = URL.createObjectURL(blob) const blob = base64ToBlob(base64, mimeType)
const uri = URL.createObjectURL(blob)
if (blob.size > MAX_VIDEO_SIZE) { if (blob.size > MAX_VIDEO_SIZE) {
throw new VideoTooLargeError() throw new VideoTooLargeError()
@@ -24,6 +21,34 @@ export async function compressVideo(
return { return {
size: blob.size, size: blob.size,
uri: video, uri,
bytes: await blob.arrayBuffer(),
} }
} }
function parseDataUrl(dataUrl: string) {
const [mimeType, base64] = dataUrl.slice('data:'.length).split(';base64,')
if (!mimeType || !base64) {
throw new Error('Invalid data URL')
}
return {mimeType, base64}
}
function base64ToBlob(base64: string, mimeType: string) {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, {type: mimeType})
}
+6
View File
@@ -0,0 +1,6 @@
export type CompressedVideo = {
uri: string
size: number
// web only, can fall back to uri if missing
bytes?: ArrayBuffer
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { import {
ComAtprotoLabelDefs,
AppBskyLabelerDefs, AppBskyLabelerDefs,
LABELS, ComAtprotoLabelDefs,
interpretLabelValueDefinition,
InterpretedLabelValueDefinition, InterpretedLabelValueDefinition,
interpretLabelValueDefinition,
LABELS,
} from '@atproto/api' } from '@atproto/api'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import * as bcp47Match from 'bcp-47-match' import * as bcp47Match from 'bcp-47-match'
+4 -2
View File
@@ -4,5 +4,7 @@ export type Gate =
| 'fixed_bottom_bar' | 'fixed_bottom_bar'
| 'onboarding_minimum_interests' | 'onboarding_minimum_interests'
| 'suggested_feeds_interstitial' | 'suggested_feeds_interstitial'
| 'video_debug' | 'show_follow_suggestions_in_profile'
| 'videos' | 'video_debug' // not recommended
| 'video_upload' // upload videos
| 'video_view_on_posts' // see posted videos
+18
View File
@@ -1,3 +1,6 @@
import {useCallback, useMemo} from 'react'
import Graphemer from 'graphemer'
export function enforceLen( export function enforceLen(
str: string, str: string,
len: number, len: number,
@@ -23,6 +26,21 @@ export function enforceLen(
return str return str
} }
export function useEnforceMaxGraphemeCount() {
const splitter = useMemo(() => new Graphemer(), [])
return useCallback(
(text: string, maxCount: number) => {
if (splitter.countGraphemes(text) > maxCount) {
return splitter.splitGraphemes(text).slice(0, maxCount).join('')
} else {
return text
}
},
[splitter],
)
}
// https://stackoverflow.com/a/52171480 // https://stackoverflow.com/a/52171480
export function toHashCode(str: string, seed = 0): number { export function toHashCode(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed, let h1 = 0xdeadbeef ^ seed,
+8 -9
View File
@@ -1,13 +1,12 @@
export function niceDate(date: number | string | Date) { import {I18n} from '@lingui/core'
export function niceDate(i18n: I18n, date: number | string | Date) {
const d = new Date(date) const d = new Date(date)
return `${d.toLocaleDateString('en-us', {
year: 'numeric', return i18n.date(d, {
month: 'short', dateStyle: 'long',
day: 'numeric', timeStyle: 'short',
})} at ${d.toLocaleTimeString(undefined, { })
hour: 'numeric',
minute: '2-digit',
})}`
} }
export function getAge(birthDate: Date): number { export function getAge(birthDate: Date): number {
+2 -2
View File
@@ -340,7 +340,7 @@ export function shortLinkToHref(url: string): string {
} }
} }
export function getHostnameFromUrl(url: string): string | null { export function getHostnameFromUrl(url: string | URL): string | null {
let urlp let urlp
try { try {
urlp = new URL(url) urlp = new URL(url)
@@ -350,7 +350,7 @@ export function getHostnameFromUrl(url: string): string | null {
return urlp.hostname return urlp.hostname
} }
export function getServiceAuthAudFromUrl(url: string): string | null { export function getServiceAuthAudFromUrl(url: string | URL): string | null {
const hostname = getHostnameFromUrl(url) const hostname = getHostnameFromUrl(url)
if (!hostname) { if (!hostname) {
return null return null
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers' import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {isWeb} from 'platform/detection'
import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy'
import {ViewHeader} from '#/view/com/util/ViewHeader' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ListHeaderDesktop} from '#/components/Lists'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'>
export const PostLikedByScreen = ({route}: Props) => { export const PostLikedByScreen = ({route}: Props) => {
@@ -25,9 +27,10 @@ export const PostLikedByScreen = ({route}: Props) => {
) )
return ( return (
<View style={a.flex_1}> <CenteredView style={a.h_full_vh} sideBorders={true}>
<ViewHeader title={_(msg`Liked By`)} /> <ListHeaderDesktop title={_(msg`Liked By`)} />
<ViewHeader title={_(msg`Liked By`)} showBorder={!isWeb} />
<PostLikedByComponent uri={uri} /> <PostLikedByComponent uri={uri} />
</View> </CenteredView>
) )
} }
+7 -4
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers' import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {isWeb} from 'platform/detection'
import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes'
import {ViewHeader} from '#/view/com/util/ViewHeader' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ListHeaderDesktop} from '#/components/Lists'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'>
export const PostQuotesScreen = ({route}: Props) => { export const PostQuotesScreen = ({route}: Props) => {
@@ -25,9 +27,10 @@ export const PostQuotesScreen = ({route}: Props) => {
) )
return ( return (
<View style={a.flex_1}> <CenteredView style={a.h_full_vh} sideBorders={true}>
<ViewHeader title={_(msg`Quotes`)} /> <ListHeaderDesktop title={_(msg`Quotes`)} />
<ViewHeader title={_(msg`Quotes`)} showBorder={!isWeb} />
<PostQuotesComponent uri={uri} /> <PostQuotesComponent uri={uri} />
</View> </CenteredView>
) )
} }
+7 -4
View File
@@ -1,5 +1,4 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native' import {useFocusEffect} from '@react-navigation/native'
@@ -7,9 +6,12 @@ import {useFocusEffect} from '@react-navigation/native'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {makeRecordUri} from '#/lib/strings/url-helpers' import {makeRecordUri} from '#/lib/strings/url-helpers'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {isWeb} from 'platform/detection'
import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy'
import {ViewHeader} from '#/view/com/util/ViewHeader' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {ListHeaderDesktop} from '#/components/Lists'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'>
export const PostRepostedByScreen = ({route}: Props) => { export const PostRepostedByScreen = ({route}: Props) => {
@@ -25,9 +27,10 @@ export const PostRepostedByScreen = ({route}: Props) => {
) )
return ( return (
<View style={a.flex_1}> <CenteredView style={a.h_full_vh} sideBorders={true}>
<ViewHeader title={_(msg`Reposted By`)} /> <ListHeaderDesktop title={_(msg`Reposted By`)} />
<ViewHeader title={_(msg`Reposted By`)} showBorder={!isWeb} />
<PostRepostedByComponent uri={uri} /> <PostRepostedByComponent uri={uri} />
</View> </CenteredView>
) )
} }
+4 -4
View File
@@ -17,9 +17,9 @@ export function ProfileHeaderMetrics({
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const following = formatCount(profile.followsCount || 0) const following = formatCount(i18n, profile.followsCount || 0)
const followers = formatCount(profile.followersCount || 0) const followers = formatCount(i18n, profile.followersCount || 0)
const pluralizedFollowers = plural(profile.followersCount || 0, { const pluralizedFollowers = plural(profile.followersCount || 0, {
one: 'follower', one: 'follower',
other: 'followers', other: 'followers',
@@ -54,7 +54,7 @@ export function ProfileHeaderMetrics({
</Text> </Text>
</InlineLinkText> </InlineLinkText>
<Text style={[a.font_bold, t.atoms.text, a.text_md]}> <Text style={[a.font_bold, t.atoms.text, a.text_md]}>
{formatCount(profile.postsCount || 0)}{' '} {formatCount(i18n, profile.postsCount || 0)}{' '}
<Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}> <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
{plural(profile.postsCount || 0, {one: 'post', other: 'posts'})} {plural(profile.postsCount || 0, {one: 'post', other: 'posts'})}
</Text> </Text>
@@ -10,6 +10,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
@@ -59,6 +60,7 @@ let ProfileHeaderStandard = ({
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
useProfileShadow(profileUnshadowed) useProfileShadow(profileUnshadowed)
const t = useTheme() const t = useTheme()
const gate = useGate()
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const {openModal} = useModalControls() const {openModal} = useModalControls()
@@ -203,27 +205,29 @@ let ProfileHeaderStandard = ({
{hasSession && ( {hasSession && (
<> <>
<MessageProfileButton profile={profile} /> <MessageProfileButton profile={profile} />
<Button {!gate('show_follow_suggestions_in_profile') && (
testID="suggestedFollowsBtn" <Button
size="small" testID="suggestedFollowsBtn"
color={showSuggestedFollows ? 'primary' : 'secondary'} size="small"
variant="solid" color={showSuggestedFollows ? 'primary' : 'secondary'}
shape="round" variant="solid"
onPress={() => shape="round"
setShowSuggestedFollows(!showSuggestedFollows) onPress={() =>
} setShowSuggestedFollows(!showSuggestedFollows)
label={_(msg`Show follows similar to ${profile.handle}`)}
style={{width: 36, height: 36}}>
<FontAwesomeIcon
icon="user-plus"
style={
showSuggestedFollows
? {color: t.palette.white}
: t.atoms.text
} }
size={14} label={_(msg`Show follows similar to ${profile.handle}`)}
/> style={{width: 36, height: 36}}>
</Button> <FontAwesomeIcon
icon="user-plus"
style={
showSuggestedFollows
? {color: t.palette.white}
: t.atoms.text
}
size={14}
/>
</Button>
)}
</> </>
)} )}
@@ -113,7 +113,7 @@ function LandingScreenLoaded({
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
}) { }) {
const {creator, listItemsSample, feeds} = starterPack const {creator, listItemsSample, feeds} = starterPack
const {_} = useLingui() const {_, i18n} = useLingui()
const t = useTheme() const t = useTheme()
const activeStarterPack = useActiveStarterPack() const activeStarterPack = useActiveStarterPack()
const setActiveStarterPack = useSetActiveStarterPack() const setActiveStarterPack = useSetActiveStarterPack()
@@ -225,7 +225,9 @@ function LandingScreenLoaded({
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
]} ]}
numberOfLines={1}> numberOfLines={1}>
<Trans>{formatCount(JOINED_THIS_WEEK)} joined this week</Trans> <Trans>
{formatCount(i18n, JOINED_THIS_WEEK)} joined this week
</Trans>
</Text> </Text>
</View> </View>
</View> </View>
+2 -1
View File
@@ -2,7 +2,8 @@ import {ImagePickerAsset} from 'expo-image-picker'
import {useMutation} from '@tanstack/react-query' import {useMutation} from '@tanstack/react-query'
import {cancelable} from '#/lib/async/cancelable' import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo, compressVideo} from 'lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/types'
import {compressVideo} from 'lib/media/video/compress'
export function useCompressVideoMutation({ export function useCompressVideoMutation({
onProgress, onProgress,
+4 -6
View File
@@ -4,7 +4,7 @@ import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@@ -28,14 +28,11 @@ export const useUploadVideoMutation = ({
mutationFn: cancelable(async (video: CompressedVideo) => { mutationFn: cancelable(async (video: CompressedVideo) => {
const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', { const uri = createVideoEndpointUrl('/xrpc/app.bsky.video.uploadVideo', {
did: currentAccount!.did, did: currentAccount!.did,
name: `${nanoid(12)}.mp4`, // @TODO what are we limiting this to? name: `${nanoid(12)}.mp4`,
}) })
if (!currentAccount?.service) { const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
throw new Error('User is not logged in')
}
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
if (!serviceAuthAud) { if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL') throw new Error('Agent does not have a PDS URL')
} }
@@ -44,6 +41,7 @@ export const useUploadVideoMutation = ({
{ {
aud: serviceAuthAud, aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob', lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
}, },
) )
+8 -6
View File
@@ -3,7 +3,7 @@ import {useMutation} from '@tanstack/react-query'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
import {cancelable} from '#/lib/async/cancelable' import {cancelable} from '#/lib/async/cancelable'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/types'
import {createVideoEndpointUrl} from '#/state/queries/video/util' import {createVideoEndpointUrl} from '#/state/queries/video/util'
import {useAgent, useSession} from '#/state/session' import {useAgent, useSession} from '#/state/session'
import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers' import {getServiceAuthAudFromUrl} from 'lib/strings/url-helpers'
@@ -30,11 +30,8 @@ export const useUploadVideoMutation = ({
name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4' name: `${nanoid(12)}.mp4`, // @TODO: make sure it's always mp4'
}) })
if (!currentAccount?.service) { const serviceAuthAud = getServiceAuthAudFromUrl(agent.dispatchUrl)
throw new Error('User is not logged in')
}
const serviceAuthAud = getServiceAuthAudFromUrl(currentAccount.service)
if (!serviceAuthAud) { if (!serviceAuthAud) {
throw new Error('Agent does not have a PDS URL') throw new Error('Agent does not have a PDS URL')
} }
@@ -43,10 +40,15 @@ export const useUploadVideoMutation = ({
{ {
aud: serviceAuthAud, aud: serviceAuthAud,
lxm: 'com.atproto.repo.uploadBlob', lxm: 'com.atproto.repo.uploadBlob',
exp: Date.now() / 1000 + 60 * 30, // 30 minutes
}, },
) )
const bytes = await fetch(video.uri).then(res => res.arrayBuffer()) let bytes = video.bytes
if (!bytes) {
bytes = await fetch(video.uri).then(res => res.arrayBuffer())
}
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
const res = await new Promise<AppBskyVideoDefs.JobStatus>( const res = await new Promise<AppBskyVideoDefs.JobStatus>(
+19 -2
View File
@@ -1,4 +1,4 @@
import React from 'react' import React, {useCallback} from 'react'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {AppBskyVideoDefs, BlobRef} from '@atproto/api' import {AppBskyVideoDefs, BlobRef} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
@@ -6,8 +6,8 @@ import {useLingui} from '@lingui/react'
import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query' import {QueryClient, useQuery, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
import {CompressedVideo} from 'lib/media/video/compress'
import {VideoTooLargeError} from 'lib/media/video/errors' import {VideoTooLargeError} from 'lib/media/video/errors'
import {CompressedVideo} from 'lib/media/video/types'
import {useCompressVideoMutation} from 'state/queries/video/compress-video' import {useCompressVideoMutation} from 'state/queries/video/compress-video'
import {useVideoAgent} from 'state/queries/video/util' import {useVideoAgent} from 'state/queries/video/util'
import {useUploadVideoMutation} from 'state/queries/video/video-upload' import {useUploadVideoMutation} from 'state/queries/video/video-upload'
@@ -20,6 +20,7 @@ type Action =
| {type: 'SetError'; error: string | undefined} | {type: 'SetError'; error: string | undefined}
| {type: 'Reset'} | {type: 'Reset'}
| {type: 'SetAsset'; asset: ImagePickerAsset} | {type: 'SetAsset'; asset: ImagePickerAsset}
| {type: 'SetDimensions'; width: number; height: number}
| {type: 'SetVideo'; video: CompressedVideo} | {type: 'SetVideo'; video: CompressedVideo}
| {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus} | {type: 'SetJobStatus'; jobStatus: AppBskyVideoDefs.JobStatus}
| {type: 'SetBlobRef'; blobRef: BlobRef} | {type: 'SetBlobRef'; blobRef: BlobRef}
@@ -58,6 +59,13 @@ function reducer(queryClient: QueryClient) {
} }
} else if (action.type === 'SetAsset') { } else if (action.type === 'SetAsset') {
updatedState = {...state, asset: action.asset} updatedState = {...state, asset: action.asset}
} else if (action.type === 'SetDimensions') {
updatedState = {
...state,
asset: state.asset
? {...state.asset, width: action.width, height: action.height}
: undefined,
}
} else if (action.type === 'SetVideo') { } else if (action.type === 'SetVideo') {
updatedState = {...state, video: action.video} updatedState = {...state, video: action.video}
} else if (action.type === 'SetJobStatus') { } else if (action.type === 'SetJobStatus') {
@@ -178,11 +186,20 @@ export function useUploadVideo({
dispatch({type: 'Reset'}) dispatch({type: 'Reset'})
} }
const updateVideoDimensions = useCallback((width: number, height: number) => {
dispatch({
type: 'SetDimensions',
width,
height,
})
}, [])
return { return {
state, state,
dispatch, dispatch,
selectVideo, selectVideo,
clearVideo, clearVideo,
updateVideoDimensions,
} }
} }
+20 -7
View File
@@ -5,8 +5,11 @@ import {
AppBskyRichtextFacet, AppBskyRichtextFacet,
ModerationDecision, ModerationDecision,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import * as Toast from '#/view/com/util/Toast'
export interface ComposerOptsPostRef { export interface ComposerOptsPostRef {
uri: string uri: string
@@ -22,12 +25,7 @@ export interface ComposerOptsQuote {
text: string text: string
facets?: AppBskyRichtextFacet.Main[] facets?: AppBskyRichtextFacet.Main[]
indexedAt: string indexedAt: string
author: { author: AppBskyActorDefs.ProfileViewBasic
did: string
handle: string
displayName?: string
avatar?: string
}
embeds?: AppBskyEmbedRecord.ViewRecord['embeds'] embeds?: AppBskyEmbedRecord.ViewRecord['embeds']
} }
export interface ComposerOpts { export interface ComposerOpts {
@@ -56,10 +54,25 @@ const controlsContext = React.createContext<ControlsContext>({
}) })
export function Provider({children}: React.PropsWithChildren<{}>) { export function Provider({children}: React.PropsWithChildren<{}>) {
const {_} = useLingui()
const [state, setState] = React.useState<StateContext>() const [state, setState] = React.useState<StateContext>()
const openComposer = useNonReactiveCallback((opts: ComposerOpts) => { const openComposer = useNonReactiveCallback((opts: ComposerOpts) => {
setState(opts) const author = opts.replyTo?.author || opts.quote?.author
const isBlocked = Boolean(
author &&
(author.viewer?.blocking ||
author.viewer?.blockedBy ||
author.viewer?.blockingByList),
)
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
} else {
setState(opts)
}
}) })
const closeComposer = useNonReactiveCallback(() => { const closeComposer = useNonReactiveCallback(() => {
+41 -11
View File
@@ -108,6 +108,7 @@ import {TextInput, TextInputRef} from './text-input/TextInput'
import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {ThreadgateBtn} from './threadgate/ThreadgateBtn'
import {useExternalLinkFetch} from './useExternalLinkFetch' import {useExternalLinkFetch} from './useExternalLinkFetch'
import {SelectVideoBtn} from './videos/SelectVideoBtn' import {SelectVideoBtn} from './videos/SelectVideoBtn'
import {SubtitleDialogBtn} from './videos/SubtitleDialog'
import {VideoPreview} from './videos/VideoPreview' import {VideoPreview} from './videos/VideoPreview'
import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress'
@@ -172,10 +173,14 @@ export const ComposePost = observer(function ComposePost({
initQuote, initQuote,
) )
const [videoAltText, setVideoAltText] = useState('')
const [captions, setCaptions] = useState<{lang: string; file: File}[]>([])
const { const {
selectVideo, selectVideo,
clearVideo, clearVideo,
state: videoUploadState, state: videoUploadState,
updateVideoDimensions,
} = useUploadVideo({ } = useUploadVideo({
setStatus: setProcessingState, setStatus: setProcessingState,
onSuccess: () => { onSuccess: () => {
@@ -347,7 +352,19 @@ export const ComposePost = observer(function ComposePost({
postgate, postgate,
onStateChange: setProcessingState, onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage), langs: toPostLanguages(langPrefs.postLanguage),
video: videoUploadState.blobRef, video: videoUploadState.blobRef
? {
blobRef: videoUploadState.blobRef,
altText: videoAltText,
captions: captions,
aspectRatio: videoUploadState.asset
? {
width: videoUploadState.asset?.width,
height: videoUploadState.asset?.height,
}
: undefined,
}
: undefined,
}) })
).uri ).uri
try { try {
@@ -694,16 +711,29 @@ export const ComposePost = observer(function ComposePost({
)} )}
</View> </View>
) : null} ) : null}
{videoUploadState.status === 'compressing' && {videoUploadState.asset &&
videoUploadState.asset ? ( (videoUploadState.status === 'compressing' ? (
<VideoTranscodeProgress <VideoTranscodeProgress
asset={videoUploadState.asset} asset={videoUploadState.asset}
progress={videoUploadState.progress} progress={videoUploadState.progress}
clear={clearVideo} clear={clearVideo}
/>
) : videoUploadState.video ? (
<VideoPreview
asset={videoUploadState.asset}
video={videoUploadState.video}
setDimensions={updateVideoDimensions}
clear={clearVideo}
/>
) : null)}
{(videoUploadState.asset || videoUploadState.video) && (
<SubtitleDialogBtn
altText={videoAltText}
setAltText={setVideoAltText}
captions={captions}
setCaptions={setCaptions}
/> />
) : videoUploadState.video ? ( )}
<VideoPreview video={videoUploadState.video} clear={clearVideo} />
) : null}
</View> </View>
</Animated.ScrollView> </Animated.ScrollView>
<SuggestedLanguage text={richtext.text} /> <SuggestedLanguage text={richtext.text} />
@@ -730,7 +760,7 @@ export const ComposePost = observer(function ComposePost({
) : ( ) : (
<ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
{gate('videos') && ( {gate('video_upload') && (
<SelectVideoBtn <SelectVideoBtn
onSelectVideo={selectVideo} onSelectVideo={selectVideo}
disabled={!canSelectImages} disabled={!canSelectImages}
@@ -0,0 +1,265 @@
import React, {useCallback} from 'react'
import {StyleProp, View, ViewStyle} from 'react-native'
import RNPickerSelect from 'react-native-picker-select'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {MAX_ALT_TEXT} from '#/lib/constants'
import {useEnforceMaxGraphemeCount} from '#/lib/strings/helpers'
import {LANGUAGES} from '#/locale/languages'
import {isWeb} from '#/platform/detection'
import {useLanguagePrefs} from '#/state/preferences'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as TextField from '#/components/forms/TextField'
import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
import {PageText_Stroke2_Corner0_Rounded as PageTextIcon} from '#/components/icons/PageText'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
import {Text} from '#/components/Typography'
import {SubtitleFilePicker} from './SubtitleFilePicker'
interface Props {
altText: string
captions: {lang: string; file: File}[]
setAltText: (altText: string) => void
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
}
export function SubtitleDialogBtn(props: Props) {
const control = Dialog.useDialogControl()
const {_} = useLingui()
return (
<View style={[a.flex_row, a.mt_xs]}>
<Button
label={isWeb ? _('Captions & alt text') : _('Alt text')}
accessibilityHint={
isWeb
? _('Opens captions and alt text dialog')
: _('Opens alt text dialog')
}
size="xsmall"
color="secondary"
variant="ghost"
onPress={control.open}>
<ButtonIcon icon={CCIcon} />
<ButtonText>
{isWeb ? <Trans>Captions & alt text</Trans> : <Trans>Alt text</Trans>}
</ButtonText>
</Button>
<Dialog.Outer control={control}>
<Dialog.Handle />
<SubtitleDialogInner {...props} />
</Dialog.Outer>
</View>
)
}
function SubtitleDialogInner({
altText,
setAltText,
captions,
setCaptions,
}: Props) {
const control = Dialog.useDialogContext()
const {_} = useLingui()
const t = useTheme()
const enforceLen = useEnforceMaxGraphemeCount()
const {primaryLanguage} = useLanguagePrefs()
const handleSelectFile = useCallback(
(file: File) => {
setCaptions(subs => [
...subs,
{
lang: subs.some(s => s.lang === primaryLanguage)
? ''
: primaryLanguage,
file,
},
])
},
[setCaptions, primaryLanguage],
)
const subtitleMissingLanguage = captions.some(sub => sub.lang === '')
return (
<Dialog.ScrollableInner label={_(msg`Video settings`)}>
<View style={a.gap_md}>
<Text style={[a.text_xl, a.font_bold, a.leading_tight]}>
<Trans>Alt text</Trans>
</Text>
<TextField.Root>
<Dialog.Input
label={_(msg`Alt text`)}
placeholder={_(msg`Add alt text (optional)`)}
value={altText}
onChangeText={evt => setAltText(enforceLen(evt, MAX_ALT_TEXT))}
maxLength={MAX_ALT_TEXT * 10}
multiline
numberOfLines={3}
onKeyPress={({nativeEvent}) => {
if (nativeEvent.key === 'Escape') {
control.close()
}
}}
/>
</TextField.Root>
{isWeb && (
<>
<View
style={[
a.border_t,
a.w_full,
t.atoms.border_contrast_medium,
a.my_md,
]}
/>
<Text style={[a.text_xl, a.font_bold, a.leading_tight]}>
<Trans>Captions (.vtt)</Trans>
</Text>
<SubtitleFilePicker
onSelectFile={handleSelectFile}
disabled={subtitleMissingLanguage || captions.length >= 4}
/>
<View>
{captions.map((subtitle, i) => (
<SubtitleFileRow
key={subtitle.lang}
language={subtitle.lang}
file={subtitle.file}
setCaptions={setCaptions}
otherLanguages={LANGUAGES.filter(
lang =>
langCode(lang) === subtitle.lang ||
!captions.some(s => s.lang === langCode(lang)),
)}
style={[i % 2 === 0 && t.atoms.bg_contrast_25]}
/>
))}
</View>
</>
)}
{subtitleMissingLanguage && (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
Ensure you have selected a language for each subtitle file.
</Text>
)}
<View style={web([a.flex_row, a.justify_end])}>
<Button
label={_(msg`Done`)}
size={isWeb ? 'small' : 'medium'}
color="primary"
variant="solid"
onPress={() => control.close()}
style={a.mt_lg}>
<ButtonText>
<Trans>Done</Trans>
</ButtonText>
</Button>
</View>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function SubtitleFileRow({
language,
file,
otherLanguages,
setCaptions,
style,
}: {
language: string
file: File
otherLanguages: {code2: string; code3: string; name: string}[]
setCaptions: React.Dispatch<
React.SetStateAction<{lang: string; file: File}[]>
>
style: StyleProp<ViewStyle>
}) {
const {_} = useLingui()
const t = useTheme()
const handleValueChange = useCallback(
(lang: string) => {
if (lang) {
setCaptions(subs =>
subs.map(s => (s.lang === language ? {lang, file: s.file} : s)),
)
}
},
[setCaptions, language],
)
return (
<View
style={[
a.flex_row,
a.justify_between,
a.py_md,
a.px_lg,
a.rounded_md,
a.gap_md,
style,
]}>
<View style={[a.flex_1, a.gap_xs, a.justify_center]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
{language === '' ? (
<WarningIcon
style={a.flex_shrink_0}
fill={t.palette.negative_500}
size="sm"
/>
) : (
<PageTextIcon style={[t.atoms.text, a.flex_shrink_0]} size="sm" />
)}
<Text
style={[a.flex_1, a.leading_snug, a.font_bold, a.mb_2xs]}
numberOfLines={1}>
{file.name}
</Text>
<RNPickerSelect
placeholder={{
label: _(msg`Select language...`),
value: '',
}}
value={language}
onValueChange={handleValueChange}
items={otherLanguages.map(lang => ({
label: `${lang.name} (${langCode(lang)})`,
value: langCode(lang),
}))}
style={{viewContainer: {maxWidth: 200, flex: 1}}}
/>
</View>
</View>
<Button
label={_(msg`Remove subtitle file`)}
size="tiny"
shape="round"
variant="outline"
color="secondary"
onPress={() =>
setCaptions(subs => subs.filter(s => s.lang !== language))
}
style={[a.ml_sm]}>
<ButtonIcon icon={X} />
</Button>
</View>
)
}
function langCode(lang: {code2: string; code3: string}) {
return lang.code2 || lang.code3
}
@@ -0,0 +1,3 @@
export function SubtitleFilePicker() {
throw new Error('SubtitleFilePicker is a web-only component')
}
@@ -0,0 +1,63 @@
import React, {useRef} from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CC_Stroke2_Corner0_Rounded as CCIcon} from '#/components/icons/CC'
export function SubtitleFilePicker({
onSelectFile,
disabled,
}: {
onSelectFile: (file: File) => void
disabled?: boolean
}) {
const {_} = useLingui()
const ref = useRef<HTMLInputElement>(null)
const handleClick = () => {
ref.current?.click()
}
const handlePick = (evt: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = evt.target.files?.[0]
if (selectedFile) {
if (selectedFile.type === 'text/vtt') {
onSelectFile(selectedFile)
} else {
Toast.show(_(msg`Only WebVTT (.vtt) files are supported`))
}
}
}
return (
<View style={a.gap_lg}>
<input
type="file"
accept=".vtt"
ref={ref}
style={a.hidden}
onChange={handlePick}
disabled={disabled}
aria-disabled={disabled}
/>
<View style={a.flex_row}>
<Button
onPress={handleClick}
label={_('Select subtitle file (.vtt)')}
size="medium"
color="primary"
variant="solid"
disabled={disabled}>
<ButtonIcon icon={CCIcon} />
<ButtonText>
<Trans>Select subtitle file (.vtt)</Trans>
</ButtonText>
</Button>
</View>
</View>
)
}
+21 -3
View File
@@ -1,38 +1,56 @@
/* eslint-disable @typescript-eslint/no-shadow */ /* eslint-disable @typescript-eslint/no-shadow */
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
import {useVideoPlayer, VideoView} from 'expo-video' import {useVideoPlayer, VideoView} from 'expo-video'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf' import {atoms as a, useTheme} from '#/alf'
export function VideoPreview({ export function VideoPreview({
asset,
video, video,
clear, clear,
}: { }: {
asset: ImagePickerAsset
video: CompressedVideo video: CompressedVideo
setDimensions: (width: number, height: number) => void
clear: () => void clear: () => void
}) { }) {
const t = useTheme()
const player = useVideoPlayer(video.uri, player => { const player = useVideoPlayer(video.uri, player => {
player.loop = true player.loop = true
player.muted = true player.muted = true
player.play() player.play()
}) })
let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) {
aspectRatio = 16 / 9
}
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
return ( return (
<View <View
style={[ style={[
a.w_full, a.w_full,
a.rounded_sm, a.rounded_sm,
{aspectRatio: 16 / 9}, {aspectRatio},
a.overflow_hidden, a.overflow_hidden,
a.border,
t.atoms.border_contrast_low,
{backgroundColor: 'black'},
]}> ]}>
<VideoView <VideoView
player={player} player={player}
style={a.flex_1} style={a.flex_1}
allowsPictureInPicture={false} allowsPictureInPicture={false}
nativeControls={false} nativeControls={false}
contentFit="contain"
/> />
<ExternalEmbedRemoveBtn onRemove={clear} /> <ExternalEmbedRemoveBtn onRemove={clear} />
</View> </View>
@@ -1,27 +1,70 @@
import React from 'react' import React, {useEffect, useRef} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker'
import {CompressedVideo} from '#/lib/media/video/compress' import {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers'
import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
export function VideoPreview({ export function VideoPreview({
asset,
video, video,
setDimensions,
clear, clear,
}: { }: {
asset: ImagePickerAsset
video: CompressedVideo video: CompressedVideo
setDimensions: (width: number, height: number) => void
clear: () => void clear: () => void
}) { }) {
const ref = useRef<HTMLVideoElement>(null)
useEffect(() => {
if (!ref.current) return
const abortController = new AbortController()
const {signal} = abortController
ref.current.addEventListener(
'loadedmetadata',
function () {
setDimensions(this.videoWidth, this.videoHeight)
},
{signal},
)
return () => {
abortController.abort()
}
}, [setDimensions])
let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) {
aspectRatio = 16 / 9
}
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
return ( return (
<View <View
style={[ style={[
a.w_full, a.w_full,
a.rounded_sm, a.rounded_sm,
{aspectRatio: 16 / 9}, {aspectRatio},
a.overflow_hidden, a.overflow_hidden,
{backgroundColor: 'black'},
]}> ]}>
<ExternalEmbedRemoveBtn onRemove={clear} /> <ExternalEmbedRemoveBtn onRemove={clear} />
<video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline /> <video
ref={ref}
src={video.uri}
style={a.flex_1}
autoPlay
loop
muted
playsInline
/>
</View> </View>
) )
} }
@@ -21,8 +21,8 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
}, []) }, [])
return ( return (
<Animated.View style={a.flex_1} entering={FadeIn}> thumbnail && (
{thumbnail && ( <Animated.View style={a.flex_1} entering={FadeIn}>
<Image <Image
style={a.flex_1} style={a.flex_1}
source={thumbnail.path} source={thumbnail.path}
@@ -31,7 +31,7 @@ export function VideoTranscodeBackdrop({uri}: {uri: string}) {
blurRadius={15} blurRadius={15}
contentFit="cover" contentFit="cover"
/> />
)} </Animated.View>
</Animated.View> )
) )
} }
@@ -1,7 +1,3 @@
import React from 'react' export function VideoTranscodeBackdrop() {
return null
export function VideoTranscodeBackdrop({uri}: {uri: string}) {
return (
<video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay />
)
} }
@@ -4,6 +4,8 @@ import {View} from 'react-native'
import ProgressPie from 'react-native-progress/Pie' import ProgressPie from 'react-native-progress/Pie'
import {ImagePickerAsset} from 'expo-image-picker' import {ImagePickerAsset} from 'expo-image-picker'
import {clamp} from '#/lib/numbers'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn' import {ExternalEmbedRemoveBtn} from '../ExternalEmbedRemoveBtn'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -19,7 +21,15 @@ export function VideoTranscodeProgress({
}) { }) {
const t = useTheme() const t = useTheme()
const aspectRatio = asset.width / asset.height if (isWeb) return null
let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) {
aspectRatio = 16 / 9
}
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
return ( return (
<View <View
@@ -29,7 +39,7 @@ export function VideoTranscodeProgress({
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
a.rounded_md, a.rounded_md,
a.overflow_hidden, a.overflow_hidden,
{aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio}, {aspectRatio},
]}> ]}>
<VideoTranscodeBackdrop uri={asset.uri} /> <VideoTranscodeBackdrop uri={asset.uri} />
<View <View
+12 -11
View File
@@ -1,23 +1,24 @@
import React, {useCallback, useMemo, useState} from 'react' import React, {useCallback, useMemo, useState} from 'react'
import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api' import {AppBskyFeedGetLikes as GetLikes} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
import {useLikedByQuery} from '#/state/queries/post-liked-by' import {useLikedByQuery} from '#/state/queries/post-liked-by'
import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import { import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
function renderItem({item}: {item: GetLikes.Like}) { function renderItem({item, index}: {item: GetLikes.Like; index: number}) {
return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} /> return (
<ProfileCardWithFollowBtn
key={item.actor.did}
profile={item.actor}
noBorder={index === 0 && !isWeb}
/>
)
} }
function keyExtractor(item: GetLikes.Like) { function keyExtractor(item: GetLikes.Like) {
@@ -25,7 +26,6 @@ function keyExtractor(item: GetLikes.Like) {
} }
export function PostLikedBy({uri}: {uri: string}) { export function PostLikedBy({uri}: {uri: string}) {
const {_} = useLingui()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
@@ -78,6 +78,7 @@ export function PostLikedBy({uri}: {uri: string}) {
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={isLoadingUri || isLoadingLikes} isLoading={isLoadingUri || isLoadingLikes}
isError={isError} isError={isError}
sideBorders={false}
/> />
) )
} }
@@ -91,7 +92,6 @@ export function PostLikedBy({uri}: {uri: string}) {
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Liked By`)} />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
@@ -103,6 +103,7 @@ export function PostLikedBy({uri}: {uri: string}) {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
sideBorders={false}
/> />
) )
} }
+7 -8
View File
@@ -14,24 +14,23 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuotesQuery} from '#/state/queries/post-quotes' import {usePostQuotesQuery} from '#/state/queries/post-quotes'
import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {Post} from 'view/com/post/Post' import {Post} from 'view/com/post/Post'
import { import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
import {List} from '../util/List' import {List} from '../util/List'
function renderItem({ function renderItem({
item, item,
index,
}: { }: {
item: { item: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
moderation: ModerationDecision moderation: ModerationDecision
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
} }
index: number
}) { }) {
return <Post post={item.post} /> return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} />
} }
function keyExtractor(item: { function keyExtractor(item: {
@@ -45,7 +44,6 @@ function keyExtractor(item: {
export function PostQuotes({uri}: {uri: string}) { export function PostQuotes({uri}: {uri: string}) {
const {_} = useLingui() const {_} = useLingui()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
const { const {
@@ -104,6 +102,7 @@ export function PostQuotes({uri}: {uri: string}) {
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={isLoadingUri || isLoadingQuotes} isLoading={isLoadingUri || isLoadingQuotes}
isError={isError} isError={isError}
sideBorders={false}
/> />
) )
} }
@@ -119,7 +118,6 @@ export function PostQuotes({uri}: {uri: string}) {
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Quotes`)} />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
@@ -133,6 +131,7 @@ export function PostQuotes({uri}: {uri: string}) {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
sideBorders={false}
/> />
) )
} }
+3 -9
View File
@@ -1,7 +1,5 @@
import React, {useCallback, useMemo, useState} from 'react' import React, {useCallback, useMemo, useState} from 'react'
import {AppBskyActorDefs as ActorDefs} from '@atproto/api' import {AppBskyActorDefs as ActorDefs} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -10,11 +8,7 @@ import {useResolveUriQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
import {List} from '#/view/com/util/List' import {List} from '#/view/com/util/List'
import { import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) {
return <ProfileCardWithFollowBtn key={item.did} profile={item} /> return <ProfileCardWithFollowBtn key={item.did} profile={item} />
@@ -25,7 +19,6 @@ function keyExtractor(item: ActorDefs.ProfileViewBasic) {
} }
export function PostRepostedBy({uri}: {uri: string}) { export function PostRepostedBy({uri}: {uri: string}) {
const {_} = useLingui()
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
const [isPTRing, setIsPTRing] = useState(false) const [isPTRing, setIsPTRing] = useState(false)
@@ -78,6 +71,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
<ListMaybePlaceholder <ListMaybePlaceholder
isLoading={isLoadingUri || isLoadingRepostedBy} isLoading={isLoadingUri || isLoadingRepostedBy}
isError={isError} isError={isError}
sideBorders={false}
/> />
) )
} }
@@ -93,7 +87,6 @@ export function PostRepostedBy({uri}: {uri: string}) {
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Reposted By`)} />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
@@ -105,6 +98,7 @@ export function PostRepostedBy({uri}: {uri: string}) {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
sideBorders={false}
/> />
) )
} }
+8 -6
View File
@@ -181,7 +181,7 @@ let PostThreadItemLoaded = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs() const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState( const [limitLines, setLimitLines] = React.useState(
@@ -388,7 +388,7 @@ let PostThreadItemLoaded = ({
type="lg" type="lg"
style={pal.textLight}> style={pal.textLight}>
<Text type="xl-bold" style={pal.text}> <Text type="xl-bold" style={pal.text}>
{formatCount(post.repostCount)} {formatCount(i18n, post.repostCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
value={post.repostCount} value={post.repostCount}
@@ -410,7 +410,7 @@ let PostThreadItemLoaded = ({
type="lg" type="lg"
style={pal.textLight}> style={pal.textLight}>
<Text type="xl-bold" style={pal.text}> <Text type="xl-bold" style={pal.text}>
{formatCount(post.quoteCount)} {formatCount(i18n, post.quoteCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
value={post.quoteCount} value={post.quoteCount}
@@ -430,7 +430,7 @@ let PostThreadItemLoaded = ({
type="lg" type="lg"
style={pal.textLight}> style={pal.textLight}>
<Text type="xl-bold" style={pal.text}> <Text type="xl-bold" style={pal.text}>
{formatCount(post.likeCount)} {formatCount(i18n, post.likeCount)}
</Text>{' '} </Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" /> <Plural value={post.likeCount} one="like" other="likes" />
</Text> </Text>
@@ -705,7 +705,7 @@ function ExpandedPostDetails({
translatorUrl: string translatorUrl: string
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_, i18n} = useLingui()
const openLink = useOpenLink() const openLink = useOpenLink()
const isRootPost = !('reply' in post.record) const isRootPost = !('reply' in post.record)
@@ -723,7 +723,9 @@ function ExpandedPostDetails({
s.mt2, s.mt2,
s.mb10, s.mb10,
]}> ]}>
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> <Text style={[a.text_sm, pal.textLight]}>
{niceDate(i18n, post.indexedAt)}
</Text>
{isRootPost && ( {isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> <WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
)} )}
+29 -14
View File
@@ -101,7 +101,7 @@ const feedInterstitialType = 'interstitialFeeds'
const followInterstitialType = 'interstitialFollows' const followInterstitialType = 'interstitialFollows'
const progressGuideInterstitialType = 'interstitialProgressGuide' const progressGuideInterstitialType = 'interstitialProgressGuide'
const interstials: Record< const interstials: Record<
'following' | 'discover', 'following' | 'discover' | 'profile',
(FeedItem & { (FeedItem & {
type: type:
| 'interstitialFeeds' | 'interstitialFeeds'
@@ -128,6 +128,16 @@ const interstials: Record<
slot: 20, slot: 20,
}, },
], ],
profile: [
{
type: followInterstitialType,
params: {
variant: 'default',
},
key: followInterstitialType,
slot: 5,
},
],
} }
export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null { export function getFeedPostSlice(feedItem: FeedItem): FeedPostSlice | null {
@@ -193,9 +203,7 @@ let Feed = ({
const [isPTRing, setIsPTRing] = React.useState(false) const [isPTRing, setIsPTRing] = React.useState(false)
const checkForNewRef = React.useRef<(() => void) | null>(null) const checkForNewRef = React.useRef<(() => void) | null>(null)
const lastFetchRef = React.useRef<number>(Date.now()) const lastFetchRef = React.useRef<number>(Date.now())
const [feedType, feedUri] = feed.split('|') const [feedType, feedUri, feedTab] = feed.split('|')
const feedIsDiscover = feedUri === DISCOVER_FEED_URI
const feedIsFollowing = feedType === 'following'
const gate = useGate() const gate = useGate()
const opts = React.useMemo( const opts = React.useMemo(
@@ -339,14 +347,21 @@ let Feed = ({
} }
if (hasSession) { if (hasSession) {
const feedType = feedIsFollowing let feedKind: 'following' | 'discover' | 'profile' | undefined
? 'following' if (feedType === 'following') {
: feedIsDiscover feedKind = 'following'
? 'discover' } else if (feedUri === DISCOVER_FEED_URI) {
: undefined feedKind = 'discover'
} else if (
feedType === 'author' &&
(feedTab === 'posts_and_author_threads' ||
feedTab === 'posts_with_replies')
) {
feedKind = 'profile'
}
if (feedType) { if (feedKind) {
for (const interstitial of interstials[feedType]) { for (const interstitial of interstials[feedKind]) {
const shouldShow = const shouldShow =
(interstitial.type === feedInterstitialType && (interstitial.type === feedInterstitialType &&
gate('suggested_feeds_interstitial')) || gate('suggested_feeds_interstitial')) ||
@@ -377,9 +392,9 @@ let Feed = ({
isEmpty, isEmpty,
lastFetchedAt, lastFetchedAt,
data, data,
feedType,
feedUri, feedUri,
feedIsDiscover, feedTab,
feedIsFollowing,
gate, gate,
hasSession, hasSession,
]) ])
@@ -470,7 +485,7 @@ let Feed = ({
} else if (item.type === feedInterstitialType) { } else if (item.type === feedInterstitialType) {
return <SuggestedFeeds /> return <SuggestedFeeds />
} else if (item.type === followInterstitialType) { } else if (item.type === followInterstitialType) {
return <SuggestedFollows /> return <SuggestedFollows feed={feed} />
} else if (item.type === progressGuideInterstitialType) { } else if (item.type === progressGuideInterstitialType) {
return <ProgressGuide /> return <ProgressGuide />
} else if (item.type === 'slice') { } else if (item.type === 'slice') {
+18 -8
View File
@@ -8,17 +8,26 @@ import {logger} from '#/logger'
import {useProfileFollowersQuery} from '#/state/queries/profile-followers' import {useProfileFollowersQuery} from '#/state/queries/profile-followers'
import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import { import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
import {List} from '../util/List' import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from './ProfileCard' import {ProfileCardWithFollowBtn} from './ProfileCard'
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { function renderItem({
return <ProfileCardWithFollowBtn key={item.did} profile={item} /> item,
index,
}: {
item: ActorDefs.ProfileViewBasic
index: number
}) {
return (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
noBorder={index === 0 && !isWeb}
/>
)
} }
function keyExtractor(item: ActorDefs.ProfileViewBasic) { function keyExtractor(item: ActorDefs.ProfileViewBasic) {
@@ -88,6 +97,7 @@ export function ProfileFollowers({name}: {name: string}) {
} }
errorMessage={cleanError(resolveError || error)} errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined} onRetry={isError ? refetch : undefined}
sideBorders={false}
/> />
) )
} }
@@ -101,7 +111,6 @@ export function ProfileFollowers({name}: {name: string}) {
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Followers`)} />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
@@ -113,6 +122,7 @@ export function ProfileFollowers({name}: {name: string}) {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
sideBorders={false}
/> />
) )
} }
+18 -8
View File
@@ -8,17 +8,26 @@ import {logger} from '#/logger'
import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
import {useResolveDidQuery} from '#/state/queries/resolve-uri' import {useResolveDidQuery} from '#/state/queries/resolve-uri'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {isWeb} from 'platform/detection'
import {useSession} from 'state/session' import {useSession} from 'state/session'
import { import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
ListFooter,
ListHeaderDesktop,
ListMaybePlaceholder,
} from '#/components/Lists'
import {List} from '../util/List' import {List} from '../util/List'
import {ProfileCardWithFollowBtn} from './ProfileCard' import {ProfileCardWithFollowBtn} from './ProfileCard'
function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { function renderItem({
return <ProfileCardWithFollowBtn key={item.did} profile={item} /> item,
index,
}: {
item: ActorDefs.ProfileViewBasic
index: number
}) {
return (
<ProfileCardWithFollowBtn
key={item.did}
profile={item}
noBorder={index === 0 && !isWeb}
/>
)
} }
function keyExtractor(item: ActorDefs.ProfileViewBasic) { function keyExtractor(item: ActorDefs.ProfileViewBasic) {
@@ -88,6 +97,7 @@ export function ProfileFollows({name}: {name: string}) {
} }
errorMessage={cleanError(resolveError || error)} errorMessage={cleanError(resolveError || error)}
onRetry={isError ? refetch : undefined} onRetry={isError ? refetch : undefined}
sideBorders={false}
/> />
) )
} }
@@ -101,7 +111,6 @@ export function ProfileFollows({name}: {name: string}) {
onRefresh={onRefresh} onRefresh={onRefresh}
onEndReached={onEndReached} onEndReached={onEndReached}
onEndReachedThreshold={4} onEndReachedThreshold={4}
ListHeaderComponent={<ListHeaderDesktop title={_(msg`Following`)} />}
ListFooterComponent={ ListFooterComponent={
<ListFooter <ListFooter
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
@@ -113,6 +122,7 @@ export function ProfileFollows({name}: {name: string}) {
desktopFixedHeight desktopFixedHeight
initialNumToRender={initialNumToRender} initialNumToRender={initialNumToRender}
windowSize={11} windowSize={11}
sideBorders={false}
/> />
) )
} }
+5 -2
View File
@@ -1,6 +1,7 @@
import React, {memo, useCallback} from 'react' import React, {memo, useCallback} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api' import {AppBskyActorDefs, ModerationDecision, ModerationUI} from '@atproto/api'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {precacheProfile} from '#/state/queries/profile' import {precacheProfile} from '#/state/queries/profile'
@@ -35,6 +36,8 @@ interface PostMetaOpts {
} }
let PostMeta = (opts: PostMetaOpts): React.ReactNode => { let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
const {i18n} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const displayName = opts.author.displayName || opts.author.handle const displayName = opts.author.displayName || opts.author.handle
const handle = opts.author.handle const handle = opts.author.handle
@@ -101,8 +104,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
type="md" type="md"
style={pal.textLight} style={pal.textLight}
text={timeElapsed} text={timeElapsed}
accessibilityLabel={niceDate(opts.timestamp)} accessibilityLabel={niceDate(i18n, opts.timestamp)}
title={niceDate(opts.timestamp)} title={niceDate(i18n, opts.timestamp)}
accessibilityHint="" accessibilityHint=""
href={opts.postHref} href={opts.postHref}
onBeforePress={onBeforePressPost} onBeforePress={onBeforePressPost}
+8 -4
View File
@@ -1,4 +1,6 @@
import React from 'react' import React from 'react'
import {I18n} from '@lingui/core'
import {useLingui} from '@lingui/react'
import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useTickEveryMinute} from '#/state/shell' import {useTickEveryMinute} from '#/state/shell'
@@ -10,19 +12,21 @@ export function TimeElapsed({
}: { }: {
timestamp: string timestamp: string
children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element children: ({timeElapsed}: {timeElapsed: string}) => JSX.Element
timeToString?: (timeElapsed: string) => string timeToString?: (i18n: I18n, timeElapsed: string) => string
}) { }) {
const {i18n} = useLingui()
const ago = useGetTimeAgo() const ago = useGetTimeAgo()
const format = timeToString ?? ago
const tick = useTickEveryMinute() const tick = useTickEveryMinute()
const [timeElapsed, setTimeAgo] = React.useState(() => const [timeElapsed, setTimeAgo] = React.useState(() =>
format(timestamp, tick), timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
) )
const [prevTick, setPrevTick] = React.useState(tick) const [prevTick, setPrevTick] = React.useState(tick)
if (prevTick !== tick) { if (prevTick !== tick) {
setPrevTick(tick) setPrevTick(tick)
setTimeAgo(format(timestamp, tick)) setTimeAgo(
timeToString ? timeToString(i18n, timestamp) : ago(timestamp, tick),
)
} }
return children({timeElapsed}) return children({timeElapsed})
+1 -1
View File
@@ -47,7 +47,7 @@ export const CenteredView = React.forwardRef(function CenteredView(
if (!isMobile) { if (!isMobile) {
style = addStyle(style, styles.container) style = addStyle(style, styles.container)
} }
if (sideBorders) { if (sideBorders && !isMobile) {
style = addStyle(style, { style = addStyle(style, {
borderLeftWidth: StyleSheet.hairlineWidth, borderLeftWidth: StyleSheet.hairlineWidth,
borderRightWidth: StyleSheet.hairlineWidth, borderRightWidth: StyleSheet.hairlineWidth,
+12 -16
View File
@@ -1,19 +1,18 @@
import React, {useState, useCallback} from 'react' import React, {useCallback, useState} from 'react'
import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, TextStyle, View, ViewStyle} from 'react-native'
import DatePicker from 'react-native-date-picker'
import { import {
FontAwesomeIcon, FontAwesomeIcon,
FontAwesomeIconStyle, FontAwesomeIconStyle,
} from '@fortawesome/react-native-fontawesome' } from '@fortawesome/react-native-fontawesome'
import {isIOS, isAndroid} from 'platform/detection' import {useLingui} from '@lingui/react'
import {Button, ButtonType} from './Button'
import {Text} from '../text/Text' import {usePalette} from 'lib/hooks/usePalette'
import {TypographyVariant} from 'lib/ThemeContext' import {TypographyVariant} from 'lib/ThemeContext'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {usePalette} from 'lib/hooks/usePalette' import {isAndroid, isIOS} from 'platform/detection'
import {getLocales} from 'expo-localization' import {Text} from '../text/Text'
import DatePicker from 'react-native-date-picker' import {Button, ButtonType} from './Button'
const LOCALE = getLocales()[0]
interface Props { interface Props {
testID?: string testID?: string
@@ -30,16 +29,11 @@ interface Props {
} }
export function DateInput(props: Props) { export function DateInput(props: Props) {
const {i18n} = useLingui()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const theme = useTheme() const theme = useTheme()
const pal = usePalette('default') const pal = usePalette('default')
const formatter = React.useMemo(() => {
return new Intl.DateTimeFormat(LOCALE.languageTag, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})
}, [props.handleAsUTC])
const onChangeInternal = useCallback( const onChangeInternal = useCallback(
(date: Date) => { (date: Date) => {
setShow(false) setShow(false)
@@ -74,7 +68,9 @@ export function DateInput(props: Props) {
<Text <Text
type={props.buttonLabelType} type={props.buttonLabelType}
style={[pal.text, props.buttonLabelStyle]}> style={[pal.text, props.buttonLabelStyle]}>
{formatter.format(props.value)} {i18n.date(props.value, {
timeZone: props.handleAsUTC ? 'UTC' : undefined,
})}
</Text> </Text>
</View> </View>
</Button> </Button>
+5 -12
View File
@@ -1,19 +1,12 @@
export const formatCount = (num: number) => import type {I18n} from '@lingui/core'
Intl.NumberFormat('en-US', {
export const formatCount = (i18n: I18n, num: number) => {
return i18n.number(num, {
notation: 'compact', notation: 'compact',
maximumFractionDigits: 1, maximumFractionDigits: 1,
// `1,953` shouldn't be rounded up to 2k, it should be truncated. // `1,953` shouldn't be rounded up to 2k, it should be truncated.
// @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet // @ts-expect-error: `roundingMode` doesn't seem to be in the typings yet
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#roundingmode
roundingMode: 'trunc', roundingMode: 'trunc',
}).format(num) })
export function formatCountShortOnly(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return String(num)
} }
+51 -31
View File
@@ -23,7 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing' import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers' import {toShareUrl} from '#/lib/strings/url-helpers'
import {s} from '#/lib/styles'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import { import {
@@ -36,14 +35,12 @@ import {
ProgressGuideAction, ProgressGuideAction,
useProgressGuideControls, useProgressGuideControls,
} from '#/state/shell/progress-guide' } from '#/state/shell/progress-guide'
import {CountWheel} from 'lib/custom-animations/CountWheel'
import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox' import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
import {
Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
} from '#/components/icons/Heart2'
import * as Prompt from '#/components/Prompt' import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn' import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format' import {formatCount} from '../numeric/format'
@@ -75,7 +72,7 @@ let PostCtrls = ({
threadgateRecord?: AppBskyFeedThreadgate.Record threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => { }): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext) const [queueLike, queueUnlike] = usePostLikeMutationQueue(post, logContext)
@@ -89,6 +86,11 @@ let PostCtrls = ({
const {captureAction} = useProgressGuideControls() const {captureAction} = useProgressGuideControls()
const playHaptic = useHaptics() const playHaptic = useHaptics()
const gate = useGate() const gate = useGate()
const isBlocked = Boolean(
post.author.viewer?.blocking ||
post.author.viewer?.blockedBy ||
post.author.viewer?.blockingByList,
)
const shouldShowLoggedOutWarning = React.useMemo(() => { const shouldShowLoggedOutWarning = React.useMemo(() => {
return ( return (
@@ -104,9 +106,21 @@ let PostCtrls = ({
[t], [t],
) as StyleProp<ViewStyle> ) as StyleProp<ViewStyle>
const likeValue = post.viewer?.like ? 1 : 0
const nextExpectedLikeValue = React.useRef(likeValue)
const onPressToggleLike = React.useCallback(async () => { const onPressToggleLike = React.useCallback(async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
try { try {
if (!post.viewer?.like) { if (!post.viewer?.like) {
nextExpectedLikeValue.current = 1
playHaptic() playHaptic()
sendInteraction({ sendInteraction({
item: post.uri, item: post.uri,
@@ -116,6 +130,7 @@ let PostCtrls = ({
captureAction(ProgressGuideAction.Like) captureAction(ProgressGuideAction.Like)
await queueLike() await queueLike()
} else { } else {
nextExpectedLikeValue.current = 0
await queueUnlike() await queueUnlike()
} }
} catch (e: any) { } catch (e: any) {
@@ -124,6 +139,7 @@ let PostCtrls = ({
} }
} }
}, [ }, [
_,
playHaptic, playHaptic,
post.uri, post.uri,
post.viewer?.like, post.viewer?.like,
@@ -132,9 +148,18 @@ let PostCtrls = ({
sendInteraction, sendInteraction,
captureAction, captureAction,
feedContext, feedContext,
isBlocked,
]) ])
const onRepost = useCallback(async () => { const onRepost = useCallback(async () => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
try { try {
if (!post.viewer?.repost) { if (!post.viewer?.repost) {
sendInteraction({ sendInteraction({
@@ -152,15 +177,25 @@ let PostCtrls = ({
} }
} }
}, [ }, [
_,
post.uri, post.uri,
post.viewer?.repost, post.viewer?.repost,
queueRepost, queueRepost,
queueUnrepost, queueUnrepost,
sendInteraction, sendInteraction,
feedContext, feedContext,
isBlocked,
]) ])
const onQuote = useCallback(() => { const onQuote = useCallback(() => {
if (isBlocked) {
Toast.show(
_(msg`Cannot interact with a blocked user`),
'exclamation-circle',
)
return
}
sendInteraction({ sendInteraction({
item: post.uri, item: post.uri,
event: 'app.bsky.feed.defs#interactionQuote', event: 'app.bsky.feed.defs#interactionQuote',
@@ -178,6 +213,7 @@ let PostCtrls = ({
onPost: onPostReply, onPost: onPostReply,
}) })
}, [ }, [
_,
sendInteraction, sendInteraction,
post.uri, post.uri,
post.cid, post.cid,
@@ -188,6 +224,7 @@ let PostCtrls = ({
openComposer, openComposer,
record.text, record.text,
onPostReply, onPostReply,
isBlocked,
]) ])
const onShare = useCallback(() => { const onShare = useCallback(() => {
@@ -207,8 +244,8 @@ let PostCtrls = ({
a.gap_xs, a.gap_xs,
a.rounded_full, a.rounded_full,
a.flex_row, a.flex_row,
a.align_center,
a.justify_center, a.justify_center,
a.align_center,
{padding: 5}, {padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25, (pressed || hovered) && t.atoms.bg_contrast_25,
], ],
@@ -247,7 +284,7 @@ let PostCtrls = ({
big ? a.text_md : {fontSize: 15}, big ? a.text_md : {fontSize: 15},
a.user_select_none, a.user_select_none,
]}> ]}>
{formatCount(post.replyCount)} {formatCount(i18n, post.replyCount)}
</Text> </Text>
) : undefined} ) : undefined}
</Pressable> </Pressable>
@@ -280,29 +317,12 @@ let PostCtrls = ({
} }
accessibilityHint="" accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}> hitSlop={POST_CTRL_HITSLOP}>
{post.viewer?.like ? ( <AnimatedLikeIcon isLiked={Boolean(post.viewer?.like)} big={big} />
<HeartIconFilled style={s.likeColor} width={big ? 22 : 18} /> <CountWheel
) : ( likeCount={post.likeCount ?? 0}
<HeartIconOutline big={big}
style={[defaultCtrlColor, {pointerEvents: 'none'}]} isLiked={Boolean(post.viewer?.like)}
width={big ? 22 : 18} />
/>
)}
{typeof post.likeCount !== 'undefined' && post.likeCount > 0 ? (
<Text
testID="likeCount"
style={[
[
big ? a.text_md : {fontSize: 15},
a.user_select_none,
post.viewer?.like
? [a.font_bold, s.likeColor]
: defaultCtrlColor,
],
]}>
{formatCount(post.likeCount)}
</Text>
) : undefined}
</Pressable> </Pressable>
</View> </View>
{big && ( {big && (
@@ -32,7 +32,7 @@ let RepostButton = ({
embeddingDisabled, embeddingDisabled,
}: Props): React.ReactNode => { }: Props): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_, i18n} = useLingui()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const dialogControl = Dialog.useDialogControl() const dialogControl = Dialog.useDialogControl()
const playHaptic = useHaptics() const playHaptic = useHaptics()
@@ -79,7 +79,7 @@ let RepostButton = ({
big ? a.text_md : {fontSize: 15}, big ? a.text_md : {fontSize: 15},
isReposted && a.font_bold, isReposted && a.font_bold,
]}> ]}>
{formatCount(repostCount)} {formatCount(i18n, repostCount)}
</Text> </Text>
) : undefined} ) : undefined}
</Button> </Button>
@@ -128,6 +128,7 @@ const RepostInner = ({
repostCount?: number repostCount?: number
big?: boolean big?: boolean
}) => { }) => {
const {i18n} = useLingui()
return ( return (
<View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}> <View style={[a.flex_row, a.align_center, a.gap_xs, {padding: 5}]}>
<Repost style={color} width={big ? 22 : 18} /> <Repost style={color} width={big ? 22 : 18} />
@@ -140,7 +141,7 @@ const RepostInner = ({
isReposted && [a.font_bold], isReposted && [a.font_bold],
a.user_select_none, a.user_select_none,
]}> ]}>
{formatCount(repostCount)} {formatCount(i18n, repostCount)}
</Text> </Text>
) : undefined} ) : undefined}
</View> </View>
+3 -5
View File
@@ -31,7 +31,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
) )
const gate = useGate() const gate = useGate()
if (!gate('videos')) { if (!gate('video_view_on_posts')) {
return null return null
} }
@@ -50,7 +50,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
a.rounded_sm, a.rounded_sm,
a.overflow_hidden, a.overflow_hidden,
{aspectRatio}, {aspectRatio},
{backgroundColor: t.palette.black}, {backgroundColor: 'black'},
a.my_xs, a.my_xs,
]}> ]}>
<ErrorBoundary renderError={renderError} key={key}> <ErrorBoundary renderError={renderError} key={key}>
@@ -78,9 +78,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
setActiveSource(embed.playlist) setActiveSource(embed.playlist)
}} }}
label={_(msg`Play video`)} label={_(msg`Play video`)}
variant="ghost" color="secondary">
color="secondary"
size="large">
<PlayIcon width={48} fill={t.palette.white} /> <PlayIcon width={48} fill={t.palette.white} />
</Button> </Button>
</> </>
@@ -9,13 +9,12 @@ import {
HLSUnsupportedError, HLSUnsupportedError,
VideoEmbedInnerWeb, VideoEmbedInnerWeb,
} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' } from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb'
import {atoms as a, useTheme} from '#/alf' import {atoms as a} from '#/alf'
import {ErrorBoundary} from '../ErrorBoundary' import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoWeb} from './ActiveVideoWebContext' import {useActiveVideoWeb} from './ActiveVideoWebContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
const t = useTheme()
const ref = useRef<HTMLDivElement>(null) const ref = useRef<HTMLDivElement>(null)
const gate = useGate() const gate = useGate()
const {active, setActive, sendPosition, currentActiveView} = const {active, setActive, sendPosition, currentActiveView} =
@@ -47,7 +46,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
[key], [key],
) )
if (!gate('videos')) { if (!gate('video_view_on_posts')) {
return null return null
} }
@@ -64,7 +63,7 @@ export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) {
style={[ style={[
a.w_full, a.w_full,
{aspectRatio}, {aspectRatio},
{backgroundColor: t.palette.black}, {backgroundColor: 'black'},
a.relative, a.relative,
a.rounded_sm, a.rounded_sm,
a.my_xs, a.my_xs,
@@ -29,9 +29,9 @@ export function TimeIndicator({time}: {time: number}) {
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 5, left: 6,
bottom: 5, bottom: 6,
minHeight: 20, minHeight: 21,
justifyContent: 'center', justifyContent: 'center',
}, },
]}> ]}>
@@ -167,17 +167,20 @@ function VideoControls({
/> />
<Animated.View <Animated.View
entering={FadeInDown.duration(300)} entering={FadeInDown.duration(300)}
style={{ style={[
backgroundColor: 'rgba(0, 0, 0, 0.5)', a.absolute,
borderRadius: 6, a.rounded_full,
paddingHorizontal: 6, a.justify_center,
paddingVertical: 3, {
position: 'absolute', backgroundColor: 'rgba(0, 0, 0, 0.5)',
bottom: 5, paddingHorizontal: 4,
right: 5, paddingVertical: 4,
minHeight: 20, bottom: 6,
justifyContent: 'center', right: 6,
}}> minHeight: 21,
minWidth: 21,
},
]}>
<Pressable <Pressable
onPress={toggleMuted} onPress={toggleMuted}
style={a.flex_1} style={a.flex_1}
@@ -186,9 +189,9 @@ function VideoControls({
accessibilityRole="button" accessibilityRole="button"
hitSlop={HITSLOP_30}> hitSlop={HITSLOP_30}>
{isMuted ? ( {isMuted ? (
<MuteIcon width={14} fill={t.palette.white} /> <MuteIcon width={13} fill={t.palette.white} />
) : ( ) : (
<UnmuteIcon width={14} fill={t.palette.white} /> <UnmuteIcon width={13} fill={t.palette.white} />
)} )}
</Pressable> </Pressable>
</Animated.View> </Animated.View>
@@ -253,7 +253,7 @@ export function Controls({
style={a.flex_1} style={a.flex_1}
onPress={onPressEmptySpace} onPress={onPressEmptySpace}
/> />
{active && !showControls && !focused && ( {active && !showControls && !focused && duration > 0 && (
<TimeIndicator time={Math.floor(duration - currentTime)} /> <TimeIndicator time={Math.floor(duration - currentTime)} />
)} )}
<View <View
@@ -475,21 +475,8 @@ function Scrubber({
if (isFirefox && scrubberActive) { if (isFirefox && scrubberActive) {
document.body.classList.add('force-no-clicks') document.body.classList.add('force-no-clicks')
const abortController = new AbortController()
const {signal} = abortController
document.documentElement.addEventListener(
'mouseleave',
() => {
isSeekingRef.current = false
onSeekEnd()
setScrubberActive(false)
},
{signal},
)
return () => { return () => {
document.body.classList.remove('force-no-clicks') document.body.classList.remove('force-no-clicks')
abortController.abort()
} }
} }
}, [scrubberActive, onSeekEnd]) }, [scrubberActive, onSeekEnd])
@@ -548,7 +535,8 @@ function Scrubber({
}} }}
onPointerDown={onPointerDown} onPointerDown={onPointerDown}
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerUp={onPointerUp}> onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}>
<View <View
style={[ style={[
a.w_full, a.w_full,
@@ -557,7 +545,7 @@ function Scrubber({
{backgroundColor: 'rgba(255, 255, 255, 0.4)'}, {backgroundColor: 'rgba(255, 255, 255, 0.4)'},
{height: hovered || scrubberActive ? 6 : 3}, {height: hovered || scrubberActive ? 6 : 3},
]}> ]}>
{currentTime > 0 && duration > 0 && ( {duration > 0 && (
<View <View
style={[ style={[
a.h_full, a.h_full,
+3 -8
View File
@@ -18,7 +18,6 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {CommonNavigatorParams} from '#/lib/routes/types' import {CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useLanguagePrefs} from '#/state/preferences'
import { import {
useAppPasswordDeleteMutation, useAppPasswordDeleteMutation,
useAppPasswordsQuery, useAppPasswordsQuery,
@@ -218,9 +217,8 @@ function AppPassword({
privileged?: boolean privileged?: boolean
}) { }) {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_, i18n} = useLingui()
const control = useDialogControl() const control = useDialogControl()
const {contentLanguages} = useLanguagePrefs()
const deleteMutation = useAppPasswordDeleteMutation() const deleteMutation = useAppPasswordDeleteMutation()
const onDelete = React.useCallback(async () => { const onDelete = React.useCallback(async () => {
@@ -232,9 +230,6 @@ function AppPassword({
control.open() control.open()
}, [control]) }, [control])
const primaryLocale =
contentLanguages.length > 0 ? contentLanguages[0] : 'en-US'
return ( return (
<TouchableOpacity <TouchableOpacity
testID={testID} testID={testID}
@@ -250,14 +245,14 @@ function AppPassword({
<Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}> <Text type="md" style={[pal.text, styles.pr10]} numberOfLines={1}>
<Trans> <Trans>
Created{' '} Created{' '}
{Intl.DateTimeFormat(primaryLocale, { {i18n.date(createdAt, {
year: 'numeric', year: 'numeric',
month: 'numeric', month: 'numeric',
day: 'numeric', day: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
}).format(new Date(createdAt))} })}
</Trans> </Trans>
</Text> </Text>
{privileged && ( {privileged && (
+15 -10
View File
@@ -1,12 +1,16 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useSetMinimalShellMode} from '#/state/shell'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {isWeb} from 'platform/detection'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a} from '#/alf'
import {ListHeaderDesktop} from '#/components/Lists'
import {ProfileFollowers as ProfileFollowersComponent} from '../com/profile/ProfileFollowers'
import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollowers'>
export const ProfileFollowersScreen = ({route}: Props) => { export const ProfileFollowersScreen = ({route}: Props) => {
@@ -21,9 +25,10 @@ export const ProfileFollowersScreen = ({route}: Props) => {
) )
return ( return (
<View style={{flex: 1}}> <CenteredView style={a.h_full_vh} sideBorders={true}>
<ViewHeader title={_(msg`Followers`)} /> <ListHeaderDesktop title={_(msg`Followers`)} />
<ViewHeader title={_(msg`Followers`)} showBorder={!isWeb} />
<ProfileFollowersComponent name={name} /> <ProfileFollowersComponent name={name} />
</View> </CenteredView>
) )
} }
+15 -10
View File
@@ -1,12 +1,16 @@
import React from 'react' import React from 'react'
import {View} from 'react-native'
import {useFocusEffect} from '@react-navigation/native'
import {NativeStackScreenProps, CommonNavigatorParams} from 'lib/routes/types'
import {ViewHeader} from '../com/util/ViewHeader'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLingui} from '@lingui/react'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useSetMinimalShellMode} from '#/state/shell'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {isWeb} from 'platform/detection'
import {CenteredView} from 'view/com/util/Views'
import {atoms as a} from '#/alf'
import {ListHeaderDesktop} from '#/components/Lists'
import {ProfileFollows as ProfileFollowsComponent} from '../com/profile/ProfileFollows'
import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFollows'>
export const ProfileFollowsScreen = ({route}: Props) => { export const ProfileFollowsScreen = ({route}: Props) => {
@@ -21,9 +25,10 @@ export const ProfileFollowsScreen = ({route}: Props) => {
) )
return ( return (
<View style={{flex: 1}}> <CenteredView style={a.h_full_vh} sideBorders={true}>
<ViewHeader title={_(msg`Following`)} /> <ListHeaderDesktop title={_(msg`Following`)} />
<ViewHeader title={_(msg`Following`)} showBorder={!isWeb} />
<ProfileFollowsComponent name={name} /> <ProfileFollowsComponent name={name} />
</View> </CenteredView>
) )
} }
+4 -4
View File
@@ -30,7 +30,7 @@ import {colors, s} from 'lib/styles'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {NavSignupCard} from '#/view/shell/NavSignupCard' import {NavSignupCard} from '#/view/shell/NavSignupCard'
import {formatCountShortOnly} from 'view/com/util/numeric/format' import {formatCount} from 'view/com/util/numeric/format'
import {Text} from 'view/com/util/text/Text' import {Text} from 'view/com/util/text/Text'
import {UserAvatar} from 'view/com/util/UserAvatar' import {UserAvatar} from 'view/com/util/UserAvatar'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
@@ -68,7 +68,7 @@ let DrawerProfileCard = ({
account: SessionAccount account: SessionAccount
onPressProfile: () => void onPressProfile: () => void
}): React.ReactNode => { }): React.ReactNode => {
const {_} = useLingui() const {_, i18n} = useLingui()
const pal = usePalette('default') const pal = usePalette('default')
const {data: profile} = useProfileQuery({did: account.did}) const {data: profile} = useProfileQuery({did: account.did})
@@ -108,7 +108,7 @@ let DrawerProfileCard = ({
<Text type="xl" style={pal.textLight}> <Text type="xl" style={pal.textLight}>
<Trans> <Trans>
<Text type="xl-medium" style={pal.text}> <Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followersCount ?? 0)} {formatCount(i18n, profile?.followersCount ?? 0)}
</Text>{' '} </Text>{' '}
<Plural <Plural
value={profile?.followersCount || 0} value={profile?.followersCount || 0}
@@ -123,7 +123,7 @@ let DrawerProfileCard = ({
<Text type="xl" style={pal.textLight}> <Text type="xl" style={pal.textLight}>
<Trans> <Trans>
<Text type="xl-medium" style={pal.text}> <Text type="xl-medium" style={pal.text}>
{formatCountShortOnly(profile?.followsCount ?? 0)} {formatCount(i18n, profile?.followsCount ?? 0)}
</Text>{' '} </Text>{' '}
<Plural <Plural
value={profile?.followsCount || 0} value={profile?.followsCount || 0}
+6 -6
View File
@@ -9918,14 +9918,14 @@ caniuse-api@^3.0.0:
lodash.uniq "^4.5.0" lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520: caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001517, caniuse-lite@^1.0.30001520:
version "1.0.30001596" version "1.0.30001655"
resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001596.tgz" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
integrity sha512-zpkZ+kEr6We7w63ORkoJ2pOfBwBkY/bJrG/UZ90qNb45Isblu8wzDgevEOrRL1r9dWayHjYiiyCMEXPn4DweGQ== integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
caniuse-lite@^1.0.30001587: caniuse-lite@^1.0.30001587:
version "1.0.30001620" version "1.0.30001655"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz#78bb6f35b8fe315b96b8590597094145d0b146b4" resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz"
integrity sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew== integrity sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==
case-anything@^2.1.13: case-anything@^2.1.13:
version "2.1.13" version "2.1.13"