Merge branch 'main' into starter-packs

This commit is contained in:
Hailey
2024-06-18 18:26:59 -07:00
15 changed files with 307 additions and 132 deletions
+49 -18
View File
@@ -100,7 +100,15 @@ function KnownFollowersInner({
moderation, 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 ( return (
<Link <Link
@@ -164,31 +172,54 @@ function KnownFollowersInner({
}, },
]} ]}
numberOfLines={2}> numberOfLines={2}>
{count > 2 ? ( {slice.length >= 2 ? (
<Trans> // 2-n followers, including blocks
Followed by{' '} serverCount > 2 ? (
<Text key={slice[0].profile.did} style={textStyle}> <Trans>
{slice[0].profile.displayName} Followed by{' '}
</Text> <Text key={slice[0].profile.did} style={textStyle}>
,{' '} {slice[0].profile.displayName}
<Text key={slice[1].profile.did} style={textStyle}> </Text>
{slice[1].profile.displayName} ,{' '}
</Text> <Text key={slice[1].profile.did} style={textStyle}>
, and{' '} {slice[1].profile.displayName}
<Plural value={count - 2} one="# other" other="# others" /> </Text>
</Trans> , and{' '}
) : count === 2 ? ( <Plural
value={serverCount - 2}
one="# other"
other="# others"
/>
</Trans>
) : (
// only 2
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
)
) : serverCount > 1 ? (
// 1-n followers, including blocks
<Trans> <Trans>
Followed by{' '} Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}> <Text key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName} {slice[0].profile.displayName}
</Text>{' '} </Text>{' '}
and{' '} and{' '}
<Text key={slice[1].profile.did} style={textStyle}> <Plural
{slice[1].profile.displayName} value={serverCount - 1}
</Text> one="# other"
other="# others"
/>
</Trans> </Trans>
) : ( ) : (
// only 1
<Trans> <Trans>
Followed by{' '} Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}> <Text key={slice[0].profile.did} style={textStyle}>
+2
View File
@@ -92,6 +92,8 @@ function PostLabel({
<UserAvatar <UserAvatar
avatar={desc.sourceAvi} avatar={desc.sourceAvi}
size={size === 'large' ? 16 : 12} size={size === 'large' ? 16 : 12}
type="labeler"
shape="circle"
/> />
) : ( ) : (
<desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} /> <desc.icon size="sm" fill={t.atoms.text_contrast_medium.color} />
+10 -2
View File
@@ -1,6 +1,6 @@
import React, {ComponentProps} from 'react' import React, {ComponentProps} from 'react'
import {Pressable, StyleProp, StyleSheet, View, ViewStyle} from 'react-native' 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 {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
@@ -45,7 +45,8 @@ export function PostHider({
const [override, setOverride] = React.useState(false) const [override, setOverride] = React.useState(false)
const control = useModerationDetailsDialogControl() const control = useModerationDetailsDialogControl()
const blur = const blur =
modui.blurs[0] || (interpretFilterAsBlur ? modui.filters[0] : undefined) modui.blurs[0] ||
(interpretFilterAsBlur ? getBlurrableFilter(modui) : undefined)
const desc = useModerationCauseDescription(blur) const desc = useModerationCauseDescription(blur)
const onBeforePress = React.useCallback(() => { 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({ const styles = StyleSheet.create({
child: { child: {
borderWidth: 0, borderWidth: 0,
+64 -34
View File
@@ -1,7 +1,8 @@
import {Dimensions, Platform} from 'react-native' import {Dimensions} from 'react-native'
import {isSafari} from 'lib/browser' import {isSafari} from 'lib/browser'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
const {height: SCREEN_HEIGHT} = Dimensions.get('window') const {height: SCREEN_HEIGHT} = Dimensions.get('window')
const IFRAME_HOST = isWeb const IFRAME_HOST = isWeb
@@ -342,40 +343,17 @@ export function parseEmbedPlayerFromUrl(
} }
} }
if (urlp.hostname === 'media.tenor.com') { const tenorGif = parseTenorGif(urlp)
let [_, id, filename] = urlp.pathname.split('/') if (tenorGif.success) {
const {playerUri, dimensions} = tenorGif
const h = urlp.searchParams.get('hh') return {
const w = urlp.searchParams.get('ww') type: 'tenor_gif',
let dimensions source: 'tenor',
if (h && w) { isGif: true,
dimensions = { hideDetails: true,
height: Number(h), playerUri,
width: Number(w), dimensions,
}
}
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,
}
} }
} }
@@ -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,
}
}
+5 -2
View File
@@ -252,7 +252,6 @@ export function useSubmitSignup({
dispatch({type: 'setIsLoading', value: true}) dispatch({type: 'setIsLoading', value: true})
try { try {
onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
await createAccount({ await createAccount({
service: state.serviceUrl, service: state.serviceUrl,
email: state.email, email: state.email,
@@ -262,8 +261,12 @@ export function useSubmitSignup({
inviteCode: state.inviteCode.trim(), inviteCode: state.inviteCode.trim(),
verificationCode: verificationCode, 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) { } catch (e: any) {
onboardingDispatch({type: 'skip'}) // undo starting the onboard
let errMsg = e.toString() let errMsg = e.toString()
if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) { if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
dispatch({ dispatch({
+6
View File
@@ -78,6 +78,7 @@ export interface FeedPostSliceItem {
feedContext: string | undefined feedContext: string | undefined
moderation: ModerationDecision moderation: ModerationDecision
parentAuthor?: AppBskyActorDefs.ProfileViewBasic parentAuthor?: AppBskyActorDefs.ProfileViewBasic
isParentBlocked?: boolean
} }
export interface FeedPostSlice { export interface FeedPostSlice {
@@ -311,6 +312,10 @@ export function usePostFeedQuery(
const parentAuthor = const parentAuthor =
item.reply?.parent?.author ?? item.reply?.parent?.author ??
slice.items[i + 1]?.reply?.grandparentAuthor slice.items[i + 1]?.reply?.grandparentAuthor
const replyRef = item.reply
const isParentBlocked = AppBskyFeedDefs.isBlockedPost(
replyRef?.parent,
)
return { return {
_reactKey: `${slice._reactKey}-${i}-${item.post.uri}`, _reactKey: `${slice._reactKey}-${i}-${item.post.uri}`,
@@ -324,6 +329,7 @@ export function usePostFeedQuery(
feedContext: item.feedContext || slice.feedContext, feedContext: item.feedContext || slice.feedContext,
moderation: moderations[i], moderation: moderations[i],
parentAuthor, parentAuthor,
isParentBlocked,
} }
} }
return undefined return undefined
+21 -15
View File
@@ -38,21 +38,7 @@ export async function createAgentAndResume(
} }
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency') const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const moderation = configureModerationForAccount(agent, storedAccount) const moderation = configureModerationForAccount(agent, storedAccount)
const prevSession: AtpSessionData = { const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
// 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,
}
if (isSessionExpired(storedAccount)) { if (isSessionExpired(storedAccount)) {
await networkRetry(1, () => agent.resumeSession(prevSession)) await networkRetry(1, () => agent.resumeSession(prevSession))
} else { } else {
@@ -253,3 +239,23 @@ export function agentToSessionAccount(
pdsUrl: agent.pdsUrl?.toString(), 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,
}
}
+3 -2
View File
@@ -14,6 +14,7 @@ import {
createAgentAndCreateAccount, createAgentAndCreateAccount,
createAgentAndLogin, createAgentAndLogin,
createAgentAndResume, createAgentAndResume,
sessionAccountToSession,
} from './agent' } from './agent'
import {getInitialState, reducer} from './reducer' import {getInitialState, reducer} from './reducer'
@@ -175,8 +176,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (syncedAccount.did !== state.currentAgentState.did) { if (syncedAccount.did !== state.currentAgentState.did) {
resumeSession(syncedAccount) resumeSession(syncedAccount)
} else { } else {
// @ts-ignore we checked for `refreshJwt` above const agent = state.currentAgentState.agent as BskyAgent
state.currentAgentState.agent.session = syncedAccount agent.session = sessionAccountToSession(syncedAccount)
} }
} }
}) })
+40 -7
View File
@@ -8,6 +8,7 @@ import {
} from 'react-native' } from 'react-native'
import { import {
AppBskyActorDefs, AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedDefs, AppBskyFeedDefs,
@@ -51,6 +52,7 @@ import {TimeElapsed} from '../util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '../util/UserAvatar'
import hairlineWidth = StyleSheet.hairlineWidth import hairlineWidth = StyleSheet.hairlineWidth
import {parseTenorGif} from '#/lib/strings/embed-player'
import {StarterPackIcon} from '#/components/icons/StarterPackIcon' import {StarterPackIcon} from '#/components/icons/StarterPackIcon'
const MAX_AUTHORS = 5 const MAX_AUTHORS = 5
@@ -487,17 +489,48 @@ function AdditionalPostText({post}: {post?: AppBskyFeedDefs.PostView}) {
const pal = usePalette('default') const pal = usePalette('default')
if (post && AppBskyFeedPost.isRecord(post?.record)) { if (post && AppBskyFeedPost.isRecord(post?.record)) {
const text = post.record.text const text = post.record.text
const images = AppBskyEmbedImages.isView(post.embed) let images
? post.embed.images let isGif = false
: AppBskyEmbedRecordWithMedia.isView(post.embed) &&
AppBskyEmbedImages.isView(post.embed.media) if (AppBskyEmbedImages.isView(post.embed)) {
? post.embed.media.images images = post.embed.images
: undefined } 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 ( return (
<> <>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
{images && images.length > 0 && ( {images && images.length > 0 && (
<ImageHorzList images={images} style={styles.additionalPostImages} /> <ImageHorzList
images={images}
style={styles.additionalPostImages}
gif={isGif}
/>
)} )}
</> </>
) )
+2 -2
View File
@@ -180,7 +180,7 @@ const desktopStyles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
bottom: -1, top: '100%',
borderBottomWidth: 1, borderBottomWidth: 1,
}, },
}) })
@@ -207,7 +207,7 @@ const mobileStyles = StyleSheet.create({
position: 'absolute', position: 'absolute',
left: 0, left: 0,
right: 0, right: 0,
bottom: -1, top: '100%',
borderBottomWidth: hairlineWidth, borderBottomWidth: hairlineWidth,
}, },
}) })
+5 -1
View File
@@ -331,7 +331,11 @@ export function PostThread({
<PostThreadShowHiddenReplies <PostThreadShowHiddenReplies
type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
onPress={() => onPress={() =>
setHiddenRepliesState(HiddenRepliesState.ShowAndOverridePostHider) setHiddenRepliesState(
item === SHOW_HIDDEN_REPLIES
? HiddenRepliesState.Show
: HiddenRepliesState.ShowAndOverridePostHider,
)
} }
hideTopBorder={index === 0} hideTopBorder={index === 0}
/> />
+33 -20
View File
@@ -56,6 +56,7 @@ interface FeedItemProps {
isThreadParent?: boolean isThreadParent?: boolean
feedContext: string | undefined feedContext: string | undefined
hideTopBorder?: boolean hideTopBorder?: boolean
isParentBlocked?: boolean
} }
export function FeedItem({ export function FeedItem({
@@ -70,6 +71,7 @@ export function FeedItem({
isThreadLastChild, isThreadLastChild,
isThreadParent, isThreadParent,
hideTopBorder, hideTopBorder,
isParentBlocked,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode { }: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
@@ -100,6 +102,7 @@ export function FeedItem({
isThreadLastChild={isThreadLastChild} isThreadLastChild={isThreadLastChild}
isThreadParent={isThreadParent} isThreadParent={isThreadParent}
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
/> />
) )
} }
@@ -119,6 +122,7 @@ let FeedItemInner = ({
isThreadLastChild, isThreadLastChild,
isThreadParent, isThreadParent,
hideTopBorder, hideTopBorder,
isParentBlocked,
}: FeedItemProps & { }: FeedItemProps & {
richText: RichTextAPI richText: RichTextAPI
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
@@ -320,7 +324,7 @@ let FeedItemInner = ({
onOpenAuthor={onOpenAuthor} onOpenAuthor={onOpenAuthor}
/> />
{!isThreadChild && showReplyTo && parentAuthor && ( {!isThreadChild && showReplyTo && parentAuthor && (
<ReplyToLabel profile={parentAuthor} /> <ReplyToLabel blocked={isParentBlocked} profile={parentAuthor} />
)} )}
<LabelsOnMyPost post={post} /> <LabelsOnMyPost post={post} />
<PostContent <PostContent
@@ -409,9 +413,14 @@ let PostContent = ({
} }
PostContent = memo(PostContent) PostContent = memo(PostContent)
function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) { function ReplyToLabel({
profile,
blocked,
}: {
profile: AppBskyActorDefs.ProfileViewBasic
blocked?: boolean
}) {
const pal = usePalette('default') const pal = usePalette('default')
return ( return (
<View style={[s.flexRow, s.mb2, s.alignCenter]}> <View style={[s.flexRow, s.mb2, s.alignCenter]}>
<FontAwesomeIcon <FontAwesomeIcon
@@ -424,23 +433,27 @@ function ReplyToLabel({profile}: {profile: AppBskyActorDefs.ProfileViewBasic}) {
style={[pal.textLight, s.mr2]} style={[pal.textLight, s.mr2]}
lineHeight={1.2} lineHeight={1.2}
numberOfLines={1}> numberOfLines={1}>
<Trans context="description"> {blocked ? (
Reply to{' '} <Trans context="description">Reply to a blocked post</Trans>
<ProfileHoverCard inline did={profile.did}> ) : (
<TextLinkOnWebOnly <Trans context="description">
type="md" Reply to{' '}
style={pal.textLight} <ProfileHoverCard inline did={profile.did}>
lineHeight={1.2} <TextLinkOnWebOnly
numberOfLines={1} type="md"
href={makeProfileLink(profile)} style={pal.textLight}
text={ lineHeight={1.2}
profile.displayName numberOfLines={1}
? sanitizeDisplayName(profile.displayName) href={makeProfileLink(profile)}
: sanitizeHandle(profile.handle) text={
} profile.displayName
/> ? sanitizeDisplayName(profile.displayName)
</ProfileHoverCard> : sanitizeHandle(profile.handle)
</Trans> }
/>
</ProfileHoverCard>
</Trans>
)}
</Text> </Text>
</View> </View>
) )
+4
View File
@@ -34,6 +34,7 @@ let FeedSlice = ({
isThreadParent={isThreadParentAt(slice.items, 0)} isThreadParent={isThreadParentAt(slice.items, 0)}
isThreadChild={isThreadChildAt(slice.items, 0)} isThreadChild={isThreadChildAt(slice.items, 0)}
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
/> />
<FeedItem <FeedItem
key={slice.items[1]._reactKey} key={slice.items[1]._reactKey}
@@ -46,6 +47,7 @@ let FeedSlice = ({
moderation={slice.items[1].moderation} moderation={slice.items[1].moderation}
isThreadParent={isThreadParentAt(slice.items, 1)} isThreadParent={isThreadParentAt(slice.items, 1)}
isThreadChild={isThreadChildAt(slice.items, 1)} isThreadChild={isThreadChildAt(slice.items, 1)}
isParentBlocked={slice.items[1].isParentBlocked}
/> />
<ViewFullThread slice={slice} /> <ViewFullThread slice={slice} />
<FeedItem <FeedItem
@@ -59,6 +61,7 @@ let FeedSlice = ({
moderation={slice.items[last].moderation} moderation={slice.items[last].moderation}
isThreadParent={isThreadParentAt(slice.items, last)} isThreadParent={isThreadParentAt(slice.items, last)}
isThreadChild={isThreadChildAt(slice.items, last)} isThreadChild={isThreadChildAt(slice.items, last)}
isParentBlocked={slice.items[2].isParentBlocked}
isThreadLastChild isThreadLastChild
/> />
</> </>
@@ -82,6 +85,7 @@ let FeedSlice = ({
isThreadLastChild={ isThreadLastChild={
isThreadChildAt(slice.items, i) && slice.items.length === i + 1 isThreadChildAt(slice.items, i) && slice.items.length === i + 1
} }
isParentBlocked={slice.items[i].isParentBlocked}
hideTopBorder={hideTopBorder && i === 0} hideTopBorder={hideTopBorder && i === 0}
/> />
))} ))}
+24 -11
View File
@@ -35,6 +35,7 @@ export type UserAvatarType = 'user' | 'algo' | 'list' | 'labeler'
interface BaseUserAvatarProps { interface BaseUserAvatarProps {
type?: UserAvatarType type?: UserAvatarType
shape?: 'circle' | 'square'
size: number size: number
avatar?: string | null avatar?: string | null
} }
@@ -60,12 +61,16 @@ const BLUR_AMOUNT = isWeb ? 5 : 100
let DefaultAvatar = ({ let DefaultAvatar = ({
type, type,
shape: overrideShape,
size, size,
}: { }: {
type: UserAvatarType type: UserAvatarType
shape?: 'square' | 'circle'
size: number size: number
}): React.ReactNode => { }): React.ReactNode => {
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
if (type === 'algo') { 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. // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return ( return (
<Svg <Svg
@@ -84,6 +89,7 @@ let DefaultAvatar = ({
) )
} }
if (type === 'list') { if (type === 'list') {
// 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. // Font Awesome Pro 6.4.0 by @fontawesome -https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc.
return ( return (
<Svg <Svg
@@ -117,14 +123,18 @@ let DefaultAvatar = ({
viewBox="0 0 32 32" viewBox="0 0 32 32"
fill="none" fill="none"
stroke="none"> stroke="none">
<Rect {finalShape === 'square' ? (
x="0" <Rect
y="0" x="0"
width="32" y="0"
height="32" width="32"
rx="3" height="32"
fill={tokens.color.temp_purple} rx="3"
/> fill={tokens.color.temp_purple}
/>
) : (
<Circle cx="16" cy="16" r="16" fill={tokens.color.temp_purple} />
)}
<Path <Path
d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z" d="M24 9.75L16 7L8 9.75V15.9123C8 20.8848 12 23 16 25.1579C20 23 24 20.8848 24 15.9123V9.75Z"
stroke="white" stroke="white"
@@ -135,6 +145,7 @@ let DefaultAvatar = ({
</Svg> </Svg>
) )
} }
// TODO: shape=square
return ( return (
<Svg <Svg
testID="userAvatarFallback" testID="userAvatarFallback"
@@ -159,6 +170,7 @@ export {DefaultAvatar}
let UserAvatar = ({ let UserAvatar = ({
type = 'user', type = 'user',
shape: overrideShape,
size, size,
avatar, avatar,
moderation, moderation,
@@ -166,9 +178,10 @@ let UserAvatar = ({
}: UserAvatarProps): React.ReactNode => { }: UserAvatarProps): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const backgroundColor = pal.colors.backgroundLight const backgroundColor = pal.colors.backgroundLight
const finalShape = overrideShape ?? (type === 'user' ? 'circle' : 'square')
const aviStyle = useMemo(() => { const aviStyle = useMemo(() => {
if (type === 'algo' || type === 'list' || type === 'labeler') { if (finalShape === 'square') {
return { return {
width: size, width: size,
height: size, height: size,
@@ -182,7 +195,7 @@ let UserAvatar = ({
borderRadius: Math.floor(size / 2), borderRadius: Math.floor(size / 2),
backgroundColor, backgroundColor,
} }
}, [type, size, backgroundColor]) }, [finalShape, size, backgroundColor])
const alert = useMemo(() => { const alert = useMemo(() => {
if (!moderation?.alert) { if (!moderation?.alert) {
@@ -224,7 +237,7 @@ let UserAvatar = ({
</View> </View>
) : ( ) : (
<View style={{width: size, height: size}}> <View style={{width: size, height: size}}>
<DefaultAvatar type={type} size={size} /> <DefaultAvatar type={type} shape={finalShape} size={size} />
{alert} {alert}
</View> </View>
) )
+39 -18
View File
@@ -2,39 +2,60 @@ import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
interface Props { interface Props {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
gif?: boolean
} }
export function ImageHorzList({images, style}: Props) { export function ImageHorzList({images, style, gif}: Props) {
return ( return (
<View style={[styles.flexRow, style]}> <View style={[a.flex_row, a.gap_xs, style]}>
{images.map(({thumb, alt}) => ( {images.map(({thumb, alt}) => (
<Image <View
key={thumb} key={thumb}
source={{uri: thumb}} style={[a.relative, a.flex_1, {aspectRatio: 1, maxWidth: 100}]}>
style={styles.image} <Image
accessible={true} key={thumb}
accessibilityIgnoresInvertColors source={{uri: thumb}}
accessibilityHint={alt} style={[a.flex_1, a.rounded_xs]}
accessibilityLabel="" accessible={true}
/> accessibilityIgnoresInvertColors
accessibilityHint={alt}
accessibilityLabel=""
/>
{gif && (
<View style={styles.altContainer}>
<Text style={styles.alt}>
<Trans>GIF</Trans>
</Text>
</View>
)}
</View>
))} ))}
</View> </View>
) )
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flexRow: { altContainer: {
flexDirection: 'row', backgroundColor: 'rgba(0, 0, 0, 0.75)',
gap: 5, borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
}, },
image: { alt: {
maxWidth: 100, color: 'white',
aspectRatio: 1, fontSize: 7,
flex: 1, fontWeight: 'bold',
borderRadius: 4,
}, },
}) })