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 <j.jaffeux@gmail.com>
This commit is contained in:
Penar Musaraj
2025-01-10 11:00:15 -05:00
committed by GitHub
parent ce97e51b63
commit 07e5f8907e
24 changed files with 282 additions and 524 deletions

View File

@ -3,7 +3,6 @@ import { cancel, schedule, throttle } from "@ember/runloop";
import { classNameBindings } from "@ember-decorators/component"; import { classNameBindings } from "@ember-decorators/component";
import { observes } from "@ember-decorators/object"; import { observes } from "@ember-decorators/object";
import { headerOffset } from "discourse/lib/offset-calculator"; import { headerOffset } from "discourse/lib/offset-calculator";
import positioningWorkaround from "discourse/lib/safari-hacks";
import { isiPad } from "discourse/lib/utilities"; import { isiPad } from "discourse/lib/utilities";
import Composer from "discourse/models/composer"; import Composer from "discourse/models/composer";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
@ -68,13 +67,6 @@ export default class ComposerBody extends Component {
}, 1000); }, 1000);
} }
@observes("composeState")
disableFullscreen() {
if (this.composeState !== Composer.OPEN && positioningWorkaround.blur) {
positioningWorkaround.blur();
}
}
setupComposerResizeEvents() { setupComposerResizeEvents() {
this.origComposerSize = 0; this.origComposerSize = 0;
this.lastMousePos = 0; this.lastMousePos = 0;
@ -184,8 +176,6 @@ export default class ComposerBody extends Component {
triggerOpen(); triggerOpen();
} }
}); });
positioningWorkaround(this.element);
} }
willDestroyElement() { willDestroyElement() {

View File

@ -211,6 +211,7 @@
{{/unless}} {{/unless}}
</div> </div>
</ComposerEditor> </ComposerEditor>
<DComposerPosition />
<span> <span>
<PluginOutlet <PluginOutlet

View File

@ -0,0 +1,74 @@
import Component from "@glimmer/component";
import { later } from "@ember/runloop";
export default class DComposerPosition extends Component {
// This component contains two composer positioning adjustments
// for Safari iOS/iPad and Firefox on Android
// The fixes here go together with styling in base/compose.css
constructor() {
super(...arguments);
const html = document.documentElement;
if (
html.classList.contains("mobile-device") ||
html.classList.contains("ipados-device")
) {
window.addEventListener("scroll", this._correctScrollPosition);
this._correctScrollPosition();
const editor = document.querySelector(".d-editor-input");
editor?.addEventListener("touchmove", this._textareaTouchMove);
}
}
willDestroy() {
super.willDestroy(...arguments);
const html = document.documentElement;
if (
html.classList.contains("mobile-device") ||
html.classList.contains("ipados-device")
) {
window.removeEventListener("scroll", this._correctScrollPosition);
const editor = document.querySelector(".d-editor-input");
editor?.removeEventListener("touchmove", this._textareaTouchMove);
}
}
_correctScrollPosition() {
// In some rare cases, when quoting a large text or
// when editing a long topic, Safari/Firefox will scroll
// the body so that the input/textarea is centered
// This pushes the fixed element offscreen
// Here we detect when the composer's top position is above the window's
// current scroll offset and correct it
later(() => {
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();
}
}
}
}

View File

@ -1,18 +1,17 @@
import Component from "@glimmer/component"; import Component from "@glimmer/component";
import { cancel, scheduleOnce } from "@ember/runloop"; import { cancel, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import { clearAllBodyScrollLocks } from "discourse/lib/body-scroll-lock";
import isZoomed from "discourse/lib/zoom-check"; import isZoomed from "discourse/lib/zoom-check";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
import { bind } from "discourse-common/utils/decorators"; import { bind } from "discourse-common/utils/decorators";
const KEYBOARD_DETECT_THRESHOLD = 150;
export default class DVirtualHeight extends Component { export default class DVirtualHeight extends Component {
@service site; @service site;
@service capabilities; @service capabilities;
@service appEvents; @service appEvents;
MIN_THRESHOLD = 120;
constructor() { constructor() {
super(...arguments); super(...arguments);
@ -24,7 +23,6 @@ export default class DVirtualHeight extends Component {
return; return;
} }
// TODO: Handle device rotation
this.windowInnerHeight = window.innerHeight; this.windowInnerHeight = window.innerHeight;
scheduleOnce("afterRender", this, this.debouncedOnViewportResize); scheduleOnce("afterRender", this, this.debouncedOnViewportResize);
@ -33,13 +31,6 @@ export default class DVirtualHeight extends Component {
"resize", "resize",
this.debouncedOnViewportResize this.debouncedOnViewportResize
); );
if ("virtualKeyboard" in navigator) {
navigator.virtualKeyboard.overlaysContent = true;
navigator.virtualKeyboard.addEventListener(
"geometrychange",
this.debouncedOnViewportResize
);
}
} }
willDestroy() { willDestroy() {
@ -51,13 +42,6 @@ export default class DVirtualHeight extends Component {
"resize", "resize",
this.debouncedOnViewportResize this.debouncedOnViewportResize
); );
if ("virtualKeyboard" in navigator) {
navigator.virtualKeyboard.overlaysContent = false;
navigator.virtualKeyboard.removeEventListener(
"geometrychange",
this.debouncedOnViewportResize
);
}
} }
setVH() { setVH() {
@ -65,18 +49,10 @@ export default class DVirtualHeight extends Component {
return; return;
} }
let height; const height = Math.round(window.visualViewport.height);
if ("virtualKeyboard" in navigator) {
height =
window.visualViewport.height -
navigator.virtualKeyboard.boundingRect.height;
} else {
const activeWindow = window.visualViewport || window;
height = activeWindow?.height || window.innerHeight;
}
if (this.previousHeight && Math.abs(this.previousHeight - height) <= 1) { if (this.previousHeight && Math.abs(this.previousHeight - height) <= 1) {
return; return false;
} }
this.previousHeight = height; this.previousHeight = height;
@ -94,48 +70,27 @@ export default class DVirtualHeight extends Component {
@bind @bind
onViewportResize() { onViewportResize() {
this.setVH(); const setVHresult = this.setVH();
if (setVHresult === false) {
return;
}
const docEl = document.documentElement;
let keyboardVisible = false; 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 = let viewportWindowDiff =
this.windowInnerHeight - window.visualViewport.height; 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 if (viewportWindowDiff > this.MIN_THRESHOLD) {
// accessory bar height is 55px, using 75 allows a small buffer keyboardVisible = true;
if (this.capabilities.isIpadOS) {
document.documentElement.style.setProperty(
"--composer-ipad-padding",
`${viewportWindowDiff < 75 ? viewportWindowDiff : 0}px`
);
}
} }
this.appEvents.trigger("keyboard-visibility-change", keyboardVisible); this.appEvents.trigger("keyboard-visibility-change", keyboardVisible);
keyboardVisible keyboardVisible
? document.documentElement.classList.add("keyboard-visible") ? docEl.classList.add("keyboard-visible")
: document.documentElement.classList.remove("keyboard-visible"); : docEl.classList.remove("keyboard-visible");
if (!keyboardVisible) {
clearAllBodyScrollLocks();
}
} }
} }

View File

@ -2,7 +2,6 @@ import { schedule, scheduleOnce } from "@ember/runloop";
import { service } from "@ember/service"; import { service } from "@ember/service";
import MountWidget from "discourse/components/mount-widget"; import MountWidget from "discourse/components/mount-widget";
import offsetCalculator from "discourse/lib/offset-calculator"; import offsetCalculator from "discourse/lib/offset-calculator";
import { isWorkaroundActive } from "discourse/lib/safari-hacks";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import { cloak, uncloak } from "discourse/widgets/post-stream"; import { cloak, uncloak } from "discourse/widgets/post-stream";
import discourseDebounce from "discourse-common/lib/debounce"; import discourseDebounce from "discourse-common/lib/debounce";
@ -64,11 +63,7 @@ export default class ScrollingPostStream extends MountWidget {
return; return;
} }
if ( if (document.webkitFullscreenElement || document.fullscreenElement) {
isWorkaroundActive() ||
document.webkitFullscreenElement ||
document.fullscreenElement
) {
return; return;
} }

View File

@ -17,7 +17,8 @@ const MIN_HEIGHT_TIMELINE = 325;
@classNameBindings( @classNameBindings(
"info.topicProgressExpanded:topic-progress-expanded", "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 { export default class TopicNavigation extends Component {
@service modal; @service modal;
@ -33,7 +34,10 @@ export default class TopicNavigation extends Component {
if (this._lastTopicId !== this.topic.id) { if (this._lastTopicId !== this.topic.id) {
this._lastTopicId = this.topic.id; this._lastTopicId = this.topic.id;
this.set("canRender", false); 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.mediaQuery.matches && verticalSpace > MIN_HEIGHT_TIMELINE
); );
} }
this.info.set(
"withTopicProgress",
!this.info.renderTimeline && this.topic.posts_count > 1
);
} }
@bind @bind

View File

@ -3,16 +3,12 @@ import { action } from "@ember/object";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import { scheduleOnce } from "@ember/runloop"; import { scheduleOnce } from "@ember/runloop";
import { classNameBindings } from "@ember-decorators/component"; import { classNameBindings } from "@ember-decorators/component";
import discourseLater from "discourse-common/lib/later"; import discourseComputed from "discourse-common/utils/decorators";
import discourseComputed, { bind } from "discourse-common/utils/decorators";
const CSS_TRANSITION_DELAY = 500; @classNameBindings("docked")
@classNameBindings("docked", "withTransitions")
export default class TopicProgress extends Component { export default class TopicProgress extends Component {
elementId = "topic-progress-wrapper"; elementId = "topic-progress-wrapper";
docked = false; docked = false;
withTransitions = null;
progressPosition = null; progressPosition = null;
@alias("topic.postStream") postStream; @alias("topic.postStream") postStream;
@ -69,107 +65,21 @@ export default class TopicProgress extends Component {
didInsertElement() { didInsertElement() {
super.didInsertElement(...arguments); super.didInsertElement(...arguments);
this.appEvents this.appEvents.on("topic:current-post-scrolled", this, this._topicScrolled);
.on("composer:resized", this, this._composerEvent)
.on("topic:current-post-scrolled", this, this._topicScrolled);
if (this.prevEvent) { if (this.prevEvent) {
scheduleOnce("afterRender", this, this._topicScrolled, 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() { willDestroyElement() {
super.willDestroyElement(...arguments); super.willDestroyElement(...arguments);
this._topicBottomObserver?.disconnect(); this.appEvents.off(
this.appEvents "topic:current-post-scrolled",
.off("composer:resized", this, this._composerEvent) this,
.off("topic:current-post-scrolled", this, this._topicScrolled); 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`);
}
}
}
}
click(e) { click(e) {
if (e.target.closest("#topic-progress")) { if (e.target.closest("#topic-progress")) {

View File

@ -8,5 +8,13 @@ export default {
} else { } else {
html.classList.add("no-touch", "discourse-no-touch"); html.classList.add("no-touch", "discourse-no-touch");
} }
if (caps.isIpadOS) {
html.classList.add("ipados-device");
}
if (caps.isIOS) {
html.classList.add("ios-device");
}
}, },
}; };

View File

@ -41,7 +41,7 @@ const isIosDevice =
(/iP(ad|hone|od)/.test(window.navigator.platform) || (/iP(ad|hone|od)/.test(window.navigator.platform) ||
(window.navigator.platform === "MacIntel" && (window.navigator.platform === "MacIntel" &&
window.navigator.maxTouchPoints > 1)); window.navigator.maxTouchPoints > 1));
let locks = []; export let locks = [];
let locksIndex = /* @__PURE__ */ new Map(); let locksIndex = /* @__PURE__ */ new Map();
let documentListenerAdded = false; let documentListenerAdded = false;
let initialClientY = -1; let initialClientY = -1;

View File

@ -1,14 +1,5 @@
import positioningWorkaround from "discourse/lib/safari-hacks";
import { helperContext } from "discourse-common/lib/helpers";
export default function (element) { 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; const len = element.value.length;
element.setSelectionRange(len, len); element.setSelectionRange(len, len);

View File

@ -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;

View File

@ -5,6 +5,7 @@ import { modifier } from "ember-modifier";
import { import {
disableBodyScroll, disableBodyScroll,
enableBodyScroll, enableBodyScroll,
locks,
} from "discourse/lib/body-scroll-lock"; } from "discourse/lib/body-scroll-lock";
@tagName("") @tagName("")
@ -16,20 +17,18 @@ export default class SelectKitCollection extends Component {
return; return;
} }
// when opened a modal will disable all scroll but itself const isChildOfLock = locks.some((lock) =>
// this code is whitelisting the collection to ensure it can be scrolled in this case lock.targetElement.contains(element)
// 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 = if (isChildOfLock) {
document.documentElement.classList.contains("modal-open"); disableBodyScroll(element);
if (!isModalOpen) {
return;
} }
disableBodyScroll(element);
return () => { return () => {
if (isChildOfLock) {
enableBodyScroll(element); enableBodyScroll(element);
}
}; };
}); });
} }

View File

@ -4,18 +4,20 @@ html.composer-open {
transition: padding-bottom 250ms ease; transition: padding-bottom 250ms ease;
} }
} }
#reply-control { #reply-control {
position: fixed; position: fixed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
bottom: 0; bottom: 0;
height: 0;
right: 0; right: 0;
left: 0; left: 0;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
max-width: $reply-area-max-width; max-width: $reply-area-max-width;
width: 100%; width: 100%;
height: 0;
min-height: 0;
&.hide-preview { &.hide-preview {
max-width: 740px; max-width: 740px;
@ -37,19 +39,14 @@ html.composer-open {
min-width: 0; min-width: 0;
} }
z-index: z("composer", "content"); z-index: z("composer", "content");
transition: height 250ms ease, background 250ms ease, transform 250ms ease, transition: height 0.2s, max-width 0.2s, padding-bottom 0.2s, top 0.2s,
max-width 250ms ease, padding-bottom 250ms ease; transform 0.2s, min-height 0.2s;
background-color: var(--secondary); background-color: var(--secondary);
box-shadow: var(--shadow-composer); box-shadow: var(--shadow-composer);
.reply-area { .reply-area {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
&.with-form-template {
max-height: 100%;
box-sizing: border-box;
}
} }
.saving-text, .saving-text,
@ -68,11 +65,10 @@ html.composer-open {
} }
&.open { &.open {
box-sizing: border-box;
height: var(--composer-height); height: var(--composer-height);
} max-height: calc(100vh - var(--header-offset, 4em));
padding-bottom: var(--composer-ipad-padding);
&.closed {
height: 0 !important;
} }
&.draft, &.draft,
@ -104,6 +100,7 @@ html.composer-open {
display: flex; display: flex;
.draft-text { .draft-text {
display: block; display: block;
@include ellipsis;
} }
.grippie, .grippie,
.saving-text { .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 { .toggle-preview {
margin-left: auto; margin-left: auto;
transition: all 0.33s ease-out; transition: all 0.33s ease-out;
@ -659,3 +617,103 @@ body:not(.ios-safari-composer-hacks) {
.draft-error { .draft-error {
color: var(--danger); 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;
}
}

View File

@ -16,10 +16,6 @@
position: relative; position: relative;
} }
html.keyboard-visible body.ios-safari-composer-hacks & {
height: calc(var(--composer-vh, 1vh) * 100);
}
&__container { &__container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -40,23 +40,19 @@
} }
} }
#topic-progress-wrapper { .with-topic-progress {
position: fixed; position: sticky;
&.docked { bottom: calc(env(safe-area-inset-bottom) + var(--composer-height, 0px));
position: initial; z-index: z("timeline");
}
#topic-progress-wrapper {
&.docked {
.toggle-admin-menu { .toggle-admin-menu {
display: none; display: none;
} }
} }
bottom: 0;
html:not(.footer-nav-visible) & {
bottom: env(safe-area-inset-bottom);
}
right: 10px;
z-index: z("timeline");
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
overflow: hidden; overflow: hidden;
@ -65,14 +61,6 @@
border: 0; border: 0;
} }
&.with-transitions {
transition: bottom 0.2s, margin-bottom 0.2s;
#topic-progress .bg {
transition: width 0.5s;
}
}
&:not(.docked) { &:not(.docked) {
@media screen and (min-width: $reply-area-max-width) { @media screen and (min-width: $reply-area-max-width) {
right: calc(50%); // right side of composer right: calc(50%); // right side of composer

View File

@ -91,6 +91,9 @@
grid-template-areas: "posts"; grid-template-areas: "posts";
grid-template-columns: auto; grid-template-columns: auto;
.topic-navigation { .topic-navigation {
display: flex;
justify-content: flex-end;
align-items: center;
grid-area: posts; grid-area: posts;
grid-row: 3; grid-row: 3;
width: auto; width: auto;

View File

@ -19,9 +19,13 @@ html.footer-nav-visible {
padding-bottom: 0px; padding-bottom: 0px;
} }
#topic-progress-wrapper:not(.docked) { .with-topic-progress {
margin-bottom: calc(var(--footer-nav-height) + env(safe-area-inset-bottom)); bottom: calc(
var(--footer-nav-height) + env(safe-area-inset-bottom) +
var(--composer-height, 0px)
);
} }
.posts-filtered-notice { .posts-filtered-notice {
transition: all linear 0.1s; transition: all linear 0.1s;
bottom: calc(var(--footer-nav-height) + env(safe-area-inset-bottom)); 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 padding-bottom: 0; // resets safe-area-inset-bottom
} }
#reply-control,
#reply-control.fullscreen {
z-index: z("ipad-header-nav") + 1;
}
.d-header-wrap { .d-header-wrap {
top: var(--footer-nav-height); top: var(--footer-nav-height);
} }

View File

@ -7,8 +7,6 @@
} }
#reply-control { #reply-control {
z-index: z("mobile-composer");
.reply-area { .reply-area {
padding: 6px; padding: 6px;
padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)"); padding-bottom: unquote("max(env(safe-area-inset-bottom), 6px)");
@ -16,19 +14,12 @@
} }
&.open { &.open {
height: 250px; z-index: z("mobile-composer");
&.edit-title {
height: 100%;
}
} }
.keyboard-visible &.open { .keyboard-visible &.open .reply-area {
top: 0px;
height: calc(var(--composer-vh, 1vh) * 100);
.reply-area {
padding-bottom: 6px; padding-bottom: 6px;
} }
}
.reply-to { .reply-to {
justify-content: space-between; justify-content: space-between;
@ -64,17 +55,10 @@
} }
&.draft { &.draft {
z-index: z("footer-nav") + 1;
padding-bottom: env(safe-area-inset-bottom);
.toggle-toolbar, .toggle-toolbar,
.toggle-minimize { .toggle-minimize {
top: 6px; top: 6px;
} }
.draft-text {
width: calc(100% - 40px);
@include ellipsis;
}
} }
#reply-title { #reply-title {
@ -143,8 +127,9 @@
display: none; display: none;
} }
} }
.d-editor-preview-wrapper { .d-editor-preview-wrapper {
position: fixed; position: absolute;
z-index: z("fullscreen"); z-index: z("fullscreen");
top: 0; top: 0;
bottom: 0; bottom: 0;
@ -160,6 +145,10 @@
margin-bottom: 40px; margin-bottom: 40px;
} }
} }
.composer-controls {
display: none;
}
} }
&.hide-preview { &.hide-preview {

View File

@ -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 { html:not(.anon) #topic-footer-buttons {
.topic-footer-main-buttons { .topic-footer-main-buttons {
width: 100%; width: 100%;

View File

@ -175,7 +175,6 @@ module ApplicationHelper
list = [] list = []
list << (mobile_view? ? "mobile-view" : "desktop-view") list << (mobile_view? ? "mobile-view" : "desktop-view")
list << (mobile_device? ? "mobile-device" : "not-mobile-device") list << (mobile_device? ? "mobile-device" : "not-mobile-device")
list << "ios-device" if ios_device?
list << "rtl" if rtl? list << "rtl" if rtl?
list << text_size_class list << text_size_class
list << "anon" unless current_user list << "anon" unless current_user
@ -446,10 +445,6 @@ module ApplicationHelper
MobileDetection.mobile_device?(request.user_agent) MobileDetection.mobile_device?(request.user_agent)
end end
def ios_device?
MobileDetection.ios_device?(request.user_agent)
end
def customization_disabled? def customization_disabled?
request.env[ApplicationController::NO_THEMES] request.env[ApplicationController::NO_THEMES]
end end

View File

@ -8,7 +8,7 @@
<%- end %> <%- end %>
<%= discourse_theme_color_meta_tags %> <%= discourse_theme_color_meta_tags %>
<%= discourse_color_scheme_meta_tag %> <%= discourse_color_scheme_meta_tag %>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover, interactive-widget=resizes-content">
<%- if Discourse.base_path.present? %> <%- if Discourse.base_path.present? %>
<meta name="discourse-base-uri" content="<%= Discourse.base_path %>"> <meta name="discourse-base-uri" content="<%= Discourse.base_path %>">
<% end %> <% end %>

View File

@ -20,10 +20,6 @@ module MobileDetection
end end
end end
def self.ios_device?(user_agent)
user_agent =~ /iPad|iPhone|iPod/
end
MODERN_MOBILE_REGEX = MODERN_MOBILE_REGEX =
%r{ %r{
\(.*iPhone\ OS\ 1[5-9].*\)| \(.*iPhone\ OS\ 1[5-9].*\)|

View File

@ -1 +0,0 @@
<TopicPresenceDisplay @topic={{@outletArgs.model}} />

View File

@ -1,5 +1,5 @@
.topic-above-footer-buttons-outlet.presence { .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; margin: var(--below-topic-margin) 0;
} }
@ -7,6 +7,9 @@
background-color: var(--secondary); background-color: var(--secondary);
color: var(--primary-medium); color: var(--primary-medium);
display: flex; display: flex;
padding: 0.5em;
padding-left: 0;
border-radius: var(--d-border-radius);
span.presence-text { span.presence-text {
align-items: center; align-items: center;
@ -54,16 +57,8 @@
} }
} }
.composer-fields .presence-users { .reply-to .presence-users {
overflow: hidden; padding: unset;
flex-shrink: 1;
.presence-avatars {
flex-wrap: nowrap;
}
}
.mobile-view .composer-fields .presence-users .description {
display: none;
} }
// TMP: RTL overrides // TMP: RTL overrides
@ -71,18 +66,6 @@
span.presence-text { span.presence-text {
margin-right: 8px; 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 // Always hide the "Topic Presence" in the topic timeline
@ -90,14 +73,12 @@
display: none; display: none;
} }
// Hide the "Topic Presence" in the topic footer when the timeline is hidden // When topic progress is visible in the posts grid area and is sticky,
body:has(.topic-navigation.with-topic-progress) // 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 { .topic-above-footer-buttons-outlet.presence {
display: none; margin-top: -3.2em;
} margin-right: 8em;
}
.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;
} }