Footer navigation for iOS PWAs and DiscourseHub app (#7347)
This commit is contained in:
parent
f665862ee8
commit
eae22548de
|
@ -2,12 +2,11 @@ import DiscourseURL from "discourse/lib/url";
|
||||||
import AddArchetypeClass from "discourse/mixins/add-archetype-class";
|
import AddArchetypeClass from "discourse/mixins/add-archetype-class";
|
||||||
import ClickTrack from "discourse/lib/click-track";
|
import ClickTrack from "discourse/lib/click-track";
|
||||||
import Scrolling from "discourse/mixins/scrolling";
|
import Scrolling from "discourse/mixins/scrolling";
|
||||||
|
import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
|
||||||
import { selectedText } from "discourse/lib/utilities";
|
import { selectedText } from "discourse/lib/utilities";
|
||||||
import { observes } from "ember-addons/ember-computed-decorators";
|
import { observes } from "ember-addons/ember-computed-decorators";
|
||||||
|
|
||||||
const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300;
|
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) {
|
function highlight(postNumber) {
|
||||||
const $contents = $(`#post_${postNumber} .topic-body`);
|
const $contents = $(`#post_${postNumber} .topic-body`);
|
||||||
|
@ -16,222 +15,195 @@ function highlight(postNumber) {
|
||||||
$contents.on("animationend", () => $contents.removeClass("highlighted"));
|
$contents.on("animationend", () => $contents.removeClass("highlighted"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Ember.Component.extend(AddArchetypeClass, Scrolling, {
|
export default Ember.Component.extend(
|
||||||
userFilters: Ember.computed.alias("topic.userFilters"),
|
AddArchetypeClass,
|
||||||
classNameBindings: [
|
Scrolling,
|
||||||
"multiSelect",
|
MobileScrollDirection,
|
||||||
"topic.archetype",
|
{
|
||||||
"topic.is_warning",
|
userFilters: Ember.computed.alias("topic.userFilters"),
|
||||||
"topic.category.read_restricted:read_restricted",
|
classNameBindings: [
|
||||||
"topic.deleted:deleted-topic",
|
"multiSelect",
|
||||||
"topic.categoryClass",
|
"topic.archetype",
|
||||||
"topic.tagClasses"
|
"topic.is_warning",
|
||||||
],
|
"topic.category.read_restricted:read_restricted",
|
||||||
menuVisible: true,
|
"topic.deleted:deleted-topic",
|
||||||
SHORT_POST: 1200,
|
"topic.categoryClass",
|
||||||
|
"topic.tagClasses"
|
||||||
|
],
|
||||||
|
menuVisible: true,
|
||||||
|
SHORT_POST: 1200,
|
||||||
|
|
||||||
postStream: Ember.computed.alias("topic.postStream"),
|
postStream: Ember.computed.alias("topic.postStream"),
|
||||||
archetype: Ember.computed.alias("topic.archetype"),
|
archetype: Ember.computed.alias("topic.archetype"),
|
||||||
dockAt: 0,
|
dockAt: 0,
|
||||||
|
|
||||||
_lastShowTopic: null,
|
_lastShowTopic: null,
|
||||||
|
|
||||||
mobileScrollDirection: null,
|
mobileScrollDirection: null,
|
||||||
_mobileLastScroll: null,
|
|
||||||
|
|
||||||
@observes("enteredAt")
|
@observes("enteredAt")
|
||||||
_enteredTopic() {
|
_enteredTopic() {
|
||||||
// Ember is supposed to only call observers when values change but something
|
// 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
|
// in our view set up is firing this observer with the same value. This check
|
||||||
// prevents scrolled from being called twice.
|
// prevents scrolled from being called twice.
|
||||||
const enteredAt = this.get("enteredAt");
|
const enteredAt = this.get("enteredAt");
|
||||||
if (enteredAt && this.get("lastEnteredAt") !== enteredAt) {
|
if (enteredAt && this.get("lastEnteredAt") !== enteredAt) {
|
||||||
this._lastShowTopic = null;
|
this._lastShowTopic = null;
|
||||||
Ember.run.schedule("afterRender", () => this.scrolled());
|
Ember.run.schedule("afterRender", () => this.scrolled());
|
||||||
this.set("lastEnteredAt", enteredAt);
|
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);
|
|
||||||
}
|
}
|
||||||
);
|
},
|
||||||
|
|
||||||
this.appEvents.on("post:highlight", this, "_highlightPost");
|
_highlightPost(postNumber) {
|
||||||
|
Ember.run.scheduleOnce("afterRender", null, highlight, postNumber);
|
||||||
|
},
|
||||||
|
|
||||||
this.appEvents.on("header:update-topic", this, "_updateTopic");
|
_updateTopic(topic) {
|
||||||
},
|
if (topic === null) {
|
||||||
|
this._lastShowTopic = false;
|
||||||
willDestroyElement() {
|
this.appEvents.trigger("header:hide-topic");
|
||||||
this._super(...arguments);
|
return;
|
||||||
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 offset = window.pageYOffset || $("html").scrollTop();
|
||||||
|
this._lastShowTopic = this.showTopicInHeader(topic, offset);
|
||||||
|
|
||||||
const topic = this.get("topic");
|
if (this._lastShowTopic) {
|
||||||
const showTopic = this.showTopicInHeader(topic, offset);
|
|
||||||
if (showTopic !== this._lastShowTopic) {
|
|
||||||
if (showTopic) {
|
|
||||||
this.appEvents.trigger("header:show-topic", topic);
|
this.appEvents.trigger("header:show-topic", topic);
|
||||||
this._lastShowTopic = true;
|
|
||||||
} else {
|
} else {
|
||||||
if (!DiscourseURL.isJumpScheduled()) {
|
this.appEvents.trigger("header:hide-topic");
|
||||||
const loadingNear = topic.get("postStream.loadingNearPost") || 1;
|
}
|
||||||
if (loadingNear === 1) {
|
},
|
||||||
this.appEvents.trigger("header:hide-topic");
|
|
||||||
this._lastShowTopic = false;
|
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.
|
// 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
|
// 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
|
// at the start of the scroll. This feels a lot more snappy compared to waiting
|
||||||
// for the scroll to end if we debounce.
|
// for the scroll to end if we debounce.
|
||||||
if (this.site.mobileView && this.hasScrolled) {
|
if (this.site.mobileView && this.hasScrolled) {
|
||||||
Ember.run.throttle(
|
Ember.run.throttle(
|
||||||
this,
|
this,
|
||||||
this._mobileScrollDirectionCheck,
|
this.calculateDirection,
|
||||||
offset,
|
offset,
|
||||||
MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
|
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
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
|
@ -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;
|
|
@ -1,4 +1,5 @@
|
||||||
import computed from "ember-addons/ember-computed-decorators";
|
import computed from "ember-addons/ember-computed-decorators";
|
||||||
|
import { isAppWebview, isiOSPWA } from "discourse/lib/utilities";
|
||||||
|
|
||||||
export default Ember.Controller.extend({
|
export default Ember.Controller.extend({
|
||||||
showTop: true,
|
showTop: true,
|
||||||
|
@ -16,5 +17,10 @@ export default Ember.Controller.extend({
|
||||||
@computed
|
@computed
|
||||||
loginRequired() {
|
loginRequired() {
|
||||||
return Discourse.SiteSettings.login_required && !Discourse.User.current();
|
return Discourse.SiteSettings.login_required && !Discourse.User.current();
|
||||||
|
},
|
||||||
|
|
||||||
|
@computed
|
||||||
|
showMobileFooterNav() {
|
||||||
|
return isAppWebview() || isiOSPWA();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,5 +14,14 @@ export default {
|
||||||
site.set("isMobileDevice", Mobile.isMobileDevice);
|
site.set("isMobileDevice", Mobile.isMobileDevice);
|
||||||
|
|
||||||
setResolverOption("mobileView", Mobile.mobileView);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
// This prevents a mini racer crash
|
||||||
export default {};
|
export default {};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
|
@ -33,3 +33,7 @@
|
||||||
{{outlet "modal"}}
|
{{outlet "modal"}}
|
||||||
{{topic-entrance}}
|
{{topic-entrance}}
|
||||||
{{outlet "composer"}}
|
{{outlet "composer"}}
|
||||||
|
|
||||||
|
{{#if showMobileFooterNav}}
|
||||||
|
{{mobile-footer}}
|
||||||
|
{{/if}}
|
||||||
|
|
|
@ -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 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
|
@ -80,6 +80,7 @@ $z-layers: (
|
||||||
"overlay": 1200
|
"overlay": 1200
|
||||||
),
|
),
|
||||||
"fullscreen": 1150,
|
"fullscreen": 1150,
|
||||||
|
"mobile-footer": 1140,
|
||||||
"mobile-composer": 1100,
|
"mobile-composer": 1100,
|
||||||
"header": 1000,
|
"header": 1000,
|
||||||
"tooltip": 600,
|
"tooltip": 600,
|
||||||
|
@ -129,6 +130,7 @@ $box-shadow: (
|
||||||
"card": 0 4px 14px rgba(0, 0, 0, 0.15),
|
"card": 0 4px 14px rgba(0, 0, 0, 0.15),
|
||||||
"dropdown": 0 2px 3px 0 rgba(0, 0, 0, 0.2),
|
"dropdown": 0 2px 3px 0 rgba(0, 0, 0, 0.2),
|
||||||
"header": 0 2px 4px -1px rgba(0, 0, 0, 0.25),
|
"header": 0 2px 4px -1px rgba(0, 0, 0, 0.25),
|
||||||
|
"mobile-footer": 0 2px 4px 1px rgba(0, 0, 0, 0.25),
|
||||||
"kbd": (
|
"kbd": (
|
||||||
0 2px 0 rgba(0, 0, 0, 0.2),
|
0 2px 0 rgba(0, 0, 0, 0.2),
|
||||||
0 0 0 1px dark-light-choose(#fff, #000) inset
|
0 0 0 1px dark-light-choose(#fff, #000) inset
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
@import "mobile/admin_report_counters";
|
@import "mobile/admin_report_counters";
|
||||||
@import "mobile/menu-panel";
|
@import "mobile/menu-panel";
|
||||||
@import "mobile/reviewables";
|
@import "mobile/reviewables";
|
||||||
|
@import "mobile/footer";
|
||||||
|
|
||||||
// Import all component-specific files
|
// Import all component-specific files
|
||||||
@import "mobile/components/*";
|
@import "mobile/components/*";
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
z-index: z("mobile-composer");
|
z-index: z("mobile-composer");
|
||||||
.reply-area {
|
.reply-area {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
@media screen and (max-width: 374px) {
|
@media screen and (max-width: 374px) {
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
@ -38,6 +39,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&.draft {
|
&.draft {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
|
||||||
.toggle-toolbar,
|
.toggle-toolbar,
|
||||||
.toggler {
|
.toggler {
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -51,6 +51,7 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
z-index: z("timeline");
|
z-index: z("timeline");
|
||||||
margin-right: 148px;
|
margin-right: 148px;
|
||||||
|
margin-bottom: env(safe-area-inset-bottom);
|
||||||
.topic-admin-menu-button-container .toggle-admin-menu {
|
.topic-admin-menu-button-container .toggle-admin-menu {
|
||||||
height: 43px;
|
height: 43px;
|
||||||
}
|
}
|
||||||
|
@ -105,7 +106,6 @@
|
||||||
background-color: $secondary;
|
background-color: $secondary;
|
||||||
color: $tertiary;
|
color: $tertiary;
|
||||||
border: 1px solid $tertiary-low;
|
border: 1px solid $tertiary-low;
|
||||||
border-bottom: none;
|
|
||||||
width: 145px;
|
width: 145px;
|
||||||
height: 42px;
|
height: 42px;
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
<%- end %>
|
<%- end %>
|
||||||
<meta name="theme-color" content="#<%= ColorScheme.hex_for_name('header_background', scheme_id) %>">
|
<meta name="theme-color" content="#<%= ColorScheme.hex_for_name('header_background', scheme_id) %>">
|
||||||
<% if mobile_view? %>
|
<% if mobile_view? %>
|
||||||
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, minimum-scale=1.0, user-scalable=yes, viewport-fit=cover">
|
||||||
<% else %>
|
<% else %>
|
||||||
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=yes">
|
<meta name="viewport" content="width=device-width, minimum-scale=1.0, maximum-scale=1.0, user-scalable=yes">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -42,6 +42,7 @@ module SvgSprite
|
||||||
"check-circle",
|
"check-circle",
|
||||||
"check-square",
|
"check-square",
|
||||||
"chevron-down",
|
"chevron-down",
|
||||||
|
"chevron-left",
|
||||||
"chevron-right",
|
"chevron-right",
|
||||||
"chevron-up",
|
"chevron-up",
|
||||||
"circle",
|
"circle",
|
||||||
|
|
Loading…
Reference in New Issue