From 07e5f8907e2a8de27c42c2d6f5a02fc153212288 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Fri, 10 Jan 2025 11:00:15 -0500 Subject: [PATCH] DEV: Consolidate mobile positioning strategies on mobile and iPad (#30241) This removes some longstanding Safari iOS positioning hacks and refactors the mobile positioning strategy across Safari, Chrome and Firefox. See PR descriptions for more details. Co-authored-by: Joffrey JAFFEUX --- .../discourse/app/components/composer-body.js | 10 -- .../app/components/composer-container.hbs | 1 + .../app/components/d-composer-position.gjs | 74 ++++++++ .../app/components/d-virtual-height.gjs | 81 ++------- .../app/components/scrolling-post-stream.js | 7 +- .../app/components/topic-navigation.js | 13 +- .../app/components/topic-progress.js | 106 +---------- .../sniff-capabilities.js | 8 + .../discourse/app/lib/body-scroll-lock.js | 2 +- .../discourse/app/lib/put-cursor-at-end.js | 11 +- .../discourse/app/lib/safari-hacks.js | 167 ------------------ .../select-kit/select-kit-collection.js | 21 ++- .../stylesheets/common/base/compose.scss | 160 +++++++++++------ app/assets/stylesheets/common/base/modal.scss | 4 - .../stylesheets/common/base/topic-footer.scss | 26 +-- app/assets/stylesheets/common/base/topic.scss | 3 + .../common/components/footer-nav.scss | 13 +- app/assets/stylesheets/mobile/compose.scss | 29 +-- .../stylesheets/mobile/topic-footer.scss | 11 -- app/helpers/application_helper.rb | 5 - app/views/layouts/_head.html.erb | 2 +- lib/mobile_detection.rb | 4 - .../topic-navigation-bottom/presence.hbs | 1 - .../assets/stylesheets/presence.scss | 47 ++--- 24 files changed, 282 insertions(+), 524 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/d-composer-position.gjs delete mode 100644 app/assets/javascripts/discourse/app/lib/safari-hacks.js delete mode 100644 plugins/discourse-presence/assets/javascripts/discourse/connectors/topic-navigation-bottom/presence.hbs diff --git a/app/assets/javascripts/discourse/app/components/composer-body.js b/app/assets/javascripts/discourse/app/components/composer-body.js index af2bca42844..24cb6017a00 100644 --- a/app/assets/javascripts/discourse/app/components/composer-body.js +++ b/app/assets/javascripts/discourse/app/components/composer-body.js @@ -3,7 +3,6 @@ import { cancel, schedule, throttle } from "@ember/runloop"; import { classNameBindings } from "@ember-decorators/component"; import { observes } from "@ember-decorators/object"; import { headerOffset } from "discourse/lib/offset-calculator"; -import positioningWorkaround from "discourse/lib/safari-hacks"; import { isiPad } from "discourse/lib/utilities"; import Composer from "discourse/models/composer"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -68,13 +67,6 @@ export default class ComposerBody extends Component { }, 1000); } - @observes("composeState") - disableFullscreen() { - if (this.composeState !== Composer.OPEN && positioningWorkaround.blur) { - positioningWorkaround.blur(); - } - } - setupComposerResizeEvents() { this.origComposerSize = 0; this.lastMousePos = 0; @@ -184,8 +176,6 @@ export default class ComposerBody extends Component { triggerOpen(); } }); - - positioningWorkaround(this.element); } willDestroyElement() { diff --git a/app/assets/javascripts/discourse/app/components/composer-container.hbs b/app/assets/javascripts/discourse/app/components/composer-container.hbs index 21782830dca..ad0a829ca84 100644 --- a/app/assets/javascripts/discourse/app/components/composer-container.hbs +++ b/app/assets/javascripts/discourse/app/components/composer-container.hbs @@ -211,6 +211,7 @@ {{/unless}} + { + const el = document.querySelector("#reply-control"); + const rect = el.getBoundingClientRect(); + + if (rect.top < -1) { + const scrollAmount = window.scrollY + rect.top; + + window.scrollTo({ + top: scrollAmount, + behavior: "instant", + }); + } + }, 150); + } + + _textareaTouchMove(event) { + // This is an alternative to locking up the body + // It stops scrolling in the given element from bubbling up to the body + // when the textarea does not have any content to scroll + if (event.target) { + const notScrollable = + event.target.scrollHeight <= event.target.clientHeight; + if (notScrollable) { + event.preventDefault(); + event.stopPropagation(); + } + } + } +} diff --git a/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs b/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs index 4ab506df947..06dcd36f184 100644 --- a/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs +++ b/app/assets/javascripts/discourse/app/components/d-virtual-height.gjs @@ -1,18 +1,17 @@ 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; + MIN_THRESHOLD = 120; + constructor() { super(...arguments); @@ -24,7 +23,6 @@ export default class DVirtualHeight extends Component { return; } - // TODO: Handle device rotation this.windowInnerHeight = window.innerHeight; scheduleOnce("afterRender", this, this.debouncedOnViewportResize); @@ -33,13 +31,6 @@ export default class DVirtualHeight extends Component { "resize", this.debouncedOnViewportResize ); - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.overlaysContent = true; - navigator.virtualKeyboard.addEventListener( - "geometrychange", - this.debouncedOnViewportResize - ); - } } willDestroy() { @@ -51,13 +42,6 @@ export default class DVirtualHeight extends Component { "resize", this.debouncedOnViewportResize ); - if ("virtualKeyboard" in navigator) { - navigator.virtualKeyboard.overlaysContent = false; - navigator.virtualKeyboard.removeEventListener( - "geometrychange", - this.debouncedOnViewportResize - ); - } } setVH() { @@ -65,18 +49,10 @@ export default class DVirtualHeight extends Component { 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 height = Math.round(window.visualViewport.height); if (this.previousHeight && Math.abs(this.previousHeight - height) <= 1) { - return; + return false; } this.previousHeight = height; @@ -94,48 +70,27 @@ export default class DVirtualHeight extends Component { @bind onViewportResize() { - this.setVH(); + const setVHresult = this.setVH(); + + if (setVHresult === false) { + return; + } + + const docEl = document.documentElement; 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` - ); - } + let viewportWindowDiff = + this.windowInnerHeight - window.visualViewport.height; + + if (viewportWindowDiff > this.MIN_THRESHOLD) { + keyboardVisible = true; } this.appEvents.trigger("keyboard-visibility-change", keyboardVisible); keyboardVisible - ? document.documentElement.classList.add("keyboard-visible") - : document.documentElement.classList.remove("keyboard-visible"); - - if (!keyboardVisible) { - clearAllBodyScrollLocks(); - } + ? docEl.classList.add("keyboard-visible") + : docEl.classList.remove("keyboard-visible"); } } diff --git a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js index 29063e90623..6a67001b634 100644 --- a/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js +++ b/app/assets/javascripts/discourse/app/components/scrolling-post-stream.js @@ -2,7 +2,6 @@ import { schedule, scheduleOnce } from "@ember/runloop"; import { service } from "@ember/service"; import MountWidget from "discourse/components/mount-widget"; import offsetCalculator from "discourse/lib/offset-calculator"; -import { isWorkaroundActive } from "discourse/lib/safari-hacks"; import DiscourseURL from "discourse/lib/url"; import { cloak, uncloak } from "discourse/widgets/post-stream"; import discourseDebounce from "discourse-common/lib/debounce"; @@ -64,11 +63,7 @@ export default class ScrollingPostStream extends MountWidget { return; } - if ( - isWorkaroundActive() || - document.webkitFullscreenElement || - document.fullscreenElement - ) { + if (document.webkitFullscreenElement || document.fullscreenElement) { return; } diff --git a/app/assets/javascripts/discourse/app/components/topic-navigation.js b/app/assets/javascripts/discourse/app/components/topic-navigation.js index f6900776d1e..7e659469d89 100644 --- a/app/assets/javascripts/discourse/app/components/topic-navigation.js +++ b/app/assets/javascripts/discourse/app/components/topic-navigation.js @@ -17,7 +17,8 @@ const MIN_HEIGHT_TIMELINE = 325; @classNameBindings( "info.topicProgressExpanded:topic-progress-expanded", - "info.renderTimeline:with-timeline:with-topic-progress" + "info.renderTimeline:with-timeline", + "info.withTopicProgress:with-topic-progress" ) export default class TopicNavigation extends Component { @service modal; @@ -33,7 +34,10 @@ export default class TopicNavigation extends Component { if (this._lastTopicId !== this.topic.id) { this._lastTopicId = this.topic.id; this.set("canRender", false); - next(() => this.set("canRender", true)); + next(() => { + this.set("canRender", true); + this._performCheckSize(); + }); } } @@ -57,6 +61,11 @@ export default class TopicNavigation extends Component { this.mediaQuery.matches && verticalSpace > MIN_HEIGHT_TIMELINE ); } + + this.info.set( + "withTopicProgress", + !this.info.renderTimeline && this.topic.posts_count > 1 + ); } @bind diff --git a/app/assets/javascripts/discourse/app/components/topic-progress.js b/app/assets/javascripts/discourse/app/components/topic-progress.js index 2f4737fa3d1..5f9fddfc002 100644 --- a/app/assets/javascripts/discourse/app/components/topic-progress.js +++ b/app/assets/javascripts/discourse/app/components/topic-progress.js @@ -3,16 +3,12 @@ import { action } from "@ember/object"; import { alias } from "@ember/object/computed"; import { scheduleOnce } from "@ember/runloop"; import { classNameBindings } from "@ember-decorators/component"; -import discourseLater from "discourse-common/lib/later"; -import discourseComputed, { bind } from "discourse-common/utils/decorators"; +import discourseComputed from "discourse-common/utils/decorators"; -const CSS_TRANSITION_DELAY = 500; - -@classNameBindings("docked", "withTransitions") +@classNameBindings("docked") export default class TopicProgress extends Component { elementId = "topic-progress-wrapper"; docked = false; - withTransitions = null; progressPosition = null; @alias("topic.postStream") postStream; @@ -69,106 +65,20 @@ export default class TopicProgress extends Component { didInsertElement() { super.didInsertElement(...arguments); - this.appEvents - .on("composer:resized", this, this._composerEvent) - .on("topic:current-post-scrolled", this, this._topicScrolled); + this.appEvents.on("topic:current-post-scrolled", this, this._topicScrolled); if (this.prevEvent) { scheduleOnce("afterRender", this, this._topicScrolled, this.prevEvent); } - scheduleOnce("afterRender", this, this._startObserver); - - // start CSS transitions a tiny bit later - // to avoid jumpiness on initial topic load - discourseLater(this._addCssTransitions, CSS_TRANSITION_DELAY); } willDestroyElement() { super.willDestroyElement(...arguments); - this._topicBottomObserver?.disconnect(); - this.appEvents - .off("composer:resized", this, this._composerEvent) - .off("topic:current-post-scrolled", this, this._topicScrolled); - } - - @bind - _addCssTransitions() { - if (this.isDestroying || this.isDestroyed) { - return; - } - this.set("withTransitions", true); - } - - _startObserver() { - if ("IntersectionObserver" in window) { - this._topicBottomObserver = this._setupObserver(); - this._topicBottomObserver.observe( - document.querySelector("#topic-bottom") - ); - } - } - - _setupObserver() { - // minimum 50px here ensures element is not docked when - // scrolling down quickly, it causes post stream refresh loop - // on Android - const bottomIntersectionMargin = - document.querySelector("#reply-control")?.clientHeight || 50; - - return new IntersectionObserver(this._intersectionHandler, { - threshold: 1, - rootMargin: `0px 0px -${bottomIntersectionMargin}px 0px`, - }); - } - - _composerEvent() { - // reinitializing needed to account for composer height - // might be no longer necessary if IntersectionObserver API supports dynamic rootMargin - // see https://github.com/w3c/IntersectionObserver/issues/428 - if ("IntersectionObserver" in window) { - this._topicBottomObserver?.disconnect(); - this._startObserver(); - } - } - - @bind - _intersectionHandler(entries) { - if (!this.element || this.isDestroying || this.isDestroyed) { - return; - } - - const composerH = - document.querySelector("#reply-control")?.clientHeight || 0; - - // on desktop, pin this element to the composer - // otherwise the grid layout will change too much when toggling the composer - // and jitter when the viewport is near the topic bottom - if (this.site.desktopView && composerH) { - this.set("docked", false); - this.element.style.setProperty("bottom", `${composerH}px`); - return; - } - - if (entries[0].isIntersecting === true) { - this.set("docked", true); - this.element.style.removeProperty("bottom"); - } else { - if (entries[0].boundingClientRect.top > 0) { - this.set("docked", false); - if (composerH === 0) { - const filteredPostsHeight = - document.querySelector(".posts-filtered-notice")?.clientHeight || 0; - filteredPostsHeight === 0 - ? this.element.style.removeProperty("bottom") - : this.element.style.setProperty( - "bottom", - `${filteredPostsHeight}px` - ); - } else { - this.element.style.setProperty("bottom", `${composerH}px`); - } - } - } + this.appEvents.off( + "topic:current-post-scrolled", + this, + this._topicScrolled + ); } click(e) { diff --git a/app/assets/javascripts/discourse/app/instance-initializers/sniff-capabilities.js b/app/assets/javascripts/discourse/app/instance-initializers/sniff-capabilities.js index b62e50f000a..a72b3edf935 100644 --- a/app/assets/javascripts/discourse/app/instance-initializers/sniff-capabilities.js +++ b/app/assets/javascripts/discourse/app/instance-initializers/sniff-capabilities.js @@ -8,5 +8,13 @@ export default { } else { html.classList.add("no-touch", "discourse-no-touch"); } + + if (caps.isIpadOS) { + html.classList.add("ipados-device"); + } + + if (caps.isIOS) { + html.classList.add("ios-device"); + } }, }; 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 9b157aede2a..f6ea841f6b8 100644 --- a/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js +++ b/app/assets/javascripts/discourse/app/lib/body-scroll-lock.js @@ -41,7 +41,7 @@ const isIosDevice = (/iP(ad|hone|od)/.test(window.navigator.platform) || (window.navigator.platform === "MacIntel" && window.navigator.maxTouchPoints > 1)); -let locks = []; +export let locks = []; let locksIndex = /* @__PURE__ */ new Map(); let documentListenerAdded = false; let initialClientY = -1; diff --git a/app/assets/javascripts/discourse/app/lib/put-cursor-at-end.js b/app/assets/javascripts/discourse/app/lib/put-cursor-at-end.js index 3c5ade9f84c..3fff926badc 100644 --- a/app/assets/javascripts/discourse/app/lib/put-cursor-at-end.js +++ b/app/assets/javascripts/discourse/app/lib/put-cursor-at-end.js @@ -1,14 +1,5 @@ -import positioningWorkaround from "discourse/lib/safari-hacks"; -import { helperContext } from "discourse-common/lib/helpers"; - export default function (element) { - const caps = helperContext().capabilities; - - if (caps.isApple && positioningWorkaround.touchstartEvent) { - positioningWorkaround.touchstartEvent(element); - } else { - element.focus(); - } + element.focus(); const len = element.value.length; element.setSelectionRange(len, len); diff --git a/app/assets/javascripts/discourse/app/lib/safari-hacks.js b/app/assets/javascripts/discourse/app/lib/safari-hacks.js deleted file mode 100644 index 5c31420d65c..00000000000 --- a/app/assets/javascripts/discourse/app/lib/safari-hacks.js +++ /dev/null @@ -1,167 +0,0 @@ -import $ from "jquery"; -import { INPUT_DELAY } from "discourse-common/config/environment"; -import discourseDebounce from "discourse-common/lib/debounce"; -import { helperContext } from "discourse-common/lib/helpers"; -import discourseLater from "discourse-common/lib/later"; - -let workaroundActive = false; - -export function isWorkaroundActive() { - return workaroundActive; -} - -// per http://stackoverflow.com/questions/29001977/safari-in-ios8-is-scrolling-screen-when-fixed-elements-get-focus/29064810 -function positioningWorkaround(fixedElement) { - let caps = helperContext().capabilities; - if (!caps.isIOS) { - return; - } - - document.addEventListener("scroll", () => { - if (!caps.isIpadOS && workaroundActive) { - window.scrollTo(0, 0); - } - }); - - let originalScrollTop = 0; - let lastTouchedElement = null; - - positioningWorkaround.blur = function (evt) { - if (workaroundActive) { - document.body.classList.remove("ios-safari-composer-hacks"); - window.scrollTo(0, originalScrollTop); - evt?.target?.removeEventListener("blur", blurred); - - workaroundActive = false; - } - }; - - let blurredNow = function (evt) { - // we cannot use evt.relatedTarget to get the last focused element in safari iOS - // document.activeElement is also unreliable (iOS does not mark buttons as focused) - // so instead, we store the last touched element and check against it - - // cancel blur event when: - // - switching to another iOS app - // - displaying title field - // - invoking a select-kit dropdown - // - invoking mentions - // - invoking emoji dropdown via : and hitting return - // - invoking a button in the editor toolbar - // - tapping on emoji in the emoji modal - // - tapping on the upload button - // - tapping on the edit reason icon/input - - if ( - lastTouchedElement && - (document.visibilityState === "hidden" || - fixedElement.classList.contains("edit-title") || - lastTouchedElement.classList.contains("select-kit-header") || - lastTouchedElement.closest(".autocomplete") || - (lastTouchedElement.nodeName === "TEXTAREA" && - document.activeElement === lastTouchedElement) || - lastTouchedElement.closest(".d-editor-button-bar") || - lastTouchedElement.classList.contains("emoji") || - lastTouchedElement.closest(".mobile-file-upload") || - lastTouchedElement.closest(".display-edit-reason")) - ) { - return; - } - - positioningWorkaround.blur(evt); - }; - - let blurred = function (evt) { - discourseDebounce(this, blurredNow, evt, INPUT_DELAY); - }; - - let positioningHack = function (evt) { - if (evt === undefined) { - evt = new CustomEvent("no-op"); - } - - // we need this, otherwise changing focus means we never clear - this.addEventListener("blur", blurred); - - // resets focus out of select-kit elements - // might become redundant after select-kit refactoring - fixedElement - .querySelectorAll(".select-kit.is-expanded > button") - .forEach((el) => el.click()); - fixedElement - .querySelectorAll(".select-kit > button.is-focused") - .forEach((el) => el.classList.remove("is-focused")); - - if (window.pageYOffset > 0) { - originalScrollTop = window.pageYOffset; - } - - let delay = caps.isIpadOS ? 350 : 150; - - discourseLater(() => { - if (caps.isIpadOS) { - // disable hacks when using a hardware keyboard - // by default, a hardware keyboard will show the keyboard accessory bar - // whose height is currently 55px (using 75 for a bit of a buffer) - let heightDiff = window.innerHeight - window.visualViewport.height; - if (heightDiff < 75) { - return; - } - } - - // don't trigger keyboard on disabled element (happens when a category is required) - if (this.disabled) { - return; - } - - document.body.classList.add("ios-safari-composer-hacks"); - window.scrollTo(0, 0); - - evt.preventDefault(); - this.focus(); - workaroundActive = true; - }, delay); - }; - - let lastTouched = function (evt) { - if (evt && evt.target) { - lastTouchedElement = evt.target; - } - }; - - function attachTouchStart(elem, fn) { - if (!$(elem).data("listening")) { - elem.addEventListener("touchstart", fn); - $(elem).data("listening", true); - } - } - - function checkForInputs() { - attachTouchStart(fixedElement, lastTouched); - - fixedElement - .querySelectorAll("input[type=text], textarea") - .forEach((el) => { - attachTouchStart(el, positioningHack); - }); - } - - function debouncedCheckForInputs() { - discourseDebounce(checkForInputs, 100); - } - - positioningWorkaround.touchstartEvent = function (element) { - let triggerHack = positioningHack.bind(element); - triggerHack(); - }; - - const observer = new MutationObserver(debouncedCheckForInputs); - observer.observe(fixedElement, { - childList: true, - subtree: true, - attributes: false, - characterData: false, - }); -} - -export default positioningWorkaround; diff --git a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.js b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.js index 232ba39d9cf..cf9ec5c3eeb 100644 --- a/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.js +++ b/app/assets/javascripts/select-kit/addon/components/select-kit/select-kit-collection.js @@ -5,6 +5,7 @@ import { modifier } from "ember-modifier"; import { disableBodyScroll, enableBodyScroll, + locks, } from "discourse/lib/body-scroll-lock"; @tagName("") @@ -16,20 +17,18 @@ export default class SelectKitCollection extends Component { return; } - // when opened a modal will disable all scroll but itself - // this code is whitelisting the collection to ensure it can be scrolled in this case - // however we only want to do this if the modal is open to avoid breaking the scroll on the page - // eg: opening a combobox under a topic shouldn't prevent you to scroll the topic page - const isModalOpen = - document.documentElement.classList.contains("modal-open"); - if (!isModalOpen) { - return; + const isChildOfLock = locks.some((lock) => + lock.targetElement.contains(element) + ); + + if (isChildOfLock) { + disableBodyScroll(element); } - disableBodyScroll(element); - return () => { - enableBodyScroll(element); + if (isChildOfLock) { + enableBodyScroll(element); + } }; }); } diff --git a/app/assets/stylesheets/common/base/compose.scss b/app/assets/stylesheets/common/base/compose.scss index 9bfae4b16d8..0567a0a512c 100644 --- a/app/assets/stylesheets/common/base/compose.scss +++ b/app/assets/stylesheets/common/base/compose.scss @@ -4,18 +4,20 @@ html.composer-open { transition: padding-bottom 250ms ease; } } + #reply-control { position: fixed; display: flex; flex-direction: column; bottom: 0; - height: 0; right: 0; left: 0; margin-left: auto; margin-right: auto; max-width: $reply-area-max-width; width: 100%; + height: 0; + min-height: 0; &.hide-preview { max-width: 740px; @@ -37,19 +39,14 @@ html.composer-open { min-width: 0; } z-index: z("composer", "content"); - transition: height 250ms ease, background 250ms ease, transform 250ms ease, - max-width 250ms ease, padding-bottom 250ms ease; + transition: height 0.2s, max-width 0.2s, padding-bottom 0.2s, top 0.2s, + transform 0.2s, min-height 0.2s; background-color: var(--secondary); box-shadow: var(--shadow-composer); .reply-area { display: flex; flex-direction: column; - - &.with-form-template { - max-height: 100%; - box-sizing: border-box; - } } .saving-text, @@ -68,11 +65,10 @@ html.composer-open { } &.open { + box-sizing: border-box; height: var(--composer-height); - } - - &.closed { - height: 0 !important; + max-height: calc(100vh - var(--header-offset, 4em)); + padding-bottom: var(--composer-ipad-padding); } &.draft, @@ -104,6 +100,7 @@ html.composer-open { display: flex; .draft-text { display: block; + @include ellipsis; } .grippie, .saving-text { @@ -604,45 +601,6 @@ div.ac-wrap { } } -@keyframes transformer { - 90% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -body.ios-safari-composer-hacks { - #main-outlet, - header, - .grippie, - html:not(.fullscreen-composer) & .toggle-fullscreen { - display: none; - } - - #reply-control { - top: 0px; - &.open { - height: calc(var(--composer-vh, 1vh) * 100); - } - } -} - -body:not(.ios-safari-composer-hacks) { - #reply-control.open { - --min-height: 255px; - min-height: var(--min-height); - max-height: calc(100vh - var(--header-offset, 4em)); - &.composer-action-reply { - // we can let the reply composer get a little shorter - min-height: calc(var(--min-height) - 4em); - } - padding-bottom: var(--composer-ipad-padding); - box-sizing: border-box; - } -} - .toggle-preview { margin-left: auto; transition: all 0.33s ease-out; @@ -659,3 +617,103 @@ body:not(.ios-safari-composer-hacks) { .draft-error { color: var(--danger); } + +@keyframes blink_input_opacity_to_prevent_scrolling_when_focus { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +// The composer on mobile is fixed-positioned, same as on desktop because +// goes together with the interactive-widget=resizes-content in the viewport meta tag +// for maximum browser compatibility (especially Firefox and webviews) +// see https://developer.chrome.com/blog/viewport-resize-behavior for context +.ipados-device, +.mobile-device { + #reply-control { + z-index: -1; + + &.open { + z-index: z("mobile-composer"); + } + + &.draft, + &.saving { + z-index: z("ipad-header-nav") + 1; + padding-bottom: env(safe-area-inset-bottom); + } + + .toggle-fullscreen { + display: none; + } + + .submit-panel, + .composer-fields, + .d-editor-button-bar { + // this prevents touch events (i.e. accidental scrolls) from bubbling up + touch-action: none; + } + } + + &.keyboard-visible #reply-control.open { + height: calc(var(--composer-vh, 1vh) * 100); + + .grippie { + display: none; + } + } + + &.composer-open .with-topic-progress { + bottom: calc(var(--composer-height)); + } +} + +.mobile-device { + #reply-control { + .grippie { + display: none; + } + + &.open.show-preview { + height: 70vh; + } + } +} + +.ipados-device { + // this might be overkill + // but on iPad with a physical keyboard the composer is shifted up on scroll + // this adds a solid box shadow below, looks cleaner + #reply-control { + box-shadow: 0 150px 0px 0px var(--secondary); + } +} + +// Safari in iOS/iPad does not handle well a bottom:0 fixed-positioned element, +// especially while the software keyboard is visible, so we top-anchor it here +// and shift it using transform +.ipados-device, +.ios-device { + #reply-control { + // the two properties below are equivalent to bottom: 0 + top: calc(var(--composer-vh, 1vh) * 100); + transform: translateY(-100%); + bottom: unset; + } + + // When an element (input, textearea) gets focus, iOS Safari tries to put it in the center + // by scrolling and zooming. We handle zooming with a meta tag. We used to handle scrolling + // using a complicated JS hack. + // + // However, iOS Safari doesn't scroll when the input has opacity of 0 or is clipped. + // We use this quirk and temporarily hide the element on focus + // + // Source https://gist.github.com/kiding/72721a0553fa93198ae2bb6eefaa3299 + input:focus, + textarea:focus { + animation: blink_input_opacity_to_prevent_scrolling_when_focus 0.01s; + } +} diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss index e1860380856..1ad8a3d848f 100644 --- a/app/assets/stylesheets/common/base/modal.scss +++ b/app/assets/stylesheets/common/base/modal.scss @@ -16,10 +16,6 @@ position: relative; } - html.keyboard-visible body.ios-safari-composer-hacks & { - height: calc(var(--composer-vh, 1vh) * 100); - } - &__container { display: flex; flex-direction: column; diff --git a/app/assets/stylesheets/common/base/topic-footer.scss b/app/assets/stylesheets/common/base/topic-footer.scss index 5f42ee6636c..a39f78d427e 100644 --- a/app/assets/stylesheets/common/base/topic-footer.scss +++ b/app/assets/stylesheets/common/base/topic-footer.scss @@ -40,23 +40,19 @@ } } -#topic-progress-wrapper { - position: fixed; - &.docked { - position: initial; +.with-topic-progress { + position: sticky; + bottom: calc(env(safe-area-inset-bottom) + var(--composer-height, 0px)); + z-index: z("timeline"); +} +#topic-progress-wrapper { + &.docked { .toggle-admin-menu { display: none; } } - bottom: 0; - html:not(.footer-nav-visible) & { - bottom: env(safe-area-inset-bottom); - } - - right: 10px; - z-index: z("timeline"); display: flex; justify-content: flex-end; overflow: hidden; @@ -65,14 +61,6 @@ border: 0; } - &.with-transitions { - transition: bottom 0.2s, margin-bottom 0.2s; - - #topic-progress .bg { - transition: width 0.5s; - } - } - &:not(.docked) { @media screen and (min-width: $reply-area-max-width) { right: calc(50%); // right side of composer diff --git a/app/assets/stylesheets/common/base/topic.scss b/app/assets/stylesheets/common/base/topic.scss index ad1b1bac136..8fdf0fe8f6c 100644 --- a/app/assets/stylesheets/common/base/topic.scss +++ b/app/assets/stylesheets/common/base/topic.scss @@ -91,6 +91,9 @@ grid-template-areas: "posts"; grid-template-columns: auto; .topic-navigation { + display: flex; + justify-content: flex-end; + align-items: center; grid-area: posts; grid-row: 3; width: auto; diff --git a/app/assets/stylesheets/common/components/footer-nav.scss b/app/assets/stylesheets/common/components/footer-nav.scss index ad06d497166..c6811fcde19 100644 --- a/app/assets/stylesheets/common/components/footer-nav.scss +++ b/app/assets/stylesheets/common/components/footer-nav.scss @@ -19,9 +19,13 @@ html.footer-nav-visible { padding-bottom: 0px; } - #topic-progress-wrapper:not(.docked) { - margin-bottom: calc(var(--footer-nav-height) + env(safe-area-inset-bottom)); + .with-topic-progress { + bottom: calc( + var(--footer-nav-height) + env(safe-area-inset-bottom) + + var(--composer-height, 0px) + ); } + .posts-filtered-notice { transition: all linear 0.1s; bottom: calc(var(--footer-nav-height) + env(safe-area-inset-bottom)); @@ -86,11 +90,6 @@ html.footer-nav-ipad { padding-bottom: 0; // resets safe-area-inset-bottom } - #reply-control, - #reply-control.fullscreen { - z-index: z("ipad-header-nav") + 1; - } - .d-header-wrap { top: var(--footer-nav-height); } diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss index dcb7b0228fb..45b110bb039 100644 --- a/app/assets/stylesheets/mobile/compose.scss +++ b/app/assets/stylesheets/mobile/compose.scss @@ -7,8 +7,6 @@ } #reply-control { - z-index: z("mobile-composer"); - .reply-area { padding: 6px; padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)"); @@ -16,18 +14,11 @@ } &.open { - height: 250px; - &.edit-title { - height: 100%; - } + z-index: z("mobile-composer"); } - .keyboard-visible &.open { - top: 0px; - height: calc(var(--composer-vh, 1vh) * 100); - .reply-area { - padding-bottom: 6px; - } + .keyboard-visible &.open .reply-area { + padding-bottom: 6px; } .reply-to { @@ -64,17 +55,10 @@ } &.draft { - z-index: z("footer-nav") + 1; - padding-bottom: env(safe-area-inset-bottom); - .toggle-toolbar, .toggle-minimize { top: 6px; } - .draft-text { - width: calc(100% - 40px); - @include ellipsis; - } } #reply-title { @@ -143,8 +127,9 @@ display: none; } } + .d-editor-preview-wrapper { - position: fixed; + position: absolute; z-index: z("fullscreen"); top: 0; bottom: 0; @@ -160,6 +145,10 @@ margin-bottom: 40px; } } + + .composer-controls { + display: none; + } } &.hide-preview { diff --git a/app/assets/stylesheets/mobile/topic-footer.scss b/app/assets/stylesheets/mobile/topic-footer.scss index 3383de66892..1bfe350d547 100644 --- a/app/assets/stylesheets/mobile/topic-footer.scss +++ b/app/assets/stylesheets/mobile/topic-footer.scss @@ -1,14 +1,3 @@ -.container.posts { - grid-template-areas: "posts"; - .topic-navigation { - display: flex; - justify-content: flex-end; - align-items: center; - grid-area: posts; - grid-row: 3; - } -} - html:not(.anon) #topic-footer-buttons { .topic-footer-main-buttons { width: 100%; diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index c5e4ed7812b..cb38c46574d 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -175,7 +175,6 @@ module ApplicationHelper list = [] list << (mobile_view? ? "mobile-view" : "desktop-view") list << (mobile_device? ? "mobile-device" : "not-mobile-device") - list << "ios-device" if ios_device? list << "rtl" if rtl? list << text_size_class list << "anon" unless current_user @@ -446,10 +445,6 @@ module ApplicationHelper MobileDetection.mobile_device?(request.user_agent) end - def ios_device? - MobileDetection.ios_device?(request.user_agent) - end - def customization_disabled? request.env[ApplicationController::NO_THEMES] end diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb index ed74c484671..abd3add7373 100644 --- a/app/views/layouts/_head.html.erb +++ b/app/views/layouts/_head.html.erb @@ -8,7 +8,7 @@ <%- end %> <%= discourse_theme_color_meta_tags %> <%= discourse_color_scheme_meta_tag %> - + <%- if Discourse.base_path.present? %> <% end %> diff --git a/lib/mobile_detection.rb b/lib/mobile_detection.rb index aa4a8056eb1..8a2e5b094cd 100644 --- a/lib/mobile_detection.rb +++ b/lib/mobile_detection.rb @@ -20,10 +20,6 @@ module MobileDetection end end - def self.ios_device?(user_agent) - user_agent =~ /iPad|iPhone|iPod/ - end - MODERN_MOBILE_REGEX = %r{ \(.*iPhone\ OS\ 1[5-9].*\)| diff --git a/plugins/discourse-presence/assets/javascripts/discourse/connectors/topic-navigation-bottom/presence.hbs b/plugins/discourse-presence/assets/javascripts/discourse/connectors/topic-navigation-bottom/presence.hbs deleted file mode 100644 index f708925aac5..00000000000 --- a/plugins/discourse-presence/assets/javascripts/discourse/connectors/topic-navigation-bottom/presence.hbs +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/plugins/discourse-presence/assets/stylesheets/presence.scss b/plugins/discourse-presence/assets/stylesheets/presence.scss index 83e6002ef47..da305830e84 100644 --- a/plugins/discourse-presence/assets/stylesheets/presence.scss +++ b/plugins/discourse-presence/assets/stylesheets/presence.scss @@ -1,5 +1,5 @@ .topic-above-footer-buttons-outlet.presence { - min-height: 1.8em; // height of the avatars, prevents layout shift + min-height: 2.5em; // height of the avatars, prevents layout shift margin: var(--below-topic-margin) 0; } @@ -7,6 +7,9 @@ background-color: var(--secondary); color: var(--primary-medium); display: flex; + padding: 0.5em; + padding-left: 0; + border-radius: var(--d-border-radius); span.presence-text { align-items: center; @@ -54,16 +57,8 @@ } } -.composer-fields .presence-users { - overflow: hidden; - flex-shrink: 1; - .presence-avatars { - flex-wrap: nowrap; - } -} - -.mobile-view .composer-fields .presence-users .description { - display: none; +.reply-to .presence-users { + padding: unset; } // TMP: RTL overrides @@ -71,18 +66,6 @@ span.presence-text { margin-right: 8px; } - - .composer-fields .presence-users { - right: unset; - left: 95px; - } - - &.mobile-view { - .composer-fields .presence-users { - right: unset; - left: 65px; - } - } } // Always hide the "Topic Presence" in the topic timeline @@ -90,14 +73,12 @@ display: none; } -// Hide the "Topic Presence" in the topic footer when the timeline is hidden -body:has(.topic-navigation.with-topic-progress) - .topic-above-footer-buttons-outlet.presence { - display: none; -} - -.topic-navigation-bottom-outlet.presence { - margin: var(--below-topic-margin) auto 0 0; - min-height: 1.8em; // height of the avatars, prevents layout shift - order: -1; +// When topic progress is visible in the posts grid area and is sticky, +// adjust positioning so presence is on the same line +@media screen and (max-width: 924px) { + body:has(.topic-navigation.with-topic-progress) + .topic-above-footer-buttons-outlet.presence { + margin-top: -3.2em; + margin-right: 8em; + } }