Compare commits

...

15 Commits

Author SHA1 Message Date
Ducky 026f23ae2a Zio Bsky: v1.91.0-zio24257 2024-09-13 01:45:52 +01:00
Ducky 3c38517933 main → zio/dev 2024-09-13 01:44:24 +01:00
dan fddb1255ec
Enable video_upload gate (#5311) 2024-09-12 17:37:07 -07:00
Eric Bailey 47d99b8712
Adjust image sizing (#5302) 2024-09-12 22:35:59 +01:00
Eric Bailey d60a8f26c4
Suggested follows by actor (on profiles) updates (#5243)
* If fallback, return nothing

* Compress size a bit

* Hide on own profile

* Match load state

* Remove gcTime

* Filter out followed users

* Feedback
2024-09-12 22:34:10 +01:00
Paul Frazee 47bea32061
Add a hard-coded redirect for lulaoficial.bsky.social (#5303) 2024-09-12 11:09:58 -07:00
dan 897d8ba744
Revert "Show some known OP replies in Following (#5049)" (#5301)
This reverts commit bff6aedecf.
2024-09-12 18:45:19 +01:00
Wesley 86abeb80b9
Renaming the Follow button to "Follow back" when followed by user (#5281)
* Renaming the follow button to follow back when followed by user

* Fixing conditions and reusing existing translation
2024-09-12 15:59:12 +01:00
dan e0d9e75407
Fix notification scroll jump (#5297) 2024-09-12 15:39:04 +01:00
Minseo Lee 4d22adbcf6
Tabular numbers to video timestamp (#5293) 2024-09-12 15:30:57 +01:00
Samuel Newman 7da3ddbe24
[Video] speculative .vtt file selection fix (#5296)
* speculative vtt fix

* add logging
2024-09-12 15:16:38 +01:00
Eric Bailey ae71f5ce84
NUX API (#5278)
* Set up nux API

* Bump SDK

* Naming

* Imports
2024-09-11 19:56:00 -05:00
Eduardo Tachotte cff7cbb4aa
Add autoCapitalize to password field (#5216) 2024-09-12 00:28:23 +01:00
Gargaj 67f56af6d4
remove double closing tag (#5257) 2024-09-11 23:42:21 +01:00
Samuel Newman 8a6d83de3b
make container relative (#5280) 2024-09-11 23:04:40 +01:00
24 changed files with 331 additions and 112 deletions

View File

@ -372,7 +372,7 @@ function VideoEmbed({content}: {content: AppBskyEmbedVideo.View}) {
return ( return (
<div <div
className="w-full overflow-hidden rounded-lg aspect-square" className="w-full overflow-hidden rounded-lg aspect-square relative"
style={{aspectRatio: `${aspectRatio} / 1`}}> style={{aspectRatio: `${aspectRatio} / 1`}}>
<img <img
src={content.thumbnail} src={content.thumbnail}

View File

@ -259,7 +259,6 @@
pointer-events: none !important; pointer-events: none !important;
} }
</style> </style>
</style>
{% include "scripts.html" %} {% include "scripts.html" %}
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32x32.png">

View File

@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.91.0-zio24255", "version": "1.91.0-zio24257",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.13.5", "@atproto/api": "^0.13.7",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -60,16 +60,13 @@ function CardOuter({
export function SuggestedFollowPlaceholder() { export function SuggestedFollowPlaceholder() {
const t = useTheme() const t = useTheme()
return ( return (
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}> <CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
<ProfileCard.Header> <ProfileCard.Header>
<ProfileCard.AvatarPlaceholder /> <ProfileCard.AvatarPlaceholder />
<ProfileCard.NameAndHandlePlaceholder />
</ProfileCard.Header> </ProfileCard.Header>
<View style={[a.py_xs]}> <ProfileCard.DescriptionPlaceholder numberOfLines={2} />
<ProfileCard.NameAndHandlePlaceholder />
</View>
<ProfileCard.DescriptionPlaceholder />
</CardOuter> </CardOuter>
) )
} }
@ -176,9 +173,14 @@ function useExperimentalSuggestedUsersQuery() {
} }
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) { export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
const [feedType, feedUri] = feed.split('|') const {currentAccount} = useSession()
const [feedType, feedUriOrDid] = feed.split('|')
if (feedType === 'author') { if (feedType === 'author') {
return <SuggestedFollowsProfile did={feedUri} /> if (currentAccount?.did === feedUriOrDid) {
return null
} else {
return <SuggestedFollowsProfile did={feedUriOrDid} />
}
} else { } else {
return <SuggestedFollowsHome /> return <SuggestedFollowsHome />
} }
@ -197,6 +199,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isSuggestionsLoading}
profiles={data?.suggestions ?? []} profiles={data?.suggestions ?? []}
error={error} error={error}
viewContext="profile"
/> />
) )
} }
@ -212,6 +215,7 @@ export function SuggestedFollowsHome() {
isSuggestionsLoading={isSuggestionsLoading} isSuggestionsLoading={isSuggestionsLoading}
profiles={profiles} profiles={profiles}
error={error} error={error}
viewContext="feed"
/> />
) )
} }
@ -220,10 +224,12 @@ export function ProfileGrid({
isSuggestionsLoading, isSuggestionsLoading,
error, error,
profiles, profiles,
viewContext = 'feed',
}: { }: {
isSuggestionsLoading: boolean isSuggestionsLoading: boolean
profiles: AppBskyActorDefs.ProfileViewDetailed[] profiles: AppBskyActorDefs.ProfileViewDetailed[]
error: Error | null error: Error | null
viewContext: 'profile' | 'feed'
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@ -280,7 +286,7 @@ export function ProfileGrid({
shape="round" shape="round"
/> />
</ProfileCard.Header> </ProfileCard.Header>
<ProfileCard.Description profile={profile} /> <ProfileCard.Description profile={profile} numberOfLines={2} />
</ProfileCard.Outer> </ProfileCard.Outer>
</CardOuter> </CardOuter>
)} )}
@ -297,33 +303,31 @@ export function ProfileGrid({
return ( return (
<View <View
style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}> style={[a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25]}>
<View style={[a.pt_2xl, a.px_lg, a.flex_row, a.pb_xs]}> <View
<Text
style={[ style={[
a.flex_1, a.p_lg,
a.text_lg, a.pb_xs,
a.font_bold, a.flex_row,
t.atoms.text_contrast_medium, a.align_center,
a.justify_between,
]}> ]}>
<Text style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium]}>
{viewContext === 'profile' ? (
<Trans>Similar accounts</Trans>
) : (
<Trans>Suggested for you</Trans> <Trans>Suggested for you</Trans>
)}
</Text> </Text>
<Person fill={t.atoms.text_contrast_low.color} /> <Person fill={t.atoms.text_contrast_low.color} size="sm" />
</View> </View>
{gtMobile ? ( {gtMobile ? (
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}> <View style={[a.flex_1, a.px_lg, a.pt_sm, a.pb_lg, a.gap_md]}>
<View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_md]}> <View style={[a.flex_1, a.flex_row, a.flex_wrap, a.gap_sm]}>
{content} {content}
</View> </View>
<View <View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
style={[
a.flex_row,
a.justify_end,
a.align_center,
a.pt_xs,
a.gap_md,
]}>
<InlineLinkText <InlineLinkText
label={_(msg`Browse more suggestions`)} label={_(msg`Browse more suggestions`)}
to="/search" to="/search"
@ -339,7 +343,7 @@ export function ProfileGrid({
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap} snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
decelerationRate="fast"> decelerationRate="fast">
<View style={[a.px_lg, a.pt_md, a.pb_xl, a.flex_row, a.gap_md]}> <View style={[a.px_lg, a.pt_sm, a.pb_lg, a.flex_row, a.gap_md]}>
{content} {content}
<Button <Button

View File

@ -220,8 +220,10 @@ export function NameAndHandlePlaceholder() {
export function Description({ export function Description({
profile: profileUnshadowed, profile: profileUnshadowed,
numberOfLines = 3,
}: { }: {
profile: AppBskyActorDefs.ProfileViewDetailed profile: AppBskyActorDefs.ProfileViewDetailed
numberOfLines?: number
}) { }) {
const profile = useProfileShadow(profileUnshadowed) const profile = useProfileShadow(profileUnshadowed)
const {description} = profile const {description} = profile
@ -244,31 +246,34 @@ export function Description({
<RichText <RichText
value={rt} value={rt}
style={[a.leading_snug]} style={[a.leading_snug]}
numberOfLines={3} numberOfLines={numberOfLines}
disableLinks disableLinks
/> />
</View> </View>
) )
} }
export function DescriptionPlaceholder() { export function DescriptionPlaceholder({
numberOfLines = 3,
}: {
numberOfLines?: number
}) {
const t = useTheme() const t = useTheme()
return ( return (
<View style={[a.gap_xs]}> <View style={[{gap: 8}]}>
<View {Array(numberOfLines)
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]} .fill(0)
/> .map((_, i) => (
<View
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
/>
<View <View
key={i}
style={[ style={[
a.rounded_xs, a.rounded_xs,
a.w_full, a.w_full,
t.atoms.bg_contrast_50, t.atoms.bg_contrast_50,
{height: 12, width: 100}, {height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
]} ]}
/> />
))}
</View> </View>
) )
} }

View File

@ -379,7 +379,11 @@ 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 (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) { if (
slice.isReply &&
!slice.isRepost &&
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
) {
slices.splice(i, 1) slices.splice(i, 1)
i-- i--
} }
@ -443,13 +447,9 @@ function areSameAuthor(authors: AuthorContext): boolean {
} }
function shouldDisplayReplyInFollowing( function shouldDisplayReplyInFollowing(
slice: FeedViewPostsSlice, authors: AuthorContext,
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.
@ -463,21 +463,6 @@ 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 &&

View File

@ -1,3 +1,3 @@
export type Gate = export type Gate =
// Keep this alphabetic please. // Keep this alphabetic please.
'debug_show_feedcontext' | 'suggested_feeds_interstitial' | 'video_upload' // upload videos 'debug_show_feedcontext' | 'suggested_feeds_interstitial'

View File

@ -219,6 +219,8 @@ let ProfileHeaderStandard = ({
<ButtonText> <ButtonText>
{profile.viewer?.following ? ( {profile.viewer?.following ? (
<Trans>Following</Trans> <Trans>Following</Trans>
) : profile.viewer?.followedBy ? (
<Trans>Follow Back</Trans>
) : ( ) : (
<Trans>Follow</Trans> <Trans>Follow</Trans>
)} )}

View File

@ -172,6 +172,7 @@ export function StepInfo({
defaultValue={state.password} defaultValue={state.password}
secureTextEntry secureTextEntry
autoComplete="new-password" autoComplete="new-password"
autoCapitalize="none"
/> />
</TextField.Root> </TextField.Root>
</View> </View>

View File

@ -175,9 +175,19 @@ async function fetchSubjects(
}> { }> {
const postUris = new Set<string>() const postUris = new Set<string>()
const packUris = new Set<string>() const packUris = new Set<string>()
const postUrisWithLikes = new Set<string>()
const postUrisWithReposts = new Set<string>()
for (const notif of groupedNotifs) { for (const notif of groupedNotifs) {
if (notif.subjectUri?.includes('app.bsky.feed.post')) { if (notif.subjectUri?.includes('app.bsky.feed.post')) {
postUris.add(notif.subjectUri) postUris.add(notif.subjectUri)
if (notif.type === 'post-like') {
postUrisWithLikes.add(notif.subjectUri)
}
if (notif.type === 'repost') {
postUrisWithReposts.add(notif.subjectUri)
}
} else if ( } else if (
notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack') notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
) { ) {
@ -206,6 +216,15 @@ async function fetchSubjects(
AppBskyFeedPost.validateRecord(post.record).success AppBskyFeedPost.validateRecord(post.record).success
) { ) {
postsMap.set(post.uri, post) postsMap.set(post.uri, post)
// HACK. In some cases, the appview appears to lag behind and returns empty counters.
// To prevent scroll jump due to missing metrics, fill in 1 like/repost instead of 0.
if (post.likeCount === 0 && postUrisWithLikes.has(post.uri)) {
post.likeCount = 1
}
if (post.repostCount === 0 && postUrisWithReposts.has(post.uri)) {
post.repostCount = 1
}
} }
} }
for (const pack of packsChunks.flat()) { for (const pack of packsChunks.flat()) {

View File

@ -0,0 +1,29 @@
import zod from 'zod'
import {BaseNux} from '#/state/queries/nuxs/types'
export enum Nux {
One = 'one',
Two = 'two',
}
export const nuxNames = new Set(Object.values(Nux))
export type AppNux =
| BaseNux<{
id: Nux.One
data: {
likes: number
}
}>
| BaseNux<{
id: Nux.Two
data: undefined
}>
export const NuxSchemas = {
[Nux.One]: zod.object({
likes: zod.number(),
}),
[Nux.Two]: undefined,
}

View File

@ -0,0 +1,83 @@
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {AppNux, Nux} from '#/state/queries/nuxs/definitions'
import {parseAppNux, serializeAppNux} from '#/state/queries/nuxs/util'
import {
preferencesQueryKey,
usePreferencesQuery,
} from '#/state/queries/preferences'
import {useAgent} from '#/state/session'
export {Nux} from '#/state/queries/nuxs/definitions'
export function useNuxs() {
const {data, ...rest} = usePreferencesQuery()
if (data && rest.isSuccess) {
const nuxs = data.bskyAppState.nuxs
?.map(parseAppNux)
?.filter(Boolean) as AppNux[]
if (nuxs) {
return {
nuxs,
...rest,
}
}
}
return {
nuxs: undefined,
...rest,
}
}
export function useNux<T extends Nux>(id: T) {
const {nuxs, ...rest} = useNuxs()
if (nuxs && rest.isSuccess) {
const nux = nuxs.find(nux => nux.id === id)
if (nux) {
return {
nux: nux as Extract<AppNux, {id: T}>,
...rest,
}
}
}
return {
nux: undefined,
...rest,
}
}
export function useUpsertNuxMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async (nux: AppNux) => {
await agent.bskyAppUpsertNux(serializeAppNux(nux))
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useRemoveNuxsMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async (ids: string[]) => {
await agent.bskyAppRemoveNuxs(ids)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}

View File

@ -0,0 +1,9 @@
import {AppBskyActorDefs} from '@atproto/api'
export type Data = Record<string, unknown> | undefined
export type BaseNux<
T extends Pick<AppBskyActorDefs.Nux, 'id' | 'expiresAt'> & {data: Data},
> = T & {
completed: boolean
}

View File

@ -0,0 +1,52 @@
import {AppBskyActorDefs, nuxSchema} from '@atproto/api'
import {
AppNux,
Nux,
nuxNames,
NuxSchemas,
} from '#/state/queries/nuxs/definitions'
export function parseAppNux(nux: AppBskyActorDefs.Nux): AppNux | undefined {
if (!nuxNames.has(nux.id as Nux)) return
if (!nuxSchema.safeParse(nux).success) return
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
if (schema && data) {
const parsedData = JSON.parse(data)
if (!schema.safeParse(parsedData).success) return
return {
...rest,
data: parsedData,
} as AppNux
}
return {
...rest,
data: undefined,
} as AppNux
}
export function serializeAppNux(nux: AppNux): AppBskyActorDefs.Nux {
const {data, ...rest} = nux
const schema = NuxSchemas[nux.id as Nux]
const result: AppBskyActorDefs.Nux = {
...rest,
data: undefined,
}
if (schema) {
schema.parse(data)
result.data = JSON.stringify(data)
}
nuxSchema.parse(result)
return result
}

View File

@ -37,5 +37,6 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
bskyAppState: { bskyAppState: {
queuedNudges: [], queuedNudges: [],
activeProgressGuide: undefined, activeProgressGuide: undefined,
nuxs: [],
}, },
} }

View File

@ -106,13 +106,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
export function useSuggestedFollowsByActorQuery({did}: {did: string}) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
const agent = useAgent() const agent = useAgent()
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
gcTime: 0,
queryKey: suggestedFollowsByActorQueryKey(did), queryKey: suggestedFollowsByActorQueryKey(did),
queryFn: async () => { queryFn: async () => {
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
actor: did, actor: did,
}) })
return res.data const data = res.data.isFallback ? {suggestions: []} : res.data
data.suggestions = data.suggestions.filter(profile => {
return !profile.viewer?.following
})
return data
}, },
}) })
} }

View File

@ -59,7 +59,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {LikelyType} from '#/lib/link-meta/link-meta' import {LikelyType} from '#/lib/link-meta/link-meta'
import {logEvent, useGate} from '#/lib/statsig/statsig' import {logEvent} from '#/lib/statsig/statsig'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {insertMentionAt} from '#/lib/strings/mention-manip' import {insertMentionAt} from '#/lib/strings/mention-manip'
import {shortenLinks} from '#/lib/strings/rich-text-manip' import {shortenLinks} from '#/lib/strings/rich-text-manip'
@ -140,7 +140,6 @@ export const ComposePost = observer(function ComposePost({
}: Props & { }: Props & {
cancelRef?: React.RefObject<CancelRef> cancelRef?: React.RefObject<CancelRef>
}) { }) {
const gate = useGate()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const agent = useAgent() const agent = useAgent()
const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@ -803,13 +802,11 @@ 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('video_upload') && (
<SelectVideoBtn <SelectVideoBtn
onSelectVideo={selectVideo} onSelectVideo={selectVideo}
disabled={!canSelectImages} disabled={!canSelectImages}
setError={setError} setError={setError}
/> />
)}
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
<SelectGifBtn <SelectGifBtn
onClose={focusTextInput} onClose={focusTextInput}

View File

@ -3,6 +3,7 @@ import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@ -25,9 +26,16 @@ export function SubtitleFilePicker({
const handlePick = (evt: React.ChangeEvent<HTMLInputElement>) => { const handlePick = (evt: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = evt.target.files?.[0] const selectedFile = evt.target.files?.[0]
if (selectedFile) { if (selectedFile) {
if (selectedFile.type === 'text/vtt') { if (
selectedFile.type === 'text/vtt' ||
(selectedFile.type === 'text/plain' &&
selectedFile.name.endsWith('.vtt'))
) {
onSelectFile(selectedFile) onSelectFile(selectedFile)
} else { } else {
logger.error('Invalid subtitle file type', {
safeMessage: `File: ${selectedFile.name} (${selectedFile.type})`,
})
Toast.show(_(msg`Only WebVTT (.vtt) files are supported`)) Toast.show(_(msg`Only WebVTT (.vtt) files are supported`))
} }
} }

View File

@ -61,7 +61,7 @@ export function FollowButton({
label={_(msg({message: 'Unfollow', context: 'action'}))} label={_(msg({message: 'Unfollow', context: 'action'}))}
/> />
) )
} else { } else if (!profile.viewer.followedBy) {
return ( return (
<Button <Button
type={unfollowedType} type={unfollowedType}
@ -70,5 +70,14 @@ export function FollowButton({
label={_(msg({message: 'Follow', context: 'action'}))} label={_(msg({message: 'Follow', context: 'action'}))}
/> />
) )
} else {
return (
<Button
type={unfollowedType}
labelStyle={labelStyle}
onPress={onPressFollow}
label={_(msg({message: 'Follow Back', context: 'action'}))}
/>
)
} }
} }

View File

@ -23,9 +23,10 @@ export function useImageAspectRatio({
const [raw, setAspectRatio] = React.useState<number>( const [raw, setAspectRatio] = React.useState<number>(
dimensions ? calc(dimensions) : 1, dimensions ? calc(dimensions) : 1,
) )
// this basically controls the width of the image
const {isCropped, constrained, max} = React.useMemo(() => { const {isCropped, constrained, max} = React.useMemo(() => {
const a34 = 0.75 // max of 3:4 ratio in feeds const ratio = 1 / 2 // max of 1:2 ratio in feeds
const constrained = Math.max(raw, a34) const constrained = Math.max(raw, ratio)
const max = Math.max(raw, 0.25) // max of 1:4 in thread const max = Math.max(raw, 0.25) // max of 1:4 in thread
const isCropped = raw < constrained const isCropped = raw < constrained
return { return {
@ -68,14 +69,14 @@ export function ConstrainedImage({
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
/** /**
* Computed as a % value to apply as `paddingTop` * Computed as a % value to apply as `paddingTop`, this basically controls
* the height of the image.
*/ */
const outerAspectRatio = React.useMemo<DimensionValue>(() => { const outerAspectRatio = React.useMemo<DimensionValue>(() => {
// capped to square or shorter
const ratio = const ratio =
isNative || !gtMobile isNative || !gtMobile
? Math.min(1 / aspectRatio, 1.5) ? Math.min(1 / aspectRatio, 16 / 9) // 9:16 bounding box
: Math.min(1 / aspectRatio, 1) : Math.min(1 / aspectRatio, 1) // 1:1 bounding box
return `${ratio * 100}%` return `${ratio * 100}%`
}, [aspectRatio, gtMobile]) }, [aspectRatio, gtMobile])

View File

@ -37,11 +37,11 @@ export function TimeIndicator({time}: {time: number}) {
]}> ]}>
<Text <Text
style={[ style={[
{color: t.palette.white, fontSize: 12}, {color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']},
a.font_bold, a.font_bold,
{lineHeight: 1.25}, {lineHeight: 1.25},
]}> ]}>
{minutes}:{seconds} {`${minutes}:${seconds}`}
</Text> </Text>
</Animated.View> </Animated.View>
) )

View File

@ -370,7 +370,7 @@ export function Controls({
onPress={onPressPlayPause} onPress={onPressPlayPause}
/> />
<View style={a.flex_1} /> <View style={a.flex_1} />
<Text style={{color: t.palette.white}}> <Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</Text> </Text>
{hasSubtitleTrack && ( {hasSubtitleTrack && (

View File

@ -41,6 +41,7 @@ import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
import {ScreenHider} from '#/components/moderation/ScreenHider' import {ScreenHider} from '#/components/moderation/ScreenHider'
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks' import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
import {navigate} from '#/Navigation'
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder' import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
import {ProfileLists} from '../com/lists/ProfileLists' import {ProfileLists} from '../com/lists/ProfileLists'
@ -86,6 +87,16 @@ export function ProfileScreen({route}: Props) {
} }
}, [resolveError, refetchDid, refetchProfile]) }, [resolveError, refetchDid, refetchProfile])
// Apply hard-coded redirects as need
React.useEffect(() => {
if (resolveError) {
if (name === 'lulaoficial.bsky.social') {
console.log('Applying redirect to lula.com.br')
navigate('Profile', {name: 'lula.com.br'})
}
}
}, [name, resolveError])
// When we open the profile, we want to reset the posts query if we are blocked. // When we open the profile, we want to reset the posts query if we are blocked.
React.useEffect(() => { React.useEffect(() => {
if (resolvedDid && profile?.viewer?.blockedBy) { if (resolvedDid && profile?.viewer?.blockedBy) {

View File

@ -72,19 +72,6 @@
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
"@atproto/api@0.13.5":
version "0.13.5"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.5.tgz#04305cdb0a467ba366305c5e95cebb7ce0d39735"
integrity sha512-yT/YimcKYkrI0d282Zxo7O30OSYR+KDW89f81C6oYZfDRBcShC1aniVV8kluP5LrEAg8O27yrOSnBgx2v7XPew==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.1"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
"@atproto/api@^0.13.0": "@atproto/api@^0.13.0":
version "0.13.0" version "0.13.0"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
@ -98,6 +85,20 @@
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
"@atproto/api@^0.13.7":
version "0.13.7"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.7.tgz#072eba2025d5251505f17b0b5d2de33749ea5ee4"
integrity sha512-41kSLmFWDbuPOenb52WRq1lnBkSZrL+X29tWcvEt6SZXK4xBoKAalw1MjF+oabhzff12iMtNaNvmmt2fu1L+cw==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
"@atproto/syntax" "^0.3.0"
"@atproto/xrpc" "^0.6.2"
await-lock "^2.2.2"
multiformats "^9.9.0"
tlds "^1.234.0"
zod "^3.23.8"
"@atproto/aws@^0.2.2": "@atproto/aws@^0.2.2":
version "0.2.2" version "0.2.2"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653"
@ -443,10 +444,10 @@
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"
zod "^3.23.8" zod "^3.23.8"
"@atproto/xrpc@^0.6.1": "@atproto/xrpc@^0.6.2":
version "0.6.1" version "0.6.2"
resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.1.tgz#dcd1315c8c60eef5af2db7fa4e35a38ebc6d79d5" resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.2.tgz#634228a7e533de01bda2214837d11574fdadad55"
integrity sha512-Zy5ydXEdk6sY7FDUZcEVfCL1jvbL4tXu5CcdPqbEaW6LQtk9GLds/DK1bCX9kswTGaBC88EMuqQMfkxOhp2t4A== integrity sha512-as/gb08xJb02HAGNrSQSumCe10WnOAcnM6bR6KMatQyQJuEu7OY6ZDSTM/4HfjjoxsNqdvPmbYuoUab1bKTNlA==
dependencies: dependencies:
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"
zod "^3.23.8" zod "^3.23.8"