diff --git a/app/assets/javascripts/discourse/components/discourse-topic.js.es6 b/app/assets/javascripts/discourse/components/discourse-topic.js.es6
index 7c6917f4014..4687203e855 100644
--- a/app/assets/javascripts/discourse/components/discourse-topic.js.es6
+++ b/app/assets/javascripts/discourse/components/discourse-topic.js.es6
@@ -2,12 +2,11 @@ import DiscourseURL from "discourse/lib/url";
import AddArchetypeClass from "discourse/mixins/add-archetype-class";
import ClickTrack from "discourse/lib/click-track";
import Scrolling from "discourse/mixins/scrolling";
+import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
import { selectedText } from "discourse/lib/utilities";
import { observes } from "ember-addons/ember-computed-decorators";
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300;
-// Small buffer so that very tiny scrolls don't trigger mobile header switch
-const MOBILE_SCROLL_TOLERANCE = 5;
function highlight(postNumber) {
const $contents = $(`#post_${postNumber} .topic-body`);
@@ -16,222 +15,195 @@ function highlight(postNumber) {
$contents.on("animationend", () => $contents.removeClass("highlighted"));
}
-export default Ember.Component.extend(AddArchetypeClass, Scrolling, {
- userFilters: Ember.computed.alias("topic.userFilters"),
- classNameBindings: [
- "multiSelect",
- "topic.archetype",
- "topic.is_warning",
- "topic.category.read_restricted:read_restricted",
- "topic.deleted:deleted-topic",
- "topic.categoryClass",
- "topic.tagClasses"
- ],
- menuVisible: true,
- SHORT_POST: 1200,
+export default Ember.Component.extend(
+ AddArchetypeClass,
+ Scrolling,
+ MobileScrollDirection,
+ {
+ userFilters: Ember.computed.alias("topic.userFilters"),
+ classNameBindings: [
+ "multiSelect",
+ "topic.archetype",
+ "topic.is_warning",
+ "topic.category.read_restricted:read_restricted",
+ "topic.deleted:deleted-topic",
+ "topic.categoryClass",
+ "topic.tagClasses"
+ ],
+ menuVisible: true,
+ SHORT_POST: 1200,
- postStream: Ember.computed.alias("topic.postStream"),
- archetype: Ember.computed.alias("topic.archetype"),
- dockAt: 0,
+ postStream: Ember.computed.alias("topic.postStream"),
+ archetype: Ember.computed.alias("topic.archetype"),
+ dockAt: 0,
- _lastShowTopic: null,
+ _lastShowTopic: null,
- mobileScrollDirection: null,
- _mobileLastScroll: null,
+ mobileScrollDirection: null,
- @observes("enteredAt")
- _enteredTopic() {
- // Ember is supposed to only call observers when values change but something
- // in our view set up is firing this observer with the same value. This check
- // prevents scrolled from being called twice.
- const enteredAt = this.get("enteredAt");
- if (enteredAt && this.get("lastEnteredAt") !== enteredAt) {
- this._lastShowTopic = null;
- Ember.run.schedule("afterRender", () => this.scrolled());
- this.set("lastEnteredAt", enteredAt);
- }
- },
-
- _highlightPost(postNumber) {
- Ember.run.scheduleOnce("afterRender", null, highlight, postNumber);
- },
-
- _updateTopic(topic) {
- if (topic === null) {
- this._lastShowTopic = false;
- this.appEvents.trigger("header:hide-topic");
- return;
- }
-
- const offset = window.pageYOffset || $("html").scrollTop();
- this._lastShowTopic = this.showTopicInHeader(topic, offset);
-
- if (this._lastShowTopic) {
- this.appEvents.trigger("header:show-topic", topic);
- } else {
- this.appEvents.trigger("header:hide-topic");
- }
- },
-
- didInsertElement() {
- this._super(...arguments);
- this.bindScrolling({ name: "topic-view" });
-
- $(window).on("resize.discourse-on-scroll", () => this.scrolled());
-
- this.$().on(
- "mouseup.discourse-redirect",
- ".cooked a, a.track-link",
- function(e) {
- // bypass if we are selecting stuff
- const selection = window.getSelection && window.getSelection();
- if (selection.type === "Range" || selection.rangeCount > 0) {
- if (selectedText() !== "") {
- return true;
- }
- }
-
- const $target = $(e.target);
- if (
- $target.hasClass("mention") ||
- $target.parents(".expanded-embed").length
- ) {
- return false;
- }
-
- return ClickTrack.trackClick(e);
+ @observes("enteredAt")
+ _enteredTopic() {
+ // Ember is supposed to only call observers when values change but something
+ // in our view set up is firing this observer with the same value. This check
+ // prevents scrolled from being called twice.
+ const enteredAt = this.get("enteredAt");
+ if (enteredAt && this.get("lastEnteredAt") !== enteredAt) {
+ this._lastShowTopic = null;
+ Ember.run.schedule("afterRender", () => this.scrolled());
+ this.set("lastEnteredAt", enteredAt);
}
- );
+ },
- this.appEvents.on("post:highlight", this, "_highlightPost");
+ _highlightPost(postNumber) {
+ Ember.run.scheduleOnce("afterRender", null, highlight, postNumber);
+ },
- this.appEvents.on("header:update-topic", this, "_updateTopic");
- },
-
- willDestroyElement() {
- this._super(...arguments);
- this.unbindScrolling("topic-view");
- $(window).unbind("resize.discourse-on-scroll");
-
- // Unbind link tracking
- this.$().off("mouseup.discourse-redirect", ".cooked a, a.track-link");
-
- this.resetExamineDockCache();
-
- // this happens after route exit, stuff could have trickled in
- this.appEvents.trigger("header:hide-topic");
- this.appEvents.off("post:highlight", this, "_highlightPost");
- this.appEvents.off("header:update-topic", this, "_updateTopic");
- },
-
- @observes("Discourse.hasFocus")
- gotFocus() {
- if (Discourse.get("hasFocus")) {
- this.scrolled();
- }
- },
-
- resetExamineDockCache() {
- this.set("dockAt", 0);
- },
-
- showTopicInHeader(topic, offset) {
- // On mobile, we show the header topic if the user has scrolled past the topic
- // title and the current scroll direction is down
- // On desktop the user only needs to scroll past the topic title.
- return (
- offset > this.dockAt &&
- (!this.site.mobileView || this.mobileScrollDirection === "down")
- );
- },
- // The user has scrolled the window, or it is finished rendering and ready for processing.
- scrolled() {
- if (this.isDestroyed || this.isDestroying || this._state !== "inDOM") {
- return;
- }
-
- const offset = window.pageYOffset || $("html").scrollTop();
- if (this.get("dockAt") === 0) {
- const title = $("#topic-title");
- if (title && title.length === 1) {
- this.set("dockAt", title.offset().top);
+ _updateTopic(topic) {
+ if (topic === null) {
+ this._lastShowTopic = false;
+ this.appEvents.trigger("header:hide-topic");
+ return;
}
- }
- this.set("hasScrolled", offset > 0);
+ const offset = window.pageYOffset || $("html").scrollTop();
+ this._lastShowTopic = this.showTopicInHeader(topic, offset);
- const topic = this.get("topic");
- const showTopic = this.showTopicInHeader(topic, offset);
- if (showTopic !== this._lastShowTopic) {
- if (showTopic) {
+ if (this._lastShowTopic) {
this.appEvents.trigger("header:show-topic", topic);
- this._lastShowTopic = true;
} else {
- if (!DiscourseURL.isJumpScheduled()) {
- const loadingNear = topic.get("postStream.loadingNearPost") || 1;
- if (loadingNear === 1) {
- this.appEvents.trigger("header:hide-topic");
- this._lastShowTopic = false;
+ this.appEvents.trigger("header:hide-topic");
+ }
+ },
+
+ didInsertElement() {
+ this._super(...arguments);
+ this.bindScrolling({ name: "topic-view" });
+
+ $(window).on("resize.discourse-on-scroll", () => this.scrolled());
+
+ this.$().on(
+ "mouseup.discourse-redirect",
+ ".cooked a, a.track-link",
+ function(e) {
+ // bypass if we are selecting stuff
+ const selection = window.getSelection && window.getSelection();
+ if (selection.type === "Range" || selection.rangeCount > 0) {
+ if (selectedText() !== "") {
+ return true;
+ }
+ }
+
+ const $target = $(e.target);
+ if (
+ $target.hasClass("mention") ||
+ $target.parents(".expanded-embed").length
+ ) {
+ return false;
+ }
+
+ return ClickTrack.trackClick(e);
+ }
+ );
+
+ this.appEvents.on("post:highlight", this, "_highlightPost");
+
+ this.appEvents.on("header:update-topic", this, "_updateTopic");
+ },
+
+ willDestroyElement() {
+ this._super(...arguments);
+ this.unbindScrolling("topic-view");
+ $(window).unbind("resize.discourse-on-scroll");
+
+ // Unbind link tracking
+ this.$().off("mouseup.discourse-redirect", ".cooked a, a.track-link");
+
+ this.resetExamineDockCache();
+
+ // this happens after route exit, stuff could have trickled in
+ this.appEvents.trigger("header:hide-topic");
+ this.appEvents.off("post:highlight", this, "_highlightPost");
+ this.appEvents.off("header:update-topic", this, "_updateTopic");
+ },
+
+ @observes("Discourse.hasFocus")
+ gotFocus() {
+ if (Discourse.get("hasFocus")) {
+ this.scrolled();
+ }
+ },
+
+ resetExamineDockCache() {
+ this.set("dockAt", 0);
+ },
+
+ showTopicInHeader(topic, offset) {
+ // On mobile, we show the header topic if the user has scrolled past the topic
+ // title and the current scroll direction is down
+ // On desktop the user only needs to scroll past the topic title.
+ return (
+ offset > this.dockAt &&
+ (!this.site.mobileView || this.mobileScrollDirection === "down")
+ );
+ },
+ // The user has scrolled the window, or it is finished rendering and ready for processing.
+ scrolled() {
+ if (this.isDestroyed || this.isDestroying || this._state !== "inDOM") {
+ return;
+ }
+
+ const offset = window.pageYOffset || $("html").scrollTop();
+ if (this.get("dockAt") === 0) {
+ const title = $("#topic-title");
+ if (title && title.length === 1) {
+ this.set("dockAt", title.offset().top);
+ }
+ }
+
+ this.set("hasScrolled", offset > 0);
+
+ const topic = this.get("topic");
+ const showTopic = this.showTopicInHeader(topic, offset);
+ if (showTopic !== this._lastShowTopic) {
+ if (showTopic) {
+ this.appEvents.trigger("header:show-topic", topic);
+ this._lastShowTopic = true;
+ } else {
+ if (!DiscourseURL.isJumpScheduled()) {
+ const loadingNear = topic.get("postStream.loadingNearPost") || 1;
+ if (loadingNear === 1) {
+ this.appEvents.trigger("header:hide-topic");
+ this._lastShowTopic = false;
+ }
}
}
}
- }
- // Since the user has scrolled, we need to check the scroll direction on mobile.
- // We use throttle instead of debounce because we want the switch to occur
- // at the start of the scroll. This feels a lot more snappy compared to waiting
- // for the scroll to end if we debounce.
- if (this.site.mobileView && this.hasScrolled) {
- Ember.run.throttle(
- this,
- this._mobileScrollDirectionCheck,
- offset,
- MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
+ // Since the user has scrolled, we need to check the scroll direction on mobile.
+ // We use throttle instead of debounce because we want the switch to occur
+ // at the start of the scroll. This feels a lot more snappy compared to waiting
+ // for the scroll to end if we debounce.
+ if (this.site.mobileView && this.hasScrolled) {
+ Ember.run.throttle(
+ this,
+ this.calculateDirection,
+ offset,
+ MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
+ );
+ }
+
+ // Trigger a scrolled event
+ this.appEvents.trigger("topic:scrolled", offset);
+ },
+
+ // We observe the scroll direction on mobile and if it's down, we show the topic
+ // in the header, otherwise, we hide it.
+ @observes("mobileScrollDirection")
+ toggleMobileHeaderTopic() {
+ return this.appEvents.trigger(
+ "header:update-topic",
+ this.mobileScrollDirection === "down" ? this.get("topic") : null
);
}
-
- // Trigger a scrolled event
- this.appEvents.trigger("topic:scrolled", offset);
- },
-
- _mobileScrollDirectionCheck(offset) {
- // Difference between this scroll and the one before it.
- const delta = Math.floor(offset - this._mobileLastScroll);
-
- // This is a tiny scroll, so we ignore it.
- if (delta <= MOBILE_SCROLL_TOLERANCE && delta >= -MOBILE_SCROLL_TOLERANCE)
- return;
-
- const prevDirection = this.mobileScrollDirection;
- const currDirection = delta > 0 ? "down" : "up";
-
- if (currDirection !== prevDirection) {
- this.set("mobileScrollDirection", currDirection);
- }
-
- // We store this to compare against it the next time the user scrolls
- this._mobileLastScroll = Math.floor(offset);
-
- // If the user reaches the very bottom of the topic, we want to reset the
- // scroll direction in order for the header to switch back.
- const distanceToTopicBottom = Math.floor(
- $("body").height() - offset - $(window).height()
- );
-
- // Not at the bottom yet
- if (distanceToTopicBottom > 0) return;
-
- // We're at the bottom now, so we reset the direction.
- this.set("mobileScrollDirection", null);
- },
-
- // We observe the scroll direction on mobile and if it's down, we show the topic
- // in the header, otherwise, we hide it.
- @observes("mobileScrollDirection")
- toggleMobileHeaderTopic() {
- return this.appEvents.trigger(
- "header:update-topic",
- this.mobileScrollDirection === "down" ? this.get("topic") : null
- );
}
-});
+);
diff --git a/app/assets/javascripts/discourse/components/mobile-footer.js.es6 b/app/assets/javascripts/discourse/components/mobile-footer.js.es6
new file mode 100644
index 00000000000..cecfbd47a03
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/mobile-footer.js.es6
@@ -0,0 +1,128 @@
+import MountWidget from "discourse/components/mount-widget";
+import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
+import Scrolling from "discourse/mixins/scrolling";
+import { observes } from "ember-addons/ember-computed-decorators";
+
+const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 150;
+
+const MobileFooterComponent = MountWidget.extend(
+ Scrolling,
+ MobileScrollDirection,
+ {
+ widget: "mobile-footer-nav",
+ mobileScrollDirection: null,
+ scrollEventDisabled: false,
+ classNames: ["mobile-footer", "visible"],
+ routeHistory: [],
+ currentRouteIndex: 0,
+ canGoBack: false,
+ canGoForward: false,
+ backForwardClicked: null,
+
+ buildArgs() {
+ return {
+ canGoBack: this.canGoBack,
+ canGoForward: this.canGoForward
+ };
+ },
+
+ didInsertElement() {
+ this._super(...arguments);
+ this.bindScrolling({ name: "mobile-footer" });
+ $(window).on("resize.mobile-footer-on-scroll", () => this.scrolled());
+ this.appEvents.on("page:changed", this, "_routeChanged");
+ this.appEvents.on("composer:opened", this, "_composerOpened");
+ this.appEvents.on("composer:closed", this, "_composerClosed");
+ },
+
+ willDestroyElement() {
+ this._super(...arguments);
+ this.unbindScrolling("mobile-footer");
+ $(window).unbind("resize.mobile-footer-on-scroll");
+ this.appEvents.off("page:changed", this, "_routeChanged");
+ this.appEvents.off("composer:opened", this, "_composerOpened");
+ this.appEvents.off("composer:closed", this, "_composerClosed");
+ },
+
+ // The user has scrolled the window, or it is finished rendering and ready for processing.
+ scrolled() {
+ if (
+ this.isDestroyed ||
+ this.isDestroying ||
+ this._state !== "inDOM" ||
+ this.scrollEventDisabled
+ ) {
+ return;
+ }
+
+ const offset = window.pageYOffset || $("html").scrollTop();
+
+ Ember.run.throttle(
+ this,
+ this.calculateDirection,
+ offset,
+ MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
+ );
+ },
+
+ // We observe the scroll direction on mobile and if it's down, we show the topic
+ // in the header, otherwise, we hide it.
+ @observes("mobileScrollDirection")
+ toggleMobileFooter() {
+ this.$().toggleClass(
+ "visible",
+ this.mobileScrollDirection === null ? true : false
+ );
+ // body class used to adjust positioning of #topic-progress-wrapper
+ $("body").toggleClass(
+ "mobile-footer-nav-visible",
+ this.mobileScrollDirection === null ? true : false
+ );
+ },
+
+ _routeChanged(route) {
+ // only update route history if not using back/forward nav
+ if (this.backForwardClicked) {
+ this.backForwardClicked = null;
+ return;
+ }
+
+ this.routeHistory.push(route.url);
+ this.set("currentRouteIndex", this.routeHistory.length);
+
+ this.queueRerender();
+ },
+
+ _composerOpened() {
+ this.set("mobileScrollDirection", "down");
+ this.set("scrollEventDisabled", true);
+ },
+
+ _composerClosed() {
+ this.set("mobileScrollDirection", null);
+ this.set("scrollEventDisabled", false);
+ },
+
+ goBack() {
+ this.set("currentRouteIndex", this.get("currentRouteIndex") - 1);
+ this.backForwardClicked = true;
+ window.history.back();
+ },
+
+ goForward() {
+ this.set("currentRouteIndex", this.get("currentRouteIndex") + 1);
+ this.backForwardClicked = true;
+ window.history.forward();
+ },
+
+ @observes("currentRouteIndex")
+ setBackForward() {
+ let index = this.get("currentRouteIndex");
+
+ this.set("canGoBack", index > 1 ? true : false);
+ this.set("canGoForward", index < this.routeHistory.length ? true : false);
+ }
+ }
+);
+
+export default MobileFooterComponent;
diff --git a/app/assets/javascripts/discourse/controllers/application.js.es6 b/app/assets/javascripts/discourse/controllers/application.js.es6
index 2420b4cf349..62ef324792e 100644
--- a/app/assets/javascripts/discourse/controllers/application.js.es6
+++ b/app/assets/javascripts/discourse/controllers/application.js.es6
@@ -1,4 +1,5 @@
import computed from "ember-addons/ember-computed-decorators";
+import { isAppWebview, isiOSPWA } from "discourse/lib/utilities";
export default Ember.Controller.extend({
showTop: true,
@@ -16,5 +17,10 @@ export default Ember.Controller.extend({
@computed
loginRequired() {
return Discourse.SiteSettings.login_required && !Discourse.User.current();
+ },
+
+ @computed
+ showMobileFooterNav() {
+ return isAppWebview() || isiOSPWA();
}
});
diff --git a/app/assets/javascripts/discourse/initializers/mobile.js.es6 b/app/assets/javascripts/discourse/initializers/mobile.js.es6
index d219ce5ca03..fa38f88dbff 100644
--- a/app/assets/javascripts/discourse/initializers/mobile.js.es6
+++ b/app/assets/javascripts/discourse/initializers/mobile.js.es6
@@ -14,5 +14,14 @@ export default {
site.set("isMobileDevice", Mobile.isMobileDevice);
setResolverOption("mobileView", Mobile.mobileView);
+
+ if (window.ReactNativeWebView) {
+ Ember.run.later(() => {
+ let headerBg = $(".d-header").css("background-color");
+ window.ReactNativeWebView.postMessage(
+ JSON.stringify({ headerBg: headerBg })
+ );
+ }, 500);
+ }
}
};
diff --git a/app/assets/javascripts/discourse/lib/utilities.js.es6 b/app/assets/javascripts/discourse/lib/utilities.js.es6
index 60fbf6d7d2d..171378b723e 100644
--- a/app/assets/javascripts/discourse/lib/utilities.js.es6
+++ b/app/assets/javascripts/discourse/lib/utilities.js.es6
@@ -643,5 +643,15 @@ export function areCookiesEnabled() {
}
}
+export function isiOSPWA() {
+ return (
+ window.matchMedia("(display-mode: standalone)").matches &&
+ navigator.userAgent.match(/(iPad|iPhone|iPod)/g)
+ );
+}
+
+export function isAppWebview() {
+ return window.ReactNativeWebView !== undefined;
+}
// This prevents a mini racer crash
export default {};
diff --git a/app/assets/javascripts/discourse/mixins/mobile-scroll-direction.js.es6 b/app/assets/javascripts/discourse/mixins/mobile-scroll-direction.js.es6
new file mode 100644
index 00000000000..e22bed2e13e
--- /dev/null
+++ b/app/assets/javascripts/discourse/mixins/mobile-scroll-direction.js.es6
@@ -0,0 +1,40 @@
+// Small buffer so that very tiny scrolls don't trigger mobile header switch
+const MOBILE_SCROLL_TOLERANCE = 5;
+
+export default Ember.Mixin.create({
+ _mobileLastScroll: null,
+
+ calculateDirection(offset) {
+ // Difference between this scroll and the one before it.
+ const delta = Math.floor(offset - this._mobileLastScroll);
+
+ // This is a tiny scroll, so we ignore it.
+ if (delta <= MOBILE_SCROLL_TOLERANCE && delta >= -MOBILE_SCROLL_TOLERANCE)
+ return;
+
+ const prevDirection = this.mobileScrollDirection;
+ const currDirection = delta > 0 ? "down" : null;
+
+ // Handle Safari overscroll first
+ if (offset < 0) {
+ this.set("mobileScrollDirection", null);
+ } else if (currDirection !== prevDirection) {
+ this.set("mobileScrollDirection", currDirection);
+ }
+
+ // We store this to compare against it the next time the user scrolls
+ this._mobileLastScroll = Math.floor(offset);
+
+ // If the user reaches the very bottom of the topic, we want to reset the
+ // scroll direction in order for the header to switch back.
+ const distanceToBottom = Math.floor(
+ $("body").height() - offset - $(window).height()
+ );
+
+ // Not at the bottom yet
+ if (distanceToBottom > 0) return;
+
+ // We're at the bottom now, so we reset the direction.
+ this.set("mobileScrollDirection", null);
+ }
+});
diff --git a/app/assets/javascripts/discourse/templates/application.hbs b/app/assets/javascripts/discourse/templates/application.hbs
index 2fd84406e77..8a4621398d4 100644
--- a/app/assets/javascripts/discourse/templates/application.hbs
+++ b/app/assets/javascripts/discourse/templates/application.hbs
@@ -33,3 +33,7 @@
{{outlet "modal"}}
{{topic-entrance}}
{{outlet "composer"}}
+
+{{#if showMobileFooterNav}}
+ {{mobile-footer}}
+{{/if}}
diff --git a/app/assets/javascripts/discourse/widgets/mobile-footer-nav.js.es6 b/app/assets/javascripts/discourse/widgets/mobile-footer-nav.js.es6
new file mode 100644
index 00000000000..0047d4d2b9b
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/mobile-footer-nav.js.es6
@@ -0,0 +1,58 @@
+import { createWidget } from "discourse/widgets/widget";
+import { isAppWebview } from "discourse/lib/utilities";
+
+createWidget("mobile-footer-nav", {
+ tagName: "div.mobile-footer-nav",
+
+ html(attrs) {
+ const buttons = [];
+
+ buttons.push(
+ this.attach("flat-button", {
+ action: "goBack",
+ icon: "chevron-left",
+ className: "btn-large",
+ disabled: !attrs.canGoBack
+ })
+ );
+
+ buttons.push(
+ this.attach("flat-button", {
+ action: "goForward",
+ icon: "chevron-right",
+ className: "btn-large",
+ disabled: !attrs.canGoForward
+ })
+ );
+
+ if (isAppWebview()) {
+ buttons.push(
+ this.attach("flat-button", {
+ action: "share",
+ icon: "link",
+ className: "btn-large"
+ })
+ );
+
+ buttons.push(
+ this.attach("flat-button", {
+ action: "dismiss",
+ icon: "chevron-down",
+ className: "btn-large"
+ })
+ );
+ }
+
+ return buttons;
+ },
+
+ dismiss() {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ dismiss: true }));
+ },
+
+ share() {
+ window.ReactNativeWebView.postMessage(
+ JSON.stringify({ shareUrl: window.location.href })
+ );
+ }
+});
diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss
index 5743d6f4d6c..e784213d53b 100644
--- a/app/assets/stylesheets/common/foundation/variables.scss
+++ b/app/assets/stylesheets/common/foundation/variables.scss
@@ -80,6 +80,7 @@ $z-layers: (
"overlay": 1200
),
"fullscreen": 1150,
+ "mobile-footer": 1140,
"mobile-composer": 1100,
"header": 1000,
"tooltip": 600,
@@ -129,6 +130,7 @@ $box-shadow: (
"card": 0 4px 14px rgba(0, 0, 0, 0.15),
"dropdown": 0 2px 3px 0 rgba(0, 0, 0, 0.2),
"header": 0 2px 4px -1px rgba(0, 0, 0, 0.25),
+ "mobile-footer": 0 2px 4px 1px rgba(0, 0, 0, 0.25),
"kbd": (
0 2px 0 rgba(0, 0, 0, 0.2),
0 0 0 1px dark-light-choose(#fff, #000) inset
diff --git a/app/assets/stylesheets/mobile.scss b/app/assets/stylesheets/mobile.scss
index 244c90102a9..f1f4bcb6799 100644
--- a/app/assets/stylesheets/mobile.scss
+++ b/app/assets/stylesheets/mobile.scss
@@ -30,6 +30,7 @@
@import "mobile/admin_report_counters";
@import "mobile/menu-panel";
@import "mobile/reviewables";
+@import "mobile/footer";
// Import all component-specific files
@import "mobile/components/*";
diff --git a/app/assets/stylesheets/mobile/compose.scss b/app/assets/stylesheets/mobile/compose.scss
index 1fd0eb8052a..0625646954b 100644
--- a/app/assets/stylesheets/mobile/compose.scss
+++ b/app/assets/stylesheets/mobile/compose.scss
@@ -10,6 +10,7 @@
z-index: z("mobile-composer");
.reply-area {
padding: 0 10px;
+ padding-bottom: env(safe-area-inset-bottom);
@media screen and (max-width: 374px) {
padding: 0 5px;
}
@@ -38,6 +39,8 @@
}
&.draft {
+ padding-bottom: env(safe-area-inset-bottom);
+
.toggle-toolbar,
.toggler {
top: 8px;
diff --git a/app/assets/stylesheets/mobile/footer.scss b/app/assets/stylesheets/mobile/footer.scss
new file mode 100644
index 00000000000..95e4151e535
--- /dev/null
+++ b/app/assets/stylesheets/mobile/footer.scss
@@ -0,0 +1,47 @@
+// --------------------------------------------------
+// Mobile footer (displayed in DiscourseHub app and PWAs)
+// --------------------------------------------------
+
+$footer-nav-height: 55px;
+
+body.mobile-footer-nav-visible {
+ padding-bottom: $footer-nav-height + 15;
+ #topic-progress-wrapper,
+ #reply-control.draft {
+ bottom: $footer-nav-height;
+ }
+}
+
+.mobile-footer {
+ background-color: $header_background;
+ box-shadow: shadow("mobile-footer");
+ height: $footer-nav-height;
+ position: fixed;
+ bottom: -$footer-nav-height;
+ left: 0;
+ width: 100%;
+ z-index: z("mobile-footer");
+ transition: all linear 0.15s;
+
+ .d-icon {
+ color: $header_primary-low-mid;
+ }
+
+ &.visible {
+ bottom: 0px;
+ padding-bottom: env(safe-area-inset-bottom);
+ }
+
+ .mobile-footer-nav {
+ display: flex;
+ justify-content: "space-evenly";
+ @include unselectable;
+ button {
+ flex: 1;
+ margin: 12px;
+ &:disabled {
+ opacity: 0.6;
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/mobile/topic.scss b/app/assets/stylesheets/mobile/topic.scss
index 339ac4adc7f..dc3d9ba68c4 100644
--- a/app/assets/stylesheets/mobile/topic.scss
+++ b/app/assets/stylesheets/mobile/topic.scss
@@ -51,6 +51,7 @@
bottom: 0;
z-index: z("timeline");
margin-right: 148px;
+ margin-bottom: env(safe-area-inset-bottom);
.topic-admin-menu-button-container .toggle-admin-menu {
height: 43px;
}
@@ -105,7 +106,6 @@
background-color: $secondary;
color: $tertiary;
border: 1px solid $tertiary-low;
- border-bottom: none;
width: 145px;
height: 42px;
diff --git a/app/views/layouts/_head.html.erb b/app/views/layouts/_head.html.erb
index 89933bb68a0..12334ba5eaf 100644
--- a/app/views/layouts/_head.html.erb
+++ b/app/views/layouts/_head.html.erb
@@ -9,7 +9,7 @@
<%- end %>
<% if mobile_view? %>
-
+
<% else %>
<% end %>
diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb
index 0ce2e0a4057..955fea3dc8b 100644
--- a/lib/svg_sprite/svg_sprite.rb
+++ b/lib/svg_sprite/svg_sprite.rb
@@ -42,6 +42,7 @@ module SvgSprite
"check-circle",
"check-square",
"chevron-down",
+ "chevron-left",
"chevron-right",
"chevron-up",
"circle",