Implement visual viewport height handling and toggleable emoji/reaction support.

This commit is contained in:
MartinBraquet
2026-07-30 02:23:46 +02:00
parent 372d5e3975
commit 7abbf7e39a
8 changed files with 140 additions and 46 deletions

View File

@@ -15,6 +15,10 @@ import {Avatar} from 'web/components/widgets/avatar'
import {Content} from 'web/components/widgets/editor'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
// Long-press on a message to open the emoji picker — off for now. Reactions can still be added from the
// message's "..." menu, and existing ones still show and toggle.
const LONG_PRESS_REACTIONS_ENABLED = false
export function ChatMessageItem(props: {
chats: ChatMessage[]
currentUser: DisplayUser | undefined | null
@@ -58,6 +62,7 @@ export function ChatMessageItem(props: {
: {username: '', avatarUrl: undefined, id: ''}
const startLongPress = (messageId: number) => {
if (!LONG_PRESS_REACTIONS_ENABLED) return
hasMovedRef.current = false
lastPositionRef.current = null
if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current)

View File

@@ -38,7 +38,8 @@ export function MessageReactions({message, className, setMessages}: MessageReact
if (reactionGroups.length === 0) return null
return (
<div className={clsx('mt-1 flex flex-wrap gap-1', className)}>
// negative top margin: tuck the chips up against the bubble they belong to
<div className={clsx('-mt-1 flex flex-wrap gap-1', className)}>
{reactionGroups.map(({emoji, users}) => {
const hasReacted = users.includes(user?.id || '')
return (

View File

@@ -0,0 +1,5 @@
/**
* Emoji support in the text editor — the `:shortcode` suggestion popup while typing and the smiley
* button in the format toolbar — is off for now. Flip to `true` to bring both back.
*/
export const EMOJI_ENABLED = false

View File

@@ -9,6 +9,7 @@ import {FileUploadButton} from '../buttons/file-upload-button'
import {LoadingIndicator} from '../widgets/loading-indicator'
import {Tooltip} from '../widgets/tooltip'
import {EmbedModal} from './embed-modal'
import {EMOJI_ENABLED} from './emoji/emoji-enabled'
import type {UploadMutation} from './upload-extension'
/* Toolbar, with buttons for images and embeds */
@@ -39,13 +40,15 @@ export function StickyFormatMenu(props: {
<CodeBracketIcon className="h-5 w-5" aria-hidden="true" />
</ToolbarButton>
)}
<ToolbarButton
key={'emoji-button'}
label={t('sticky_format_menu.add_emoji', 'Add emoji')}
onClick={() => insertEmoji(editor)}
>
<FaceSmileIcon className="h-5 w-5" />
</ToolbarButton>
{EMOJI_ENABLED && (
<ToolbarButton
key={'emoji-button'}
label={t('sticky_format_menu.add_emoji', 'Add emoji')}
onClick={() => insertEmoji(editor)}
>
<FaceSmileIcon className="h-5 w-5" />
</ToolbarButton>
)}
<EmbedModal editor={editor} open={iframeOpen} setOpen={setIframeOpen} />
<div className="grow" />

View File

@@ -21,6 +21,7 @@ import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {safeLocalStorage} from 'web/lib/util/local'
import {DEFAULT_PROTOCOL, SyncAutolink} from '../editor/autolink'
import {EMOJI_ENABLED} from '../editor/emoji/emoji-enabled'
import {EmojiExtension} from '../editor/emoji/emoji-extension'
import {FloatingFormatMenu} from '../editor/floating-format-menu'
import {BasicImage, DisplayImage} from '../editor/image'
@@ -56,7 +57,7 @@ const editorExtensions = (simple = false): Extensions =>
}),
simple ? DisplayImage : BasicImage,
simple ? DisplayVideo : BasicVideo,
EmojiExtension,
...(EMOJI_ENABLED ? [EmojiExtension] : []),
DisplayLink,
SyncAutolink,
DisplayMention,
@@ -385,7 +386,9 @@ export function TextEditor(props: {
)}
>
<FloatingFormatMenu editor={editor} advanced={!simple} />
<div className={clsx(`overflow-auto`, maxHeight)}>
{/* overscroll-contain: a drag started in the editor stays here instead of chaining out and
scrolling whatever is behind it (e.g. the chat page) */}
<div className={clsx(`overflow-auto overscroll-contain`, maxHeight)}>
<EditorContent editor={editor} onBlur={onBlur} onChange={onChange} />
</div>

View File

@@ -0,0 +1,61 @@
import {useEffect} from 'react'
/**
* Pins the calling page to the *visual* viewport (the part of the screen not covered by the on-screen
* keyboard) and stops the document itself from scrolling, for as long as the component is mounted.
*
* Why: on a chat page the composer sits at the bottom of the page. When the keyboard opens, the visual
* viewport shrinks but the document keeps its old height, so the browser/WebView scrolls the document up
* to keep the focused input visible — dragging the conversation header (the name of the person you're
* messaging) off the top of the screen. `100dvh` doesn't help: it tracks the *layout* viewport, which the
* keyboard doesn't change.
*
* The fix is to publish the live visual-viewport height as `--vvh` and let the page size itself off that,
* with the document locked at scroll 0 so nothing can push the header away. The page then only scrolls
* where we want it to: inside the message list.
*/
export const useVisualViewportHeight = () => {
useEffect(() => {
const root = document.documentElement
const vv = window.visualViewport
const update = () => {
root.style.setProperty('--vvh', `${vv?.height ?? window.innerHeight}px`)
// Undo any auto-scroll the browser did to reveal the focused input: with the page sized to the
// visual viewport there is nothing to scroll to, and a leftover offset hides the header.
if (document.scrollingElement) document.scrollingElement.scrollTop = 0
window.scrollTo(0, 0)
}
update()
vv?.addEventListener('resize', update)
vv?.addEventListener('scroll', update)
window.addEventListener('resize', update)
window.addEventListener('orientationchange', update)
const bodyStyle = {
overflow: document.body.style.overflow,
ob: document.body.style.overscrollBehavior,
}
const rootStyle = {overflow: root.style.overflow, ob: root.style.overscrollBehavior}
// Nothing outside the message list scrolls: overflow hidden on both the document element and the
// body (a drag started on the composer chains to whichever of the two is scrollable), and
// overscroll-behavior none so neither rubber-bands the whole page.
document.body.style.overflow = 'hidden'
document.body.style.overscrollBehavior = 'none'
root.style.overflow = 'hidden'
root.style.overscrollBehavior = 'none'
return () => {
vv?.removeEventListener('resize', update)
vv?.removeEventListener('scroll', update)
window.removeEventListener('resize', update)
window.removeEventListener('orientationchange', update)
document.body.style.overflow = bodyStyle.overflow
document.body.style.overscrollBehavior = bodyStyle.ob
root.style.overflow = rootStyle.overflow
root.style.overscrollBehavior = rootStyle.ob
root.style.removeProperty('--vvh')
}
}, [])
}

View File

@@ -38,6 +38,7 @@ import {
import {useRedirectIfSignedOut} from 'web/hooks/use-redirect-if-signed-out'
import {useUser} from 'web/hooks/use-user'
import {useUsersInStore} from 'web/hooks/use-user-supabase'
import {useVisualViewportHeight} from 'web/hooks/use-visual-viewport-height'
import {api} from 'web/lib/api'
import {firebaseLogin} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
@@ -119,6 +120,7 @@ export const PrivateChat = (props: {
const {user, channel, memberIds} = props
const t = useT()
useHideBottomNavOnKeyboard()
useVisualViewportHeight()
const channelId = channel.channel_id
const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
const isMobile = useIsMobile()
@@ -275,9 +277,13 @@ export const PrivateChat = (props: {
const [replyToUserInfo, setReplyToUserInfo] = useState<any>()
return (
<Col className="w-full">
// Sized to the visual viewport (see useVisualViewportHeight) so the keyboard can't push the header
// off-screen: the header and composer are fixed rows, only the message list scrolls.
<Col className="w-full overflow-hidden h-[calc(var(--vvh,100dvh)-var(--hloss)-var(--bnv))] lg:h-[calc(100dvh-var(--hloss)-1.5rem)]">
<Row
className={'bg-canvas-0 border border-canvas-200 h-14 items-center gap-2 rounded-xl px-2'}
className={
'bg-canvas-0 border border-canvas-200 h-14 shrink-0 items-center gap-2 rounded-xl px-2'
}
>
<BackButton className="self-stretch mr-2" />
{members && members.length > 0 ? (
@@ -438,10 +444,12 @@ export const PrivateChat = (props: {
</Modal>
)}
</Row>
<Col className="relative h-[calc(100dvh-149px-var(--bnv)-var(--hloss))] lg:h-[calc(100dvh-184px-var(--hloss))] xl:px-0">
<Col className="relative min-h-0 flex-1 xl:px-0">
{/* overscroll-contain: at the top of the feed, further upward scrolling stays here instead of
chaining to the document and dragging the header/composer with it. */}
<div
ref={outerDiv}
className="relative h-full overflow-y-auto"
className="relative h-full overflow-y-auto overscroll-contain"
style={{
transform: isSafari ? 'translate3d(0, 0, 0)' : 'none',
}}
@@ -511,38 +519,45 @@ export const PrivateChat = (props: {
</div>
</div>
</Col>
{allUsersBanned ? (
<div className="bg-canvas-50 border border-canvas-200 rounded-xl p-4 text-center m-2">
<span className="text-ink-500 text-sm">
{t(
'messages.cannot_message_banned',
"The profile was removed for suspicious activity; never send money to someone you haven't met.",
)}
</span>
</div>
) : user.isBannedFromPosting ? (
<AccountOnHoldNotice reason={user.banReason} className="m-2" compact />
) : noOtherUser ? (
<div className="bg-canvas-50 border border-canvas-200 rounded-xl p-4 text-center m-2">
<span className="text-ink-500 text-sm">
{t(
'messages.cannot_message_deleted',
"You can't text them as they deleted their account.",
)}
</span>
</div>
) : (
<CommentInputTextArea
editor={editor}
user={user}
submit={submitMessage}
isSubmitting={isSubmitting}
submitOnEnter={!isMobile}
replyTo={replyToUserInfo}
isEditing={!!editingMessage}
cancelEditing={cancelEditing}
/>
)}
{/* relative z-10: the composer is a static box, so the positioned message bubbles above it would
otherwise paint over its border / focus ring as they scroll past. */}
<div className="relative z-10 shrink-0">
{allUsersBanned ? (
<div className="bg-canvas-50 border border-canvas-200 rounded-xl p-4 text-center m-2">
<span className="text-ink-500 text-sm">
{t(
'messages.cannot_message_banned',
"The profile was removed for suspicious activity; never send money to someone you haven't met.",
)}
</span>
</div>
) : user.isBannedFromPosting ? (
<AccountOnHoldNotice reason={user.banReason} className="m-2" compact />
) : noOtherUser ? (
<div className="bg-canvas-50 border border-canvas-200 rounded-xl p-4 text-center m-2">
<span className="text-ink-500 text-sm">
{t(
'messages.cannot_message_deleted',
"You can't text them as they deleted their account.",
)}
</span>
</div>
) : (
<CommentInputTextArea
editor={editor}
// Cap the composer at 40% of the *visible* viewport (the default max-h-[60vh] tracks the
// layout viewport, so with the keyboard up a long draft swallowed the whole feed).
maxHeight="max-h-[calc(var(--vvh)*0.4)]"
user={user}
submit={submitMessage}
isSubmitting={isSubmitting}
submitOnEnter={!isMobile}
replyTo={replyToUserInfo}
isEditing={!!editingMessage}
cancelEditing={cancelEditing}
/>
)}
</div>
</Col>
)
}

View File

@@ -10,6 +10,7 @@
--tnh: env(safe-area-inset-top); /* native top nav height */
--hloss: calc(var(--bnh) + var(--tnh)); /* mobile height loss due to native top and bottom bars */
--filter-offset: 0px; /* padding for filters on mobile */
--vvh: 100dvh; /* visual viewport height; overridden in px by useVisualViewportHeight() */
touch-action: pan-y;
}