diff --git a/app/assets/javascripts/discourse/app/components/d-document.js b/app/assets/javascripts/discourse/app/components/d-document.js index 9a476f7b6ee..ff62bd4811d 100644 --- a/app/assets/javascripts/discourse/app/components/d-document.js +++ b/app/assets/javascripts/discourse/app/components/d-document.js @@ -1,6 +1,7 @@ import Component from "@ember/component"; import { service } from "@ember/service"; import { setLogoffCallback } from "discourse/lib/ajax"; +import { clearAllBodyScrollLocks } from "discourse/lib/body-scroll-lock"; import logout from "discourse/lib/logout"; import { bind } from "discourse-common/utils/decorators"; import I18n from "discourse-i18n"; @@ -59,6 +60,9 @@ export default Component.extend({ @bind _focusChanged() { + // changing app while keyboard is up could cause the keyboard to not collapse and not release lock + clearAllBodyScrollLocks(); + if (document.visibilityState === "hidden") { if (this.session.hasFocus) { this.documentTitle.setFocus(false); diff --git a/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs b/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs new file mode 100644 index 00000000000..4c88f270c87 --- /dev/null +++ b/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs @@ -0,0 +1,138 @@ +import Component from "@glimmer/component"; +import { cancel, scheduleOnce } from "@ember/runloop"; +import { service } from "@ember/service"; +import { clearAllBodyScrollLocks } from "discourse/lib/body-scroll-lock"; +import isZoomed from "discourse/lib/zoom-check"; +import discourseDebounce from "discourse-common/lib/debounce"; +import { bind } from "discourse-common/utils/decorators"; + +const KEYBOARD_DETECT_THRESHOLD = 150; + +export default class DVirtualHeight extends Component { + @service site; + @service capabilities; + @service appEvents; + + constructor() { + super(...arguments); + + if (!window.visualViewport) { + return; + } + + if (!this.capabilities.isIpadOS && this.site.desktopView) { + return; + } + + // TODO: Handle device rotation + this.windowInnerHeight = window.innerHeight; + + scheduleOnce("afterRender", this, this.debouncedOnViewportResize); + + window.visualViewport.addEventListener( + "resize", + this.debouncedOnViewportResize + ); + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.overlaysContent = true; + navigator.virtualKeyboard.addEventListener( + "geometrychange", + this.debouncedOnViewportResize + ); + } + } + + willDestroy() { + super.willDestroy(...arguments); + + cancel(this.debouncedHandler); + + window.visualViewport.removeEventListener( + "resize", + this.debouncedOnViewportResize + ); + if ("virtualKeyboard" in navigator) { + navigator.virtualKeyboard.overlaysContent = false; + navigator.virtualKeyboard.removeEventListener( + "geometrychange", + this.debouncedOnViewportResize + ); + } + } + + setVH() { + if (isZoomed()) { + return; + } + + let height; + if ("virtualKeyboard" in navigator) { + height = + window.visualViewport.height - + navigator.virtualKeyboard.boundingRect.height; + } else { + const activeWindow = window.visualViewport || window; + height = activeWindow?.height || window.innerHeight; + } + + const newVh = height * 0.01; + if (this.lastVh === newVh) { + return; + } + + document.documentElement.style.setProperty("--composer-vh", `${newVh}px`); + this.lastVh = newVh; + } + + @bind + debouncedOnViewportResize() { + this.debouncedHandler = discourseDebounce(this, this.onViewportResize, 50); + } + + @bind + onViewportResize() { + this.setVH(); + + let keyboardVisible = false; + if ("virtualKeyboard" in navigator) { + if (navigator.virtualKeyboard.boundingRect.height > 0) { + keyboardVisible = true; + } + } else if (this.capabilities.isFirefox && this.capabilities.isAndroid) { + if ( + Math.abs( + this.windowInnerHeight - + Math.min(window.innerHeight, window.visualViewport.height) + ) > KEYBOARD_DETECT_THRESHOLD + ) { + keyboardVisible = true; + } + } else { + let viewportWindowDiff = + this.windowInnerHeight - window.visualViewport.height; + const IPAD_HARDWARE_KEYBOARD_TOOLBAR_HEIGHT = 71.5; + if (viewportWindowDiff > IPAD_HARDWARE_KEYBOARD_TOOLBAR_HEIGHT) { + keyboardVisible = true; + } + + // adds bottom padding when using a hardware keyboard and the accessory bar is visible + // accessory bar height is 55px, using 75 allows a small buffer + if (this.capabilities.isIpadOS) { + document.documentElement.style.setProperty( + "--composer-ipad-padding", + `${viewportWindowDiff < 75 ? viewportWindowDiff : 0}px` + ); + } + } + + this.appEvents.trigger("keyboard-visibility-change", keyboardVisible); + + keyboardVisible + ? document.documentElement.classList.add("keyboard-visible") + : document.documentElement.classList.remove("keyboard-visible"); + + if (!keyboardVisible) { + clearAllBodyScrollLocks(); + } + } +} diff --git a/app/assets/javascripts/discourse/app/instance-initializers/mobile-keyboard.js b/app/assets/javascripts/discourse/app/instance-initializers/mobile-keyboard.js deleted file mode 100644 index 08efdd7b7c8..00000000000 --- a/app/assets/javascripts/discourse/app/instance-initializers/mobile-keyboard.js +++ /dev/null @@ -1,90 +0,0 @@ -import { bind } from "discourse-common/utils/decorators"; - -export default { - after: "mobile", - - initialize(owner) { - const site = owner.lookup("service:site"); - this.capabilities = owner.lookup("service:capabilities"); - this.appEvents = owner.lookup("service:app-events"); - - if (!this.capabilities.isIpadOS && site.desktopView) { - return; - } - - if (!window.visualViewport) { - return; - } - - // TODO: Handle device rotation? - this.windowInnerHeight = window.innerHeight; - - this.onViewportResize(); - window.visualViewport.addEventListener("resize", this.onViewportResize); - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.overlaysContent = true; - navigator.virtualKeyboard.addEventListener( - "geometrychange", - this.onViewportResize - ); - } - }, - - teardown() { - window.visualViewport.removeEventListener("resize", this.onViewportResize); - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.overlaysContent = false; - navigator.virtualKeyboard.removeEventListener( - "geometrychange", - this.onViewportResize - ); - } - }, - - @bind - onViewportResize() { - const composerVH = window.visualViewport.height * 0.01, - doc = document.documentElement, - KEYBOARD_DETECT_THRESHOLD = 150; - - doc.style.setProperty("--composer-vh", `${composerVH}px`); - - let keyboardVisible = false; - if ("virtualKeyboard" in navigator) { - if (navigator.virtualKeyboard.boundingRect.height > 0) { - keyboardVisible = true; - } - } else if (this.capabilities.isFirefox && this.capabilities.isAndroid) { - if ( - Math.abs( - this.windowInnerHeight - - Math.min(window.innerHeight, window.visualViewport.height) - ) > KEYBOARD_DETECT_THRESHOLD - ) { - keyboardVisible = true; - } - } else { - let viewportWindowDiff = - this.windowInnerHeight - window.visualViewport.height; - const IPAD_HARDWARE_KEYBOARD_TOOLBAR_HEIGHT = 71.5; - if (viewportWindowDiff > IPAD_HARDWARE_KEYBOARD_TOOLBAR_HEIGHT) { - keyboardVisible = true; - } - - // adds bottom padding when using a hardware keyboard and the accessory bar is visible - // accessory bar height is 55px, using 75 allows a small buffer - if (this.capabilities.isIpadOS) { - doc.style.setProperty( - "--composer-ipad-padding", - `${viewportWindowDiff < 75 ? viewportWindowDiff : 0}px` - ); - } - } - - this.appEvents.trigger("keyboard-visibility-change", keyboardVisible); - - keyboardVisible - ? doc.classList.add("keyboard-visible") - : doc.classList.remove("keyboard-visible"); - }, -}; diff --git a/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js b/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js index 8051cd0308e..4bf305e084d 100644 --- a/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js +++ b/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js @@ -156,17 +156,40 @@ const isTargetElementTotallyScrolled = (targetElement) => ? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight : false; -const handleScroll = (event, targetElement) => { +const handleScroll = (event, targetElement, options = {}) => { const clientY = event.targetTouches[0].clientY - initialClientY; if (allowTouchMove(event.target)) { return false; } - if (targetElement && targetElement.scrollTop === 0 && clientY > 0) { - return preventDefault(event); - } - if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) { - return preventDefault(event); + + const { reverse } = options; + const atStart = targetElement.scrollTop === 0; + const atEnd = isTargetElementTotallyScrolled(targetElement); + + // Adjust the conditions based on the 'reverse' option + if (reverse) { + // For 'column-reverse', scrolling "up" means moving towards the end of the content, + // and scrolling "down" means moving towards the start. + if (atEnd && clientY > 0) { + // At the end and attempting to scroll towards the start (down in a reversed setup) + return preventDefault(event); + } + if (atStart && clientY < 0) { + // At the start and attempting to scroll away from the start (up in a reversed setup) + return preventDefault(event); + } + } else { + // Normal scrolling (not reversed) + if (atStart && clientY > 0) { + // At the start and attempting to scroll towards the start (traditional setup) + return preventDefault(event); + } + if (atEnd && clientY < 0) { + // At the end and attempting to scroll away from the start (traditional setup) + return preventDefault(event); + } } + event.stopPropagation(); return true; }; @@ -204,7 +227,7 @@ const disableBodyScroll = (targetElement, options) => { }; targetElement.ontouchmove = (event) => { if (event.targetTouches.length === 1) { - handleScroll(event, targetElement); + handleScroll(event, targetElement, options); } }; if (!documentListenerAdded) { diff --git a/plugins/chat/assets/javascripts/discourse/lib/zoom-check.js b/app/assets/javascripts/discourse/app/lib/zoom-check.js similarity index 100% rename from plugins/chat/assets/javascripts/discourse/lib/zoom-check.js rename to app/assets/javascripts/discourse/app/lib/zoom-check.js diff --git a/app/assets/javascripts/discourse/app/templates/application.hbs b/app/assets/javascripts/discourse/app/templates/application.hbs index 9af9116828f..1c6666b8708 100644 --- a/app/assets/javascripts/discourse/app/templates/application.hbs +++ b/app/assets/javascripts/discourse/app/templates/application.hbs @@ -1,4 +1,5 @@ + diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs index 30ef22e8cde..b76d932f470 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-channel.gjs @@ -27,7 +27,6 @@ import { PAST, READ_INTERVAL_MS, } from "discourse/plugins/chat/discourse/lib/chat-constants"; -import { bodyScrollFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader"; import { checkMessageBottomVisibility, @@ -184,7 +183,6 @@ export default class ChatChannel extends Component { onPresenceChangeCallback(present) { if (present) { this.debouncedUpdateLastReadMessage(); - bodyScrollFix({ delayed: true }); } } @@ -463,7 +461,6 @@ export default class ChatChannel extends Component { return; } - bodyScrollFix(); DatesSeparatorsPositioner.apply(this.scrollable); this.needsArrow = @@ -765,6 +762,7 @@ export default class ChatChannel extends Component { @channel={{@channel}} @uploadDropZone={{this.uploadDropZone}} @onSendMessage={{this.onSendMessage}} + @scrollable={{this.scrollable}} /> {{/if}} {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js index 4e5be6499f2..6e5ed5de928 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-composer.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-composer.js @@ -141,6 +141,7 @@ export default class ChatComposer extends Component { @action setup() { + this.composer.scrollable = this.args.scrollable; this.appEvents.on("chat:modify-selection", this, "modifySelection"); this.appEvents.on( "chat:open-insert-link-modal", diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs index 3de3629f629..bb7d0196186 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message.gjs @@ -16,6 +16,7 @@ import DButton from "discourse/components/d-button"; import concatClass from "discourse/helpers/concat-class"; import optionalService from "discourse/lib/optional-service"; import { updateUserStatusOnMention } from "discourse/lib/update-user-status-on-mention"; +import isZoomed from "discourse/lib/zoom-check"; import discourseDebounce from "discourse-common/lib/debounce"; import discourseLater from "discourse-common/lib/later"; import { bind } from "discourse-common/utils/decorators"; @@ -30,7 +31,6 @@ import ChatMessageSeparator from "discourse/plugins/chat/discourse/components/ch import ChatMessageText from "discourse/plugins/chat/discourse/components/chat-message-text"; import ChatMessageThreadIndicator from "discourse/plugins/chat/discourse/components/chat-message-thread-indicator"; import ChatMessageInteractor from "discourse/plugins/chat/discourse/lib/chat-message-interactor"; -import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; import ChatOnLongPress from "discourse/plugins/chat/discourse/modifiers/chat/on-long-press"; let _chatMessageDecorators = []; diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs index c0d53095fa4..ee9df33f77b 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-thread.gjs @@ -19,10 +19,7 @@ import { PAST, READ_INTERVAL_MS, } from "discourse/plugins/chat/discourse/lib/chat-constants"; -import { - bodyScrollFix, - stackingContextFix, -} from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; +import { stackingContextFix } from "discourse/plugins/chat/discourse/lib/chat-ios-hacks"; import ChatMessagesLoader from "discourse/plugins/chat/discourse/lib/chat-messages-loader"; import DatesSeparatorsPositioner from "discourse/plugins/chat/discourse/lib/dates-separators-positioner"; import { extractCurrentTopicInfo } from "discourse/plugins/chat/discourse/lib/extract-current-topic-info"; @@ -119,7 +116,6 @@ export default class ChatThread extends Component { return; } - bodyScrollFix(); DatesSeparatorsPositioner.apply(this.scrollable); this.needsArrow = @@ -578,6 +574,7 @@ export default class ChatThread extends Component { @thread={{@thread}} @onSendMessage={{this.onSendMessage}} @uploadDropZone={{this.uploadDropZone}} + @scrollable={{this.scrollable}} /> {{/if}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js b/plugins/chat/assets/javascripts/discourse/components/chat-vh.js deleted file mode 100644 index 620f78f989d..00000000000 --- a/plugins/chat/assets/javascripts/discourse/components/chat-vh.js +++ /dev/null @@ -1,71 +0,0 @@ -import Component from "@ember/component"; -import { service } from "@ember/service"; -import { bind } from "discourse-common/utils/decorators"; -import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; - -const CSS_VAR = "--chat-vh"; -let lastVH; - -export default class ChatVh extends Component { - @service capabilities; - - tagName = ""; - - didInsertElement() { - super.didInsertElement(...arguments); - - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.overlaysContent = true; - navigator.virtualKeyboard.addEventListener("geometrychange", this.setVH); - } - - this.activeWindow = window.visualViewport || window; - this.activeWindow.addEventListener("resize", this.setVH); - this.setVH(); - } - - willDestroyElement() { - super.willDestroyElement(...arguments); - - this.activeWindow?.removeEventListener("resize", this.setVH); - lastVH = null; - - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.removeEventListener( - "geometrychange", - this.setVH - ); - } - } - - @bind - setVH() { - if (isZoomed()) { - return; - } - - let height; - if ("virtualKeyboard" in navigator) { - height = - window.visualViewport.height - - navigator.virtualKeyboard.boundingRect.height; - } else { - height = this.activeWindow?.height || window.innerHeight; - } - - const vh = height * 0.01; - - if (lastVH === vh) { - return; - } - lastVH = vh; - - document.documentElement.style.setProperty(CSS_VAR, `${vh}px`); - } - - #blurActiveElement() { - if (document.activeElement?.blur) { - document.activeElement.blur(); - } - } -} diff --git a/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js b/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js index 90036d4f6c6..5e2fd1a9218 100644 --- a/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js +++ b/plugins/chat/assets/javascripts/discourse/lib/chat-ios-hacks.js @@ -1,7 +1,6 @@ import { next, schedule } from "@ember/runloop"; import { capabilities } from "discourse/services/capabilities"; import discourseLater from "discourse-common/lib/later"; -import isZoomed from "discourse/plugins/chat/discourse/lib/zoom-check"; // since -webkit-overflow-scrolling: touch can't be used anymore to disable momentum scrolling // we use different hacks to work around this @@ -33,21 +32,3 @@ export function stackingContextFix(scrollable, callback) { }); } } - -export function bodyScrollFix(options = {}) { - // when keyboard is visible this will ensure body - // doesn’t scroll out of viewport - if ( - capabilities.isIOS && - document.documentElement.classList.contains("keyboard-visible") && - !isZoomed() - ) { - if (options.delayed) { - setTimeout(() => { - document.documentElement.scrollTo(0, 0); - }, 200); - } else { - document.documentElement.scrollTo(0, 0); - } - } -} diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js index 2057acee108..89ff9389244 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-channel-composer.js @@ -1,6 +1,8 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; import Service, { service } from "@ember/service"; +import { disableBodyScroll } from "discourse/lib/body-scroll-lock"; export default class ChatChannelComposer extends Service { @service chat; @@ -9,12 +11,31 @@ export default class ChatChannelComposer extends Service { @service router; @service("chat-thread-composer") threadComposer; @service loadingSlider; + @service capabilities; + @service appEvents; @tracked textarea; + @tracked scrollable; + + init() { + super.init(...arguments); + this.appEvents.on("discourse:focus-changed", this, this.blur); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off("discourse:focus-changed", this, this.blur); + } @action focus(options = {}) { this.textarea?.focus(options); + + schedule("afterRender", () => { + if (this.capabilities.isIOS && !this.capabilities.isIpadOS) { + disableBodyScroll(this.scrollable, { reverse: true }); + } + }); } @action diff --git a/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js index 03ee77a23f0..7eff7352dea 100644 --- a/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js +++ b/plugins/chat/assets/javascripts/discourse/services/chat-thread-composer.js @@ -1,15 +1,36 @@ import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; +import { schedule } from "@ember/runloop"; import Service, { service } from "@ember/service"; +import { disableBodyScroll } from "discourse/lib/body-scroll-lock"; export default class ChatThreadComposer extends Service { @service chat; + @service capabilities; + @service appEvents; @tracked textarea; + @tracked scrollable; + + init() { + super.init(...arguments); + this.appEvents.on("discourse:focus-changed", this, this.blur); + } + + willDestroy() { + super.willDestroy(...arguments); + this.appEvents.off("discourse:focus-changed", this, this.blur); + } @action focus(options = {}) { this.textarea?.focus(options); + + schedule("afterRender", () => { + if (this.capabilities.isIOS && !this.capabilities.isIpadOS) { + disableBodyScroll(this.scrollable, { reverse: true }); + } + }); } @action diff --git a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs index f8132b4803f..292f53880b8 100644 --- a/plugins/chat/assets/javascripts/discourse/templates/chat.hbs +++ b/plugins/chat/assets/javascripts/discourse/templates/chat.hbs @@ -1,7 +1,5 @@
- - {{#if this.chat.sidebarActive}}
{{outlet}} diff --git a/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss index 0a82deb1783..1092dc8f8dd 100644 --- a/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss +++ b/plugins/chat/assets/stylesheets/common/chat-height-mixin.scss @@ -2,7 +2,7 @@ // desktop and mobile // -1px is for the bottom border of the chat navbar $base-height: calc( - var(--chat-vh, 1vh) * 100 - var(--header-offset, 0px) - 1px - $inset + var(--composer-vh, 1vh) * 100 - var(--header-offset, 0px) - 1px - $inset ); height: calc($base-height - var(--composer-height, 0px));