mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
Implement visual viewport height handling and toggleable emoji/reaction support.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
5
web/components/editor/emoji/emoji-enabled.ts
Normal file
5
web/components/editor/emoji/emoji-enabled.ts
Normal 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
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
61
web/hooks/use-visual-viewport-height.ts
Normal file
61
web/hooks/use-visual-viewport-height.ts
Normal 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')
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user