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