Home Reference Source

js/models/NotificationCategorizer.js

import Notification from '~/models/Notification';

/**
 * Represents a notification group
 */
export class NotificationGroup {
    /**
     * @param {Notification} primaryNotification - The primary (and most recent) notification
     * @param {Notification[]} siblings - Related notifs w/ same action
     */
    constructor(primaryNotification, siblings) {
        /** @type {Notification} */
        this.primaryNotification = primaryNotification;

        /** @type {Notification[]} */
        this.siblings = siblings;
    }

    /**
     * Returns the total count of notifications
     * @return {number}
     */
    get count() { return 1 + this.siblings.length; }

    /**
     * Iterates over all groups in this
     * @return {Generator<Notification>}
     */
    *[Symbol.iterator]() {
        yield this.primaryNotification;
        yield* this.siblings;
    }

    /**
     * Returns all IDs
     * @return {Generator<string>}
     */
    *getIds() {
        for (const notification of this) {
            yield notification.id;
        }
    }

    /**
     * Returns unique senders (excluding primary).
     * @return {Generator<User>}
     */
    *senders() {
        let userIds = new Set();
        for (const notification of this.siblings) {
            if (userIds.has(notification.sender.id))
                continue;

            yield notification.sender;
            userIds.add(notification.sender.id)
        }
    }

    /**
     * Returns if any item in the group is unread
     * @return {NotificationStatus}
     */
    async getStatus() {
        const NotificationStatus = await Notification.getStatuses();

        if (this.primaryNotification.status === NotificationStatus.unseen)
            return NotificationStatus.unseen;

        let hasUnread = false;
        for (const notification of this.siblings) {
            if (notification.status === NotificationStatus.unseen)
                return NotificationStatus.unseen;

            if (notification.status === NotificationStatus.seen)
                hasUnread = true;
        }

        if (hasUnread)
            return NotificationStatus.seen;

        return this.primaryNotification.status;
    }
}

/**
 * Represents a notification category
 */
export class NotificationCategory {
    /**
     * @param {string} name
     * @param {NotificationGroup[]} notification groups expects in sorted order.
     */
    constructor(name, notificationGroups) {
        /**
         * Time-based name of category
         * @type {string}
         */
        this.name = name;

        /**
         * Prefer the generator
         * @type {NotificationGroup[]}
         */
        this.notificationGroups = notificationGroups;
    }

    /**
     * Returns if any item in the group is unread
     * @return {NotificationStatus}
     */
    async getStatus() {
        const NotificationStatus = await Notification.getStatuses();

        let hasUnread = false;
        for (const notificationGroup of this) {
            const status = await notificationGroup.getStatus();
            if (status === NotificationStatus.unseen)
                return NotificationStatus.unseen;

            if (status === NotificationStatus.seen)
                hasUnread = true;
        }

        if (hasUnread)
            return NotificationStatus.seen;

        return NotificationStatus.read;
    }

    /**
     * Returns all IDs
     * @return {Generator<string>}
     */
    *getIds() {
        for (const group of this) {
            yield* group.getIds();
        }
    }

    /**
     * This iterates in sorted order
     */
    *[Symbol.iterator]() {
        yield* this.notificationGroups;
    }
}

/**
 * Categorizes notifications
 */
export default class NotificationCategorizer {
    /**
     * Creates empty categorizer.
     */
    constructor() {
        this._categories = [];
        this._groupCount = 0;
        this._days = new Set();
    }

    *[Symbol.iterator]() {
        for (const category of this._categories) {
            const categoryGroups = category.items.map(
                ({ mostRecent, related }) => new NotificationGroup(mostRecent, related))
            yield new NotificationCategory(category.name, categoryGroups)
        }
    }

    /**
     * The amount of unique items
     * @readonly
     * @type {number}
     */
    get rowCount() { return this._groupCount; }

    /**
     * The amount of days this covers
     */
    get dayCount() { return this._days; }

    /**
     * Takes in a notification iterator. Assumes aync
     * @param {Generator<Notification>} notificationIterator,
     */
    async feed(notificationIterator) {
        for await(const notification of notificationIterator) {
            this.feedOnce(notification);
        }
    }

    /**
     * Feeds a single notification in
     * @param {Notification} notification
     */
    feedOnce(notification) {
        const header = this.headerForDate(notification.dateCreated);
        const category = this.findCategory(header);
        this.registerNotificationMetadata(notification);
        this.addNotificationWithSiblings(category, notification);
    }

    /**
     * Registers a notification as an instance
     * @param {Notification} notification
     */
    registerNotificationMetadata(notification) {
        const notificationDay = moment(notification.creationDate).startOf('day').unix()
        this._days.add(notificationDay);
    }

    /**
     * Obtains header for a date. Same header = same categorization
     * @param {Date} date
     * @return {String}
     */
    headerForDate(date) {
        return moment(date).calendar(null, {
            sameDay: '[Today]',
            lastDay: '[Yesterday]',
            lastWeek: '[Last] dddd',
            sameElse: '[Older]'
        })
    }

    /**
     * Finds a category by a name or returns a new category
     * @param {string} categoryHeader
     * @return {Object}
     */
    findCategory(categoryHeader) {
        for (const category of this._categories) {
            if (category.name === categoryHeader) {
                return category;
            }
        }

        const newCategory = { name: categoryHeader, items: [] };
        this._categories.push(newCategory);
        return newCategory;
    }

    /**
     * Attempts to find a notification's siblings in a category
     * @param {Category} category
     * @param {Notification} notification
     * @return {Object} A notification group object. Creates new if not exist
     */
    addNotificationWithSiblings(category, notification) {
        for (const notificationGroup of category.items) {
            const primaryNotification = notificationGroup.mostRecent;
            if (primaryNotification.type === notification.type &&
                primaryNotification.source === notification.source) {
                notificationGroup.related.push(notification);
                return notificationGroup;
            }
        }

        this._groupCount += 1;

        // TODO: ensure balancing of this
        category.items.push({
            mostRecent: notification,
            related: []
        });
    }
}