diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx index 63f61ce856..7b861dc660 100644 --- a/src/components/KnownFollowers.tsx +++ b/src/components/KnownFollowers.tsx @@ -100,7 +100,15 @@ function KnownFollowersInner({ moderation, } }) - const count = cachedKnownFollowers.count + + // Does not have blocks applied. Always >= slices.length + const serverCount = cachedKnownFollowers.count + + /* + * We check above too, but here for clarity and a reminder to _check for + * valid indices_ + */ + if (slice.length === 0) return null return ( - {count > 2 ? ( - - Followed by{' '} - - {slice[0].profile.displayName} - - ,{' '} - - {slice[1].profile.displayName} - - , and{' '} - - - ) : count === 2 ? ( + {slice.length >= 2 ? ( + // 2-n followers, including blocks + serverCount > 2 ? ( + + Followed by{' '} + + {slice[0].profile.displayName} + + ,{' '} + + {slice[1].profile.displayName} + + , and{' '} + + + ) : ( + // only 2 + + Followed by{' '} + + {slice[0].profile.displayName} + {' '} + and{' '} + + {slice[1].profile.displayName} + + + ) + ) : serverCount > 1 ? ( + // 1-n followers, including blocks Followed by{' '} {slice[0].profile.displayName} {' '} and{' '} - - {slice[1].profile.displayName} - + ) : ( + // only 1 Followed by{' '} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index 0b48b51d1d..ec7529a4ff 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -92,6 +92,8 @@ function PostLabel({ ) : ( diff --git a/src/components/moderation/PostHider.tsx b/src/components/moderation/PostHider.tsx index 8a64742978..b6fb174528 100644 --- a/src/components/moderation/PostHider.tsx +++ b/src/components/moderation/PostHider.tsx @@ -1,6 +1,6 @@ import React, {ComponentProps} from 'react' import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' -import {AppBskyActorDefs, ModerationUI} from '@atproto/api' +import {AppBskyActorDefs, ModerationCause, ModerationUI} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' @@ -45,7 +45,8 @@ export function PostHider({ const [override, setOverride] = React.useState(false) const control = useModerationDetailsDialogControl() const blur = - modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined) + modui.blurs[0] || + (interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined) const desc = useModerationCauseDescription(blur) const onBeforePress = React.useCallback(() => { @@ -134,6 +135,13 @@ export function PostHider({ ) } +function getBlurrableFilter(modui: ModerationUI): ModerationCause | undefined { + // moderation causes get "downgraded" when they originate from embedded content + // a downgraded cause should *only* drive filtering in feeds, so we want to look + // for filters that arent downgraded + return modui.filters.find(filter => !filter.downgraded) +} + const styles = StyleSheet.create({ child: { borderWidth: 0, diff --git a/src/lib/strings/embed-player.ts b/src/lib/strings/embed-player.ts index 30ced14921..44e42fae1c 100644 --- a/src/lib/strings/embed-player.ts +++ b/src/lib/strings/embed-player.ts @@ -1,7 +1,8 @@ -import {Dimensions, Platform} from 'react-native' +import {Dimensions} from 'react-native' import {isSafari} from 'lib/browser' import {isWeb} from 'platform/detection' + const {height: SCREEN_HEIGHT} = Dimensions.get('window') const IFRAME_HOST = isWeb @@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl( } } - if (urlp.hostname === 'media.tenor.com') { - let [_, id, filename] = urlp.pathname.split('/') + const tenorGif = parseTenorGif(urlp) + if (tenorGif.success) { + const {playerUri, dimensions} = tenorGif - const h = urlp.searchParams.get('hh') - const w = urlp.searchParams.get('ww') - let dimensions - if (h && w) { - dimensions = { - height: Number(h), - width: Number(w), - } - } - - if (id && filename && dimensions && id.includes('AAAAC')) { - if (Platform.OS === 'web') { - if (isSafari) { - id = id.replace('AAAAC', 'AAAP1') - filename = filename.replace('.gif', '.mp4') - } else { - id = id.replace('AAAAC', 'AAAP3') - filename = filename.replace('.gif', '.webm') - } - } else { - id = id.replace('AAAAC', 'AAAAM') - } - - return { - type: 'tenor_gif', - source: 'tenor', - isGif: true, - hideDetails: true, - playerUri: `https://t.gifs.bsky.app/${id}/${filename}`, - dimensions, - } + return { + type: 'tenor_gif', + source: 'tenor', + isGif: true, + hideDetails: true, + playerUri, + dimensions, } } @@ -516,3 +494,55 @@ export function getGiphyMetaUri(url: URL) { } } } + +export function parseTenorGif(urlp: URL): + | {success: false} + | { + success: true + playerUri: string + dimensions: {height: number; width: number} + } { + if (urlp.hostname !== 'media.tenor.com') { + return {success: false} + } + + let [_, id, filename] = urlp.pathname.split('/') + + if (!id || !filename) { + return {success: false} + } + + if (!id.includes('AAAAC')) { + return {success: false} + } + + const h = urlp.searchParams.get('hh') + const w = urlp.searchParams.get('ww') + + if (!h || !w) { + return {success: false} + } + + const dimensions = { + height: Number(h), + width: Number(w), + } + + if (isWeb) { + if (isSafari) { + id = id.replace('AAAAC', 'AAAP1') + filename = filename.replace('.gif', '.mp4') + } else { + id = id.replace('AAAAC', 'AAAP3') + filename = filename.replace('.gif', '.webm') + } + } else { + id = id.replace('AAAAC', 'AAAAM') + } + + return { + success: true, + playerUri: `https://t.gifs.bsky.app/${id}/${filename}`, + dimensions, + } +} diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts index facc680bd7..87700cb88e 100644 --- a/src/screens/Signup/state.ts +++ b/src/screens/Signup/state.ts @@ -252,7 +252,6 @@ export function useSubmitSignup({ dispatch({type: 'setIsLoading', value: true}) try { - onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view await createAccount({ service: state.serviceUrl, email: state.email, @@ -262,8 +261,12 @@ export function useSubmitSignup({ inviteCode: state.inviteCode.trim(), verificationCode: verificationCode, }) + /* + * Must happen last so that if the user has multiple tabs open and + * createAccount fails, one tab is not stuck in onboarding — Eric + */ + onboardingDispatch({type: 'start'}) } catch (e: any) { - onboardingDispatch({type: 'skip'}) // undo starting the onboard let errMsg = e.toString() if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { dispatch({ diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 2fb80de37d..4e44c1c695 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -78,6 +78,7 @@ export interface FeedPostSliceItem { feedContext: string | undefined moderation: ModerationDecision parentAuthor?: AppBskyActorDefs.ProfileViewBasic + isParentBlocked?: boolean } export interface FeedPostSlice { @@ -311,6 +312,10 @@ export function usePostFeedQuery( const parentAuthor = item.reply?.parent?.author ?? slice.items[i + 1]?.reply?.grandparentAuthor + const replyRef = item.reply + const isParentBlocked = AppBskyFeedDefs.isBlockedPost( + replyRef?.parent, + ) return { _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, @@ -324,6 +329,7 @@ export function usePostFeedQuery( feedContext: item.feedContext || slice.feedContext, moderation: moderations[i], parentAuthor, + isParentBlocked, } } return undefined diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts index 6afb3af88c..5a58937faa 100644 --- a/src/state/session/agent.ts +++ b/src/state/session/agent.ts @@ -38,21 +38,7 @@ export async function createAgentAndResume( } const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const moderation = configureModerationForAccount(agent, storedAccount) - const prevSession: AtpSessionData = { - // Sorted in the same property order as when returned by BskyAgent (alphabetical). - accessJwt: storedAccount.accessJwt ?? '', - did: storedAccount.did, - email: storedAccount.email, - emailAuthFactor: storedAccount.emailAuthFactor, - emailConfirmed: storedAccount.emailConfirmed, - handle: storedAccount.handle, - refreshJwt: storedAccount.refreshJwt ?? '', - /** - * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 - */ - active: storedAccount.active ?? true, - status: storedAccount.status, - } + const prevSession: AtpSessionData = sessionAccountToSession(storedAccount) if (isSessionExpired(storedAccount)) { await networkRetry(1, () => agent.resumeSession(prevSession)) } else { @@ -253,3 +239,23 @@ export function agentToSessionAccount( pdsUrl: agent.pdsUrl?.toString(), } } + +export function sessionAccountToSession( + account: SessionAccount, +): AtpSessionData { + return { + // Sorted in the same property order as when returned by BskyAgent (alphabetical). + accessJwt: account.accessJwt ?? '', + did: account.did, + email: account.email, + emailAuthFactor: account.emailAuthFactor, + emailConfirmed: account.emailConfirmed, + handle: account.handle, + refreshJwt: account.refreshJwt ?? '', + /** + * @see https://github.com/bluesky-social/atproto/blob/c5d36d5ba2a2c2a5c4f366a5621c06a5608e361e/packages/api/src/agent.ts#L188 + */ + active: account.active ?? true, + status: account.status, + } +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 371bd459ad..314945bcf9 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -14,6 +14,7 @@ import { createAgentAndCreateAccount, createAgentAndLogin, createAgentAndResume, + sessionAccountToSession, } from './agent' import {getInitialState, reducer} from './reducer' @@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { if (syncedAccount.did !== state.currentAgentState.did) { resumeSession(syncedAccount) } else { - // @ts-ignore we checked for `refreshJwt` above - state.currentAgentState.agent.session = syncedAccount + const agent = state.currentAgentState.agent as BskyAgent + agent.session = sessionAccountToSession(syncedAccount) } } }) diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx index 624c6e5837..fbbfcd53be 100644 --- a/src/view/com/notifications/FeedItem.tsx +++ b/src/view/com/notifications/FeedItem.tsx @@ -8,6 +8,7 @@ import { } from 'react-native' import { AppBskyActorDefs, + AppBskyEmbedExternal, AppBskyEmbedImages, AppBskyEmbedRecordWithMedia, AppBskyFeedDefs, @@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import hairlineWidth = StyleSheet.hairlineWidth +import {parseTenorGif} from '#/lib/strings/embed-player' import {StarterPackIcon} from '#/components/icons/StarterPackIcon' const MAX_AUTHORS = 5 @@ -487,17 +489,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) { const pal = usePalette('default') if (post && AppBskyFeedPost.isRecord(post?.record)) { const text = post.record.text - const images = AppBskyEmbedImages.isView(post.embed) - ? post.embed.images - : AppBskyEmbedRecordWithMedia.isView(post.embed) && - AppBskyEmbedImages.isView(post.embed.media) - ? post.embed.media.images - : undefined + let images + let isGif = false + + if (AppBskyEmbedImages.isView(post.embed)) { + images = post.embed.images + } else if ( + AppBskyEmbedRecordWithMedia.isView(post.embed) && + AppBskyEmbedImages.isView(post.embed.media) + ) { + images = post.embed.media.images + } else if ( + AppBskyEmbedExternal.isView(post.embed) && + post.embed.external.thumb + ) { + let url: URL | undefined + try { + url = new URL(post.embed.external.uri) + } catch {} + if (url) { + const {success} = parseTenorGif(url) + if (success) { + isGif = true + images = [ + { + thumb: post.embed.external.thumb, + alt: post.embed.external.title, + fullsize: post.embed.external.thumb, + }, + ] + } + } + } + return ( <> {text?.length > 0 && {text}} {images && images.length > 0 && ( - + )} ) diff --git a/src/view/com/pager/TabBar.tsx b/src/view/com/pager/TabBar.tsx index e940e8d1a9..1c83ecd6e2 100644 --- a/src/view/com/pager/TabBar.tsx +++ b/src/view/com/pager/TabBar.tsx @@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({ position: 'absolute', left: 0, right: 0, - bottom: -1, + top: '100%', borderBottomWidth: 1, }, }) @@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({ position: 'absolute', left: 0, right: 0, - bottom: -1, + top: '100%', borderBottomWidth: hairlineWidth, }, }) diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 8061eb11c9..a6c1a46487 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -331,7 +331,11 @@ export function PostThread({ - setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider) + setHiddenRepliesState( + item === SHOW_HIDDEN_REPLIES + ? HiddenRepliesState.Show + : HiddenRepliesState.ShowAndOverridePostHider, + ) } hideTopBorder={index === 0} /> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 675f23a88c..cc767a4a3d 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -56,6 +56,7 @@ interface FeedItemProps { isThreadParent?: boolean feedContext: string | undefined hideTopBorder?: boolean + isParentBlocked?: boolean } export function FeedItem({ @@ -70,6 +71,7 @@ export function FeedItem({ isThreadLastChild, isThreadParent, hideTopBorder, + isParentBlocked, }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { const postShadowed = usePostShadow(post) const richText = useMemo( @@ -100,6 +102,7 @@ export function FeedItem({ isThreadLastChild={isThreadLastChild} isThreadParent={isThreadParent} hideTopBorder={hideTopBorder} + isParentBlocked={isParentBlocked} /> ) } @@ -119,6 +122,7 @@ let FeedItemInner = ({ isThreadLastChild, isThreadParent, hideTopBorder, + isParentBlocked, }: FeedItemProps & { richText: RichTextAPI post: Shadow @@ -320,7 +324,7 @@ let FeedItemInner = ({ onOpenAuthor={onOpenAuthor} /> {!isThreadChild && showReplyTo && parentAuthor && ( - + )} - - Reply to{' '} - - - - + {blocked ? ( + Reply to a blocked post + ) : ( + + Reply to{' '} + + + + + )} ) diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index aeb24e8bbf..3e08f253cf 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -34,6 +34,7 @@ let FeedSlice = ({ isThreadParent={isThreadParentAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)} hideTopBorder={hideTopBorder} + isParentBlocked={slice.items[0].isParentBlocked} /> @@ -82,6 +85,7 @@ let FeedSlice = ({ isThreadLastChild={ isThreadChildAt(slice.items, i) && slice.items.length === i + 1 } + isParentBlocked={slice.items[i].isParentBlocked} hideTopBorder={hideTopBorder && i === 0} /> ))} diff --git a/src/view/com/util/UserAvatar.tsx b/src/view/com/util/UserAvatar.tsx index 587b466a3c..c212ea4c02 100644 --- a/src/view/com/util/UserAvatar.tsx +++ b/src/view/com/util/UserAvatar.tsx @@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler' interface BaseUserAvatarProps { type?: UserAvatarType + shape?: 'circle' | 'square' size: number avatar?: string | null } @@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100 let DefaultAvatar = ({ type, + shape: overrideShape, size, }: { type: UserAvatarType + shape?: 'square' | 'circle' size: number }): React.ReactNode => { + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') if (type === 'algo') { + // TODO: shape=circle // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. return ( - + {finalShape === 'square' ? ( + + ) : ( + + )} ) } + // TODO: shape=square return ( { const pal = usePalette('default') const backgroundColor = pal.colors.backgroundLight + const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square') const aviStyle = useMemo(() => { - if (type === 'algo' || type === 'list' || type === 'labeler') { + if (finalShape === 'square') { return { width: size, height: size, @@ -182,7 +195,7 @@ let UserAvatar = ({ borderRadius: Math.floor(size / 2), backgroundColor, } - }, [type, size, backgroundColor]) + }, [finalShape, size, backgroundColor]) const alert = useMemo(() => { if (!moderation?.alert) { @@ -224,7 +237,7 @@ let UserAvatar = ({ ) : ( - + {alert} ) diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx index 12eef14f73..bade2a4446 100644 --- a/src/view/com/util/images/ImageHorzList.tsx +++ b/src/view/com/util/images/ImageHorzList.tsx @@ -2,39 +2,60 @@ import React from 'react' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {Image} from 'expo-image' import {AppBskyEmbedImages} from '@atproto/api' +import {Trans} from '@lingui/macro' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' interface Props { images: AppBskyEmbedImages.ViewImage[] style?: StyleProp + gif?: boolean } -export function ImageHorzList({images, style}: Props) { +export function ImageHorzList({images, style, gif}: Props) { return ( - + {images.map(({thumb, alt}) => ( - + style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}> + + {gif && ( + + + GIF + + + )} + ))} ) } const styles = StyleSheet.create({ - flexRow: { - flexDirection: 'row', - gap: 5, + altContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + right: 5, + bottom: 5, + zIndex: 2, }, - image: { - maxWidth: 100, - aspectRatio: 1, - flex: 1, - borderRadius: 4, + alt: { + color: 'white', + fontSize: 7, + fontWeight: 'bold', }, })