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",