Merge remote-tracking branch 'upstream/main' into Improve-notification-localization
This commit is contained in:
@@ -60,16 +60,13 @@ function CardOuter({
|
||||
export function SuggestedFollowPlaceholder() {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<CardOuter style={[a.gap_sm, t.atoms.border_contrast_low]}>
|
||||
<CardOuter style={[a.gap_md, t.atoms.border_contrast_low]}>
|
||||
<ProfileCard.Header>
|
||||
<ProfileCard.AvatarPlaceholder />
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</ProfileCard.Header>
|
||||
|
||||
<View style={[a.py_xs]}>
|
||||
<ProfileCard.NameAndHandlePlaceholder />
|
||||
</View>
|
||||
|
||||
<ProfileCard.DescriptionPlaceholder />
|
||||
<ProfileCard.DescriptionPlaceholder numberOfLines={2} />
|
||||
</CardOuter>
|
||||
)
|
||||
}
|
||||
@@ -176,9 +173,14 @@ function useExperimentalSuggestedUsersQuery() {
|
||||
}
|
||||
|
||||
export function SuggestedFollows({feed}: {feed: FeedDescriptor}) {
|
||||
const [feedType, feedUri] = feed.split('|')
|
||||
const {currentAccount} = useSession()
|
||||
const [feedType, feedUriOrDid] = feed.split('|')
|
||||
if (feedType === 'author') {
|
||||
return <SuggestedFollowsProfile did={feedUri} />
|
||||
if (currentAccount?.did === feedUriOrDid) {
|
||||
return null
|
||||
} else {
|
||||
return <SuggestedFollowsProfile did={feedUriOrDid} />
|
||||
}
|
||||
} else {
|
||||
return <SuggestedFollowsHome />
|
||||
}
|
||||
@@ -197,6 +199,7 @@ export function SuggestedFollowsProfile({did}: {did: string}) {
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={data?.suggestions ?? []}
|
||||
error={error}
|
||||
viewContext="profile"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -212,6 +215,7 @@ export function SuggestedFollowsHome() {
|
||||
isSuggestionsLoading={isSuggestionsLoading}
|
||||
profiles={profiles}
|
||||
error={error}
|
||||
viewContext="feed"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -220,10 +224,12 @@ export function ProfileGrid({
|
||||
isSuggestionsLoading,
|
||||
error,
|
||||
profiles,
|
||||
viewContext = 'feed',
|
||||
}: {
|
||||
isSuggestionsLoading: boolean
|
||||
profiles: AppBskyActorDefs.ProfileViewDetailed[]
|
||||
error: Error | null
|
||||
viewContext: 'profile' | 'feed'
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
@@ -280,7 +286,7 @@ export function ProfileGrid({
|
||||
shape="round"
|
||||
/>
|
||||
</ProfileCard.Header>
|
||||
<ProfileCard.Description profile={profile} />
|
||||
<ProfileCard.Description profile={profile} numberOfLines={2} />
|
||||
</ProfileCard.Outer>
|
||||
</CardOuter>
|
||||
)}
|
||||
@@ -297,33 +303,31 @@ export function ProfileGrid({
|
||||
return (
|
||||
<View
|
||||
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]}>
|
||||
<Text
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.text_lg,
|
||||
a.font_bold,
|
||||
t.atoms.text_contrast_medium,
|
||||
]}>
|
||||
<Trans>Suggested for you</Trans>
|
||||
<View
|
||||
style={[
|
||||
a.p_lg,
|
||||
a.pb_xs,
|
||||
a.flex_row,
|
||||
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>
|
||||
)}
|
||||
</Text>
|
||||
<Person fill={t.atoms.text_contrast_low.color} />
|
||||
<Person fill={t.atoms.text_contrast_low.color} size="sm" />
|
||||
</View>
|
||||
|
||||
{gtMobile ? (
|
||||
<View style={[a.flex_1, a.px_lg, a.pt_md, a.pb_xl, a.gap_md]}>
|
||||
<View style={[a.flex_1, a.flex_row, a.flex_wrap, 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_sm]}>
|
||||
{content}
|
||||
</View>
|
||||
|
||||
<View
|
||||
style={[
|
||||
a.flex_row,
|
||||
a.justify_end,
|
||||
a.align_center,
|
||||
a.pt_xs,
|
||||
a.gap_md,
|
||||
]}>
|
||||
<View style={[a.flex_row, a.justify_end, a.align_center, a.gap_md]}>
|
||||
<InlineLinkText
|
||||
label={_(msg`Browse more suggestions`)}
|
||||
to="/search"
|
||||
@@ -339,7 +343,7 @@ export function ProfileGrid({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
snapToInterval={MOBILE_CARD_WIDTH + a.gap_md.gap}
|
||||
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}
|
||||
|
||||
<Button
|
||||
|
||||
@@ -220,8 +220,10 @@ export function NameAndHandlePlaceholder() {
|
||||
|
||||
export function Description({
|
||||
profile: profileUnshadowed,
|
||||
numberOfLines = 3,
|
||||
}: {
|
||||
profile: AppBskyActorDefs.ProfileViewDetailed
|
||||
numberOfLines?: number
|
||||
}) {
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const {description} = profile
|
||||
@@ -244,31 +246,34 @@ export function Description({
|
||||
<RichText
|
||||
value={rt}
|
||||
style={[a.leading_snug]}
|
||||
numberOfLines={3}
|
||||
numberOfLines={numberOfLines}
|
||||
disableLinks
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptionPlaceholder() {
|
||||
export function DescriptionPlaceholder({
|
||||
numberOfLines = 3,
|
||||
}: {
|
||||
numberOfLines?: number
|
||||
}) {
|
||||
const t = useTheme()
|
||||
return (
|
||||
<View style={[a.gap_xs]}>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[a.rounded_xs, a.w_full, t.atoms.bg_contrast_50, {height: 12}]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: 100},
|
||||
]}
|
||||
/>
|
||||
<View style={[{gap: 8}]}>
|
||||
{Array(numberOfLines)
|
||||
.fill(0)
|
||||
.map((_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={[
|
||||
a.rounded_xs,
|
||||
a.w_full,
|
||||
t.atoms.bg_contrast_50,
|
||||
{height: 12, width: i + 1 === numberOfLines ? '60%' : '100%'},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -379,7 +379,11 @@ export class FeedTuner {
|
||||
): FeedViewPostsSlice[] => {
|
||||
for (let i = 0; i < slices.length; i++) {
|
||||
const slice = slices[i]
|
||||
if (slice.isReply && !shouldDisplayReplyInFollowing(slice, userDid)) {
|
||||
if (
|
||||
slice.isReply &&
|
||||
!slice.isRepost &&
|
||||
!shouldDisplayReplyInFollowing(slice.getAuthors(), userDid)
|
||||
) {
|
||||
slices.splice(i, 1)
|
||||
i--
|
||||
}
|
||||
@@ -443,13 +447,9 @@ function areSameAuthor(authors: AuthorContext): boolean {
|
||||
}
|
||||
|
||||
function shouldDisplayReplyInFollowing(
|
||||
slice: FeedViewPostsSlice,
|
||||
authors: AuthorContext,
|
||||
userDid: string,
|
||||
): boolean {
|
||||
if (slice.isRepost) {
|
||||
return true
|
||||
}
|
||||
const authors = slice.getAuthors()
|
||||
const {author, parentAuthor, grandparentAuthor, rootAuthor} = authors
|
||||
if (!isSelfOrFollowing(author, userDid)) {
|
||||
// Only show replies from self or people you follow.
|
||||
@@ -463,21 +463,6 @@ function shouldDisplayReplyInFollowing(
|
||||
// Always show self-threads.
|
||||
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.
|
||||
if (
|
||||
parentAuthor &&
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export type Gate =
|
||||
// Keep this alphabetic please.
|
||||
'debug_show_feedcontext' | 'suggested_feeds_interstitial' | 'video_upload' // upload videos
|
||||
'debug_show_feedcontext' | 'suggested_feeds_interstitial'
|
||||
|
||||
@@ -219,6 +219,8 @@ let ProfileHeaderStandard = ({
|
||||
<ButtonText>
|
||||
{profile.viewer?.following ? (
|
||||
<Trans>Following</Trans>
|
||||
) : profile.viewer?.followedBy ? (
|
||||
<Trans>Follow Back</Trans>
|
||||
) : (
|
||||
<Trans>Follow</Trans>
|
||||
)}
|
||||
|
||||
@@ -175,9 +175,19 @@ async function fetchSubjects(
|
||||
}> {
|
||||
const postUris = new Set<string>()
|
||||
const packUris = new Set<string>()
|
||||
|
||||
const postUrisWithLikes = new Set<string>()
|
||||
const postUrisWithReposts = new Set<string>()
|
||||
|
||||
for (const notif of groupedNotifs) {
|
||||
if (notif.subjectUri?.includes('app.bsky.feed.post')) {
|
||||
postUris.add(notif.subjectUri)
|
||||
if (notif.type === 'post-like') {
|
||||
postUrisWithLikes.add(notif.subjectUri)
|
||||
}
|
||||
if (notif.type === 'repost') {
|
||||
postUrisWithReposts.add(notif.subjectUri)
|
||||
}
|
||||
} else if (
|
||||
notif.notification.reasonSubject?.includes('app.bsky.graph.starterpack')
|
||||
) {
|
||||
@@ -206,6 +216,15 @@ async function fetchSubjects(
|
||||
AppBskyFeedPost.validateRecord(post.record).success
|
||||
) {
|
||||
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()) {
|
||||
|
||||
@@ -106,13 +106,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) {
|
||||
export function useSuggestedFollowsByActorQuery({did}: {did: string}) {
|
||||
const agent = useAgent()
|
||||
return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({
|
||||
gcTime: 0,
|
||||
queryKey: suggestedFollowsByActorQueryKey(did),
|
||||
queryFn: async () => {
|
||||
const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
|
||||
import {usePalette} from '#/lib/hooks/usePalette'
|
||||
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
||||
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 {insertMentionAt} from '#/lib/strings/mention-manip'
|
||||
import {shortenLinks} from '#/lib/strings/rich-text-manip'
|
||||
@@ -140,7 +140,6 @@ export const ComposePost = observer(function ComposePost({
|
||||
}: Props & {
|
||||
cancelRef?: React.RefObject<CancelRef>
|
||||
}) {
|
||||
const gate = useGate()
|
||||
const {currentAccount} = useSession()
|
||||
const agent = useAgent()
|
||||
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]}>
|
||||
<SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
{gate('video_upload') && (
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
<SelectVideoBtn
|
||||
onSelectVideo={selectVideo}
|
||||
disabled={!canSelectImages}
|
||||
setError={setError}
|
||||
/>
|
||||
<OpenCameraBtn gallery={gallery} disabled={!canSelectImages} />
|
||||
<SelectGifBtn
|
||||
onClose={focusTextInput}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {View} from 'react-native'
|
||||
import {msg, Trans} from '@lingui/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
|
||||
import {logger} from '#/logger'
|
||||
import * as Toast from '#/view/com/util/Toast'
|
||||
import {atoms as a} from '#/alf'
|
||||
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
|
||||
@@ -25,9 +26,16 @@ export function SubtitleFilePicker({
|
||||
const handlePick = (evt: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = evt.target.files?.[0]
|
||||
if (selectedFile) {
|
||||
if (selectedFile.type === 'text/vtt') {
|
||||
if (
|
||||
selectedFile.type === 'text/vtt' ||
|
||||
(selectedFile.type === 'text/plain' &&
|
||||
selectedFile.name.endsWith('.vtt'))
|
||||
) {
|
||||
onSelectFile(selectedFile)
|
||||
} else {
|
||||
logger.error('Invalid subtitle file type', {
|
||||
safeMessage: `File: ${selectedFile.name} (${selectedFile.type})`,
|
||||
})
|
||||
Toast.show(_(msg`Only WebVTT (.vtt) files are supported`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function FollowButton({
|
||||
label={_(msg({message: 'Unfollow', context: 'action'}))}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
} else if (!profile.viewer.followedBy) {
|
||||
return (
|
||||
<Button
|
||||
type={unfollowedType}
|
||||
@@ -70,5 +70,14 @@ export function FollowButton({
|
||||
label={_(msg({message: 'Follow', context: 'action'}))}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<Button
|
||||
type={unfollowedType}
|
||||
labelStyle={labelStyle}
|
||||
onPress={onPressFollow}
|
||||
label={_(msg({message: 'Follow Back', context: 'action'}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,10 @@ export function useImageAspectRatio({
|
||||
const [raw, setAspectRatio] = React.useState<number>(
|
||||
dimensions ? calc(dimensions) : 1,
|
||||
)
|
||||
// this basically controls the width of the image
|
||||
const {isCropped, constrained, max} = React.useMemo(() => {
|
||||
const a34 = 0.75 // max of 3:4 ratio in feeds
|
||||
const constrained = Math.max(raw, a34)
|
||||
const ratio = 1 / 2 // max of 1:2 ratio in feeds
|
||||
const constrained = Math.max(raw, ratio)
|
||||
const max = Math.max(raw, 0.25) // max of 1:4 in thread
|
||||
const isCropped = raw < constrained
|
||||
return {
|
||||
@@ -68,14 +69,14 @@ export function ConstrainedImage({
|
||||
const t = useTheme()
|
||||
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>(() => {
|
||||
// capped to square or shorter
|
||||
const ratio =
|
||||
isNative || !gtMobile
|
||||
? Math.min(1 / aspectRatio, 1.5)
|
||||
: Math.min(1 / aspectRatio, 1)
|
||||
? Math.min(1 / aspectRatio, 16 / 9) // 9:16 bounding box
|
||||
: Math.min(1 / aspectRatio, 1) // 1:1 bounding box
|
||||
return `${ratio * 100}%`
|
||||
}, [aspectRatio, gtMobile])
|
||||
|
||||
|
||||
@@ -37,11 +37,11 @@ export function TimeIndicator({time}: {time: number}) {
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
{color: t.palette.white, fontSize: 12},
|
||||
{color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']},
|
||||
a.font_bold,
|
||||
{lineHeight: 1.25},
|
||||
]}>
|
||||
{minutes}:{seconds}
|
||||
{`${minutes}:${seconds}`}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -370,7 +370,7 @@ export function Controls({
|
||||
onPress={onPressPlayPause}
|
||||
/>
|
||||
<View style={a.flex_1} />
|
||||
<Text style={{color: t.palette.white}}>
|
||||
<Text style={{color: t.palette.white, fontVariant: ['tabular-nums']}}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</Text>
|
||||
{hasSubtitleTrack && (
|
||||
|
||||
@@ -41,6 +41,7 @@ import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
|
||||
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
|
||||
import {navigate} from '#/Navigation'
|
||||
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
|
||||
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '../com/lists/ProfileLists'
|
||||
@@ -86,6 +87,16 @@ export function ProfileScreen({route}: Props) {
|
||||
}
|
||||
}, [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.
|
||||
React.useEffect(() => {
|
||||
if (resolvedDid && profile?.viewer?.blockedBy) {
|
||||
|
||||
Reference in New Issue
Block a user