mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-13 20:57:24 +01:00
370 lines
10 KiB
Vue
370 lines
10 KiB
Vue
<template>
|
|
<div
|
|
class="notifications"
|
|
:style="{'--current-scrollY': notificationTopY}"
|
|
>
|
|
<transition-group
|
|
name="notifications"
|
|
class="animations-holder"
|
|
appear
|
|
>
|
|
<notification
|
|
v-for="(notification, index) in visibleNotifications"
|
|
:key="notification.uuid"
|
|
:notification="notification"
|
|
class="notification-item"
|
|
:visible-amount="index"
|
|
@clicked="notificationRemoved(notification)"
|
|
@hidden="notificationRemoved($event)"
|
|
/>
|
|
</transition-group>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss" scoped>
|
|
.notifications {
|
|
position: fixed;
|
|
right: 10px;
|
|
width: 350px;
|
|
z-index: 999; // to keep it above modal overlays
|
|
|
|
top: var(--current-scrollY);
|
|
|
|
justify-content: flex-end;
|
|
display: flex;
|
|
}
|
|
|
|
.animations-holder {
|
|
position: relative;
|
|
display: block;
|
|
}
|
|
|
|
.notification-item {
|
|
transition: transform 0.25s ease-in, opacity 0.25s ease-in;
|
|
}
|
|
|
|
.notifications-leave-active {
|
|
position: absolute;
|
|
right: 0;
|
|
}
|
|
|
|
.notifications-enter,
|
|
.notifications-leave-to {
|
|
opacity: 0;
|
|
right: 0;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
import debounce from 'lodash/debounce';
|
|
import find from 'lodash/find';
|
|
|
|
import { sleepAsync } from '@/../../common/script/libs/sleepAsync';
|
|
import { mapState } from '@/libs/store';
|
|
import notification from './notification';
|
|
import { getBannerHeight } from '@/libs/banner.func';
|
|
import { EVENTS } from '@/libs/events';
|
|
import { worldStateMixin } from '@/mixins/worldState';
|
|
|
|
const NOTIFICATIONS_VISIBLE_AT_ONCE = 4;
|
|
const REMOVAL_INTERVAL = 2500;
|
|
const DELAY_DELETE_AND_NEW = 60;
|
|
const DELAY_FILLING_ENTRIES = 240;
|
|
|
|
export default {
|
|
components: {
|
|
notification,
|
|
},
|
|
mixins: [
|
|
worldStateMixin,
|
|
],
|
|
props: {
|
|
preventQueue: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
debugMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
data () {
|
|
return {
|
|
visibleNotifications: [],
|
|
allowedToFillAgain: true,
|
|
removalIntervalId: null,
|
|
notificationTopY: '0px',
|
|
preventMultipleWatchExecution: false,
|
|
eventPromoBannerHeight: null,
|
|
sleepingBannerHeight: null,
|
|
warningBannerHeight: null,
|
|
};
|
|
},
|
|
computed: {
|
|
...mapState({
|
|
notificationStore: 'notificationStore',
|
|
currentEventList: 'worldState.data.currentEventList',
|
|
}),
|
|
currentEvent () {
|
|
return find(this.currentEventList, event => Boolean(event.gemsPromo) || Boolean(event.promo));
|
|
},
|
|
isEventActive () {
|
|
return Boolean(this.currentEvent?.event);
|
|
},
|
|
notificationBannerHeight () {
|
|
let scrollPosToCheck = 56;
|
|
|
|
if (this.warningBannerHeight) {
|
|
scrollPosToCheck += this.warningBannerHeight;
|
|
}
|
|
|
|
if (this.sleepingBannerHeight) {
|
|
scrollPosToCheck += this.sleepingBannerHeight;
|
|
}
|
|
|
|
if (this.isEventActive) {
|
|
scrollPosToCheck += this.eventPromoBannerHeight ?? 0;
|
|
}
|
|
|
|
return scrollPosToCheck;
|
|
},
|
|
visibleNotificationsWithoutErrors () {
|
|
return this.visibleNotifications.filter(n => n.type !== 'error');
|
|
},
|
|
},
|
|
watch: {
|
|
notificationStore: async function notificationStore (notifications) {
|
|
if (this.preventMultipleWatchExecution) {
|
|
return;
|
|
}
|
|
|
|
this.preventMultipleWatchExecution = true;
|
|
|
|
this.debug('notifications changed', {
|
|
notifications: notifications.length,
|
|
});
|
|
|
|
// if the timer is already running, stop it
|
|
// otherwise a newly added notification might just disappear faster
|
|
this.stopNotificationsRemovalTimer();
|
|
|
|
const fillingPromise = this.triggerFillUntilFull();
|
|
|
|
this.triggerRemovalTimerIfAllowed();
|
|
|
|
await fillingPromise;
|
|
|
|
this.preventMultipleWatchExecution = false;
|
|
},
|
|
currentEvent: function currentEventChanged () {
|
|
this.updateEventBannerHeight();
|
|
},
|
|
},
|
|
async mounted () {
|
|
window.addEventListener('scroll', this.updateScrollY, {
|
|
passive: true,
|
|
});
|
|
|
|
this.$root.$on(EVENTS.BANNER_HEIGHT_UPDATED, () => {
|
|
this.updateBannerHeightAndScrollY();
|
|
});
|
|
this.$root.$on(EVENTS.WORLD_STATE_LOADED, () => {
|
|
this.updateBannerHeightAndScrollY();
|
|
});
|
|
|
|
await this.triggerGetWorldState();
|
|
this.updateBannerHeightAndScrollY();
|
|
},
|
|
destroyed () {
|
|
window.removeEventListener('scroll', this.updateScrollY, {
|
|
passive: true,
|
|
});
|
|
|
|
this.$root.$off(EVENTS.BANNER_HEIGHT_UPDATED);
|
|
this.$root.$off(EVENTS.WORLD_STATE_LOADED);
|
|
},
|
|
methods: {
|
|
debug (...args) {
|
|
if (this.debugMode) {
|
|
console.info(...args); // eslint-disable-line no-console
|
|
}
|
|
},
|
|
notificationRemoved ($event) {
|
|
// findIndex+splice is the way to go on removing, instead of .filter
|
|
// due to the way vue handles new arrays / entries even if you use :key="uuid"
|
|
// the notification object was replaced and prevented to stop the right timer => to remove it
|
|
const foundNotification = this.visibleNotifications.findIndex(n => n.uuid === $event.uuid);
|
|
|
|
this.visibleNotifications.splice(foundNotification, 1);
|
|
|
|
this.updateAllowedToFillAgain();
|
|
this.$store.dispatch('snackbars:remove', $event);
|
|
|
|
this.debug('removed', {
|
|
allowedToFillAgain: this.allowedToFillAgain,
|
|
storeLength: this.notificationStore.length,
|
|
});
|
|
|
|
if (this.allowedToFillAgain) {
|
|
// reset the flag so that the next call (for a new notification) will be immediately
|
|
if (this.visibleNotificationsWithoutErrors.length !== 0) {
|
|
this.debug('start timeout to fill again');
|
|
setTimeout(() => {
|
|
this.debug('before fill new notifications');
|
|
this.triggerFillUntilFull();
|
|
|
|
this.triggerRemovalTimerIfAllowed();
|
|
}, DELAY_DELETE_AND_NEW);
|
|
}
|
|
}
|
|
},
|
|
fillVisibleNotifications (notifications) {
|
|
this.debug({
|
|
fillAgain: this.allowedToFillAgain,
|
|
visible: this.visibleNotifications.length,
|
|
notifications: notifications.length,
|
|
});
|
|
|
|
// the generic checks - new notification array has enough items
|
|
// is allowed to be filled and don't do anything while the visible items are 2
|
|
if (notifications.length === 0 || !this.allowedToFillAgain
|
|
|| this.visibleNotifications.length === NOTIFICATIONS_VISIBLE_AT_ONCE) {
|
|
this.debug('stop fill - 1');
|
|
return;
|
|
}
|
|
|
|
// this checks if the visible items are the current ones in the notifications array
|
|
// if so - there is no need to continue, for example on only one visible notification,
|
|
// it doesn't need to go with the loop again
|
|
if (notifications.length === this.visibleNotifications.length) {
|
|
const visibleIds = this.visibleNotifications.map(n => n.uuid);
|
|
|
|
const allTheSame = notifications.every(n => visibleIds.includes(n.uuid));
|
|
|
|
if (allTheSame) {
|
|
this.debug('stop fill - 2', {
|
|
visibleIds,
|
|
notifications: notifications.length,
|
|
notificationsStore: this.notificationStore.length,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// fill the new items that needs to be visible
|
|
if (this.visibleNotifications.length < NOTIFICATIONS_VISIBLE_AT_ONCE) {
|
|
const visibleIds = this.visibleNotifications.map(n => n.uuid);
|
|
|
|
const notAddedYet = notifications.filter(n => !visibleIds.includes(n.uuid));
|
|
|
|
this.debug({
|
|
visibleIds,
|
|
notAddedYet: notAddedYet.length,
|
|
});
|
|
|
|
if (notAddedYet.length > 0) {
|
|
this.visibleNotifications.push(notAddedYet[0]);
|
|
}
|
|
}
|
|
|
|
this.updateAllowedToFillAgain();
|
|
},
|
|
async triggerFillUntilFull () {
|
|
for (let i = 0; i < NOTIFICATIONS_VISIBLE_AT_ONCE; i += 1) {
|
|
this.debug(`fill ${i}`);
|
|
this.fillVisibleNotifications(this.notificationStore);
|
|
|
|
await sleepAsync(DELAY_FILLING_ENTRIES); // eslint-disable-line no-await-in-loop
|
|
}
|
|
},
|
|
triggerRemovalTimerIfAllowed () {
|
|
if (this.notificationStore.length !== 0) {
|
|
this.startNotificationRemovalTimer();
|
|
}
|
|
},
|
|
startNotificationRemovalTimer () {
|
|
if (this.removalIntervalId != null) {
|
|
// current interval still running - wait until its done
|
|
return;
|
|
}
|
|
|
|
this.debug('start removal interval');
|
|
this.removalIntervalId = setInterval(() => {
|
|
const nonErrorNotifications = this.visibleNotifications.filter(n => n.type !== 'error');
|
|
|
|
if (nonErrorNotifications.length !== 0) {
|
|
const firstEntry = nonErrorNotifications[0];
|
|
|
|
this.debug('removed entry', firstEntry);
|
|
this.notificationRemoved(firstEntry);
|
|
}
|
|
|
|
if (nonErrorNotifications.length === 0) {
|
|
this.stopNotificationsRemovalTimer();
|
|
|
|
this.updateAllowedToFillAgain();
|
|
}
|
|
}, REMOVAL_INTERVAL);
|
|
},
|
|
|
|
stopNotificationsRemovalTimer () {
|
|
if (this.removalIntervalId == null) {
|
|
// current interval still running - wait until its done
|
|
return;
|
|
}
|
|
|
|
this.debug('clear removal interval');
|
|
clearInterval(this.removalIntervalId);
|
|
this.removalIntervalId = null;
|
|
},
|
|
|
|
updateAllowedToFillAgain () {
|
|
const notificationsAmount = this.visibleNotificationsWithoutErrors.length;
|
|
this.allowedToFillAgain = notificationsAmount < NOTIFICATIONS_VISIBLE_AT_ONCE;
|
|
|
|
this.debug({
|
|
allowedToFillAgain: this.allowedToFillAgain,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* This updates the position of all notifications so that its always at the top,
|
|
* unless the header is visible then its under the header
|
|
*/
|
|
updateScrollY: debounce(function updateScrollY () {
|
|
const topY = Math.min(window.scrollY, this.notificationBannerHeight) - 10;
|
|
|
|
this.notificationTopY = `${this.notificationBannerHeight - topY}px`;
|
|
}, 16),
|
|
|
|
updateBannerHeightAndScrollY () {
|
|
this.updateEventBannerHeight();
|
|
this.warningBannerHeight = getBannerHeight('chat-warning');
|
|
this.sleepingBannerHeight = getBannerHeight('damage-paused');
|
|
this.updateScrollY();
|
|
},
|
|
|
|
updateEventBannerHeight () {
|
|
if (this.isEventActive) {
|
|
this.eventPromoBannerHeight = getBannerHeight(this.currentEventBannerName());
|
|
}
|
|
},
|
|
|
|
currentEventBannerName () {
|
|
// if there are any other types of promo bars
|
|
// this method needs to be updated
|
|
|
|
if (this.currentEvent?.promo) {
|
|
return 'gift-promo';
|
|
}
|
|
|
|
if (this.currentEvent?.gemsPromo) {
|
|
return 'gems-promo';
|
|
}
|
|
|
|
return '';
|
|
},
|
|
},
|
|
};
|
|
</script>
|