+ // negative top margin: tuck the chips up against the bubble they belong to
+
{reactionGroups.map(({emoji, users}) => {
const hasReacted = users.includes(user?.id || '')
return (
diff --git a/web/components/editor/emoji/emoji-enabled.ts b/web/components/editor/emoji/emoji-enabled.ts
new file mode 100644
index 00000000..92352b05
--- /dev/null
+++ b/web/components/editor/emoji/emoji-enabled.ts
@@ -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
diff --git a/web/components/editor/sticky-format-menu.tsx b/web/components/editor/sticky-format-menu.tsx
index 63faa9c6..547b1e32 100644
--- a/web/components/editor/sticky-format-menu.tsx
+++ b/web/components/editor/sticky-format-menu.tsx
@@ -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: {
)}
-
insertEmoji(editor)}
- >
-
-
+ {EMOJI_ENABLED && (
+
insertEmoji(editor)}
+ >
+
+
+ )}
diff --git a/web/components/widgets/editor.tsx b/web/components/widgets/editor.tsx
index 1aee3110..ebfa3397 100644
--- a/web/components/widgets/editor.tsx
+++ b/web/components/widgets/editor.tsx
@@ -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: {
)}
>
-
+ {/* 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) */}
+
diff --git a/web/hooks/use-visual-viewport-height.ts b/web/hooks/use-visual-viewport-height.ts
new file mode 100644
index 00000000..746103b9
--- /dev/null
+++ b/web/hooks/use-visual-viewport-height.ts
@@ -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')
+ }
+ }, [])
+}
diff --git a/web/pages/messages/[channelId].tsx b/web/pages/messages/[channelId].tsx
index 054d33ed..453e1d55 100644
--- a/web/pages/messages/[channelId].tsx
+++ b/web/pages/messages/[channelId].tsx
@@ -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
()
return (
-
+ // 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.
+
{members && members.length > 0 ? (
@@ -438,10 +444,12 @@ export const PrivateChat = (props: {
)}
-
+
+ {/* 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. */}
- {allUsersBanned ? (
-
-
- {t(
- 'messages.cannot_message_banned',
- "The profile was removed for suspicious activity; never send money to someone you haven't met.",
- )}
-
-
- ) : user.isBannedFromPosting ? (
-
- ) : noOtherUser ? (
-
-
- {t(
- 'messages.cannot_message_deleted',
- "You can't text them as they deleted their account.",
- )}
-
-
- ) : (
-
- )}
+ {/* 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. */}
+
+ {allUsersBanned ? (
+
+
+ {t(
+ 'messages.cannot_message_banned',
+ "The profile was removed for suspicious activity; never send money to someone you haven't met.",
+ )}
+
+
+ ) : user.isBannedFromPosting ? (
+
+ ) : noOtherUser ? (
+
+
+ {t(
+ 'messages.cannot_message_deleted',
+ "You can't text them as they deleted their account.",
+ )}
+
+
+ ) : (
+
+ )}
+
)
}
diff --git a/web/styles/globals.css b/web/styles/globals.css
index 9a8da9e8..3a406ccd 100644
--- a/web/styles/globals.css
+++ b/web/styles/globals.css
@@ -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;
}