[Chat] New post attachment style in composer (#10563)

This commit is contained in:
Samuel Newman
2026-05-20 19:50:34 +03:00
committed by GitHub
parent 3d4ba946b3
commit c4ecb007f3
3 changed files with 178 additions and 140 deletions
@@ -145,8 +145,6 @@ export function MessageComposer({
return ( return (
<ComposerContainer> <ComposerContainer>
{children}
<View <View
collapsable={false} collapsable={false}
ref={native( ref={native(
@@ -162,80 +160,83 @@ export function MessageComposer({
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]} style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50} tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}> fallbackStyle={[t.atoms.bg_contrast_50]}>
{IS_WEB && ( {children}
<EmojiPicker.Root <View style={[a.flex_1]}>
onEmojiSelect={emoji => {IS_WEB && (
composerInternalApiRef.current?.insert(emoji.native) <EmojiPicker.Root
} onEmojiSelect={emoji =>
nextFocusRef={() => composerInternalApiRef.current?.insert(emoji.native)
composerInternalApiRef.current?.input?.element }
}> nextFocusRef={() =>
<EmojiPicker.Trigger label={l`Open emoji picker`}> composerInternalApiRef.current?.input?.element
{({props, state, control}) => ( }>
<Pressable <EmojiPicker.Trigger label={l`Open emoji picker`}>
{...props} {({props, state, control}) => (
style={[ <Pressable
a.overflow_hidden, {...props}
a.absolute, style={[
a.rounded_full, a.overflow_hidden,
a.align_center, a.absolute,
a.justify_center, a.rounded_full,
a.z_30, a.align_center,
{ a.justify_center,
height: 20, a.z_30,
width: 20, {
top: 10, height: 20,
right: 10, width: 20,
}, top: 10,
]}> right: 10,
<EmojiSmileIcon },
size="md" ]}>
style={ <EmojiSmileIcon
state.hovered || size="md"
state.focused || style={
state.pressed || state.hovered ||
control.isOpen state.focused ||
? {color: t.palette.primary_500} state.pressed ||
: t.atoms.text_contrast_high control.isOpen
} ? {color: t.palette.primary_500}
/> : t.atoms.text_contrast_high
</Pressable> }
)} />
</EmojiPicker.Trigger> </Pressable>
<EmojiPicker.Picker /> )}
</EmojiPicker.Root> </EmojiPicker.Trigger>
)} <EmojiPicker.Picker />
</EmojiPicker.Root>
)}
<Composer <Composer
nativeID={textInputId} nativeID={textInputId}
label={l`Message input field`} label={l`Message input field`}
placeholder={l`Message`} placeholder={l`Message`}
autocompletePlacement="top-start" autocompletePlacement="top-start"
internalApiRef={composerInternalApiRef} internalApiRef={composerInternalApiRef}
defaultValue={text} defaultValue={text}
editable={editable} editable={editable}
autoFocus={IS_WEB} autoFocus={IS_WEB}
maxRows={12} maxRows={12}
outerStyle={[a.flex_1]} outerStyle={[a.flex_1]}
contentTextStyle={[a.text_md, a.leading_snug]} contentTextStyle={[a.text_md, a.leading_snug]}
contentPaddingStyle={{ contentPaddingStyle={{
paddingLeft: 16, paddingLeft: 16,
paddingTop: 10, paddingTop: 10,
paddingBottom: 10, paddingBottom: 10,
paddingRight: 16 + platform({web: 20, default: 0}), paddingRight: 16 + platform({web: 20, default: 0}),
}} }}
onChange={handleChange} onChange={handleChange}
onFacetCommitted={facet => { onFacetCommitted={facet => {
if (facet.type === 'url' && isBskyPostUrl(facet.value)) { if (facet.type === 'url' && isBskyPostUrl(facet.value)) {
setEmbed(facet.value) setEmbed(facet.value)
} }
}} }}
onRequestSubmit={req => { onRequestSubmit={req => {
if (req.platform === 'web' && req.shiftKey) return if (req.platform === 'web' && req.shiftKey) return
req.nativeEvent.preventDefault() req.nativeEvent.preventDefault()
handleSubmit() handleSubmit()
}} }}
/> />
</View>
</GlassView> </GlassView>
<SubmitButton onPress={handleSubmit} disabled={submitDisabled} /> <SubmitButton onPress={handleSubmit} disabled={submitDisabled} />
</GlassContainer> </GlassContainer>
@@ -158,7 +158,6 @@ export function MessageInput({
return ( return (
<ComposerContainer> <ComposerContainer>
{children}
<GlassContainer <GlassContainer
style={[a.flex_row, a.align_end, a.gap_sm]} style={[a.flex_row, a.align_end, a.gap_sm]}
spacing={tokens.space.xs}> spacing={tokens.space.xs}>
@@ -168,6 +167,7 @@ export function MessageInput({
style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]} style={[a.flex_1, a.rounded_xl, {minHeight: MIN_HEIGHT}]}
tintColor={t.palette.contrast_50} tintColor={t.palette.contrast_50}
fallbackStyle={[t.atoms.bg_contrast_50]}> fallbackStyle={[t.atoms.bg_contrast_50]}>
{children}
<AnimatedTextInput <AnimatedTextInput
nativeID={textInputId} nativeID={textInputId}
accessibilityLabel={l`Message input field`} accessibilityLabel={l`Message input field`}
@@ -7,10 +7,10 @@ import {
moderatePost, moderatePost,
RichText as RichTextAPI, RichText as RichTextAPI,
} from '@atproto/api' } from '@atproto/api'
import {msg} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {useLingui} from '@lingui/react'
import {type RouteProp, useNavigation, useRoute} from '@react-navigation/native' import {type RouteProp, useNavigation, useRoute} from '@react-navigation/native'
import {HITSLOP_20} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import { import {
type CommonNavigatorParams, type CommonNavigatorParams,
@@ -25,8 +25,8 @@ import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuery} from '#/state/queries/post' import {usePostQuery} from '#/state/queries/post'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button' import {Button} from '#/components/Button'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import * as MediaPreview from '#/components/MediaPreview' import * as MediaPreview from '#/components/MediaPreview'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
@@ -41,10 +41,10 @@ export function useMessageEmbed() {
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const embedFromParams = route.params.embed const embedFromParams = route.params.embed
const [embedUri, setEmbed] = useState(embedFromParams) const [embedUri, setEmbedUri] = useState(embedFromParams)
if (embedFromParams && embedUri !== embedFromParams) { if (embedFromParams && embedUri !== embedFromParams) {
setEmbed(embedFromParams) setEmbedUri(embedFromParams)
} }
return { return {
@@ -53,7 +53,7 @@ export function useMessageEmbed() {
(embedUrl: string | undefined) => { (embedUrl: string | undefined) => {
if (!embedUrl) { if (!embedUrl) {
navigation.setParams({embed: ''}) navigation.setParams({embed: ''})
setEmbed(undefined) setEmbedUri(undefined)
return return
} }
@@ -63,7 +63,7 @@ export function useMessageEmbed() {
const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
setEmbed(uri) setEmbedUri(uri)
}, },
[embedFromParams, navigation], [embedFromParams, navigation],
), ),
@@ -103,7 +103,7 @@ export function MessageInputEmbed({
setEmbed: (embedUrl: string | undefined) => void setEmbed: (embedUrl: string | undefined) => void
}) { }) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {t: l} = useLingui()
const {data: post, status} = usePostQuery(embedUri) const {data: post, status} = usePostQuery(embedUri)
@@ -138,25 +138,29 @@ export function MessageInputEmbed({
return null return null
} }
let content = null const onRemove = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setEmbed(undefined)
}
switch (status) { switch (status) {
case 'pending': case 'pending': {
content = ( return (
<View <SimpleContainer onRemove={onRemove}>
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}>
<Loader /> <Loader />
</View> </SimpleContainer>
) )
break }
case 'error': case 'error': {
content = ( return (
<View <SimpleContainer onRemove={onRemove}>
style={[a.flex_1, {minHeight: 64}, a.justify_center, a.align_center]}> <Text style={[a.text_center, t.atoms.text_contrast_medium, a.italic]}>
<Text style={a.text_center}>Could not fetch post</Text> <Trans>Could not fetch post</Trans>
</View> </Text>
</SimpleContainer>
) )
break }
case 'success': case 'success': {
const itemUrip = new AtUri(post.uri) const itemUrip = new AtUri(post.uri)
const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey) const itemHref = makeProfileLink(post.author, 'post', itemUrip.rkey)
@@ -164,62 +168,95 @@ export function MessageInputEmbed({
return null return null
} }
content = ( return (
<View <View
style={[ style={[
a.flex_1, a.flex_1,
t.atoms.bg, t.atoms.border_contrast_high,
t.atoms.border_contrast_low,
a.rounded_md, a.rounded_md,
a.border, a.border,
a.p_sm, a.p_sm,
a.mb_sm, a.mt_sm,
]} a.mx_sm,
pointerEvents="none"> ]}>
<PostMeta <View style={[a.flex_1, a.flex_row, a.gap_sm]}>
showAvatar <PostMeta
author={post.author} showAvatar
moderation={moderation} author={post.author}
timestamp={post.indexedAt} moderation={moderation}
postHref={itemHref} timestamp={post.indexedAt}
style={a.flex_0} postHref={itemHref}
/> linkDisabled
/>
<Button
label={l`Remove embed`}
onPress={onRemove}
style={[a.px_2xs, {transform: [{translateY: -2}]}]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
</View>
<ContentHider modui={moderation.ui('contentView')}> <ContentHider modui={moderation.ui('contentView')}>
<PostAlerts modui={moderation.ui('contentView')} style={a.py_xs} /> <PostAlerts
modui={moderation.ui('contentView')}
style={a.py_xs}
size="sm"
/>
{rt.text && ( {rt.text && (
<View style={a.mt_xs}> <RichText
<RichText enableTags
enableTags testID="postText"
testID="postText" value={rt}
value={rt} style={[a.text_sm, t.atoms.text_contrast_high]}
style={[a.text_sm, t.atoms.text_contrast_high]} authorHandle={post.author.handle}
authorHandle={post.author.handle} numberOfLines={3}
numberOfLines={3} />
/>
</View>
)} )}
<MediaPreview.Embed embed={post.embed} style={a.mt_sm} /> <MediaPreview.Embed embed={post.embed} style={a.mt_sm} />
</ContentHider> </ContentHider>
</View> </View>
) )
break }
} }
}
function SimpleContainer({
children,
onRemove,
}: {
children: React.ReactNode
onRemove?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
return ( return (
<View style={[a.flex_row, a.gap_sm]}> <View
{content} style={[
<Button a.flex_1,
label={_(msg`Remove embed`)} {minHeight: 80},
onPress={() => { a.justify_center,
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) a.align_center,
setEmbed(undefined) t.atoms.border_contrast_high,
}} a.rounded_md,
size="tiny" a.border,
variant="solid" a.mt_sm,
color="secondary" a.mx_sm,
shape="round"> ]}>
<ButtonIcon icon={X} /> {children}
</Button> {onRemove && (
<Button
label={l`Remove embed`}
onPress={onRemove}
style={[
a.absolute,
{top: 10, right: 8},
a.px_2xs,
{transform: [{translateY: -2}]},
]}
hitSlop={HITSLOP_20}>
<XIcon size="xs" style={t.atoms.text_contrast_high} />
</Button>
)}
</View> </View>
) )
} }