import LZString from 'lz-string';
import CdError from './CdError';
import cd from './cd';
import commentRegistry from './commentRegistry';
import settings from './settings';
import { getUserInfo, saveLocalOption } from './utils-api';
export default {
/**
* Request the pages visits data from the server.
*
* {@link https://doc.wikimedia.org/mediawiki-core/master/js/mw.user.html#.options mw.user.options}
* is not used even on the first run because the script may not run immediately after the page has
* loaded. In fact, when the page is loaded in a background tab, it can be throttled until it is
* focused, so an indefinite amount of time can pass.
*
* @param {import('./BootProcess').default} [bootProcess]
* @param {boolean} [reuse=false] Whether to reuse a cached userinfo request.
*/
async load(bootProcess, reuse = false) {
if (!cd.user.isRegistered()) return;
try {
// mw.user.options is not used even on first run because it appears to be cached sometimes
// which can be critical for determining subscriptions.
this.unpack(await getUserInfo(reuse).then(({ visits }) => visits));
} catch (e) {
console.warn('Convenient Discussions: Couldn\'t load the settings from the server.', e);
return;
}
const articleId = mw.config.get('wgArticleId');
this.data ||= {};
this.data[articleId] ||= [];
this.currentPageData = this.data[articleId];
this.process(bootProcess.passedData.markAsRead);
},
/**
* Process the visits data and emit events.
*
* @param {boolean} markAsReadRequested
* @fires newCommentsHighlighted
* @private
*/
async process(markAsReadRequested) {
const currentTime = Math.floor(Date.now() / 1000);
this.update(currentTime, markAsReadRequested);
const timeConflict = this.currentPageData.length ?
commentRegistry.initNewAndSeen(this.currentPageData, currentTime, markAsReadRequested) :
false;
// (Nearly) eliminate the possibility that we will wrongfully mark a seen comment as unseen/new
// at the next page load by adding a minute to the visit time if there is at least one comment
// posted at the same minute. If instead we required the comment time to be less than the
// current time to be highlighted, it would result in missed comments if the comment was posted
// at the same minute as our visit but after that moment.
//
// We sacrifice the chance that sometimes we will wrongfully mark an unseen comment as seen -
// but for that,
// * one comment should be added at the same minute as our visit but earlier;
// * another comment should be added at the same minute as our visit but later.
//
// We could decide that not marking unseen comments as seen is an absolute priority and remove
// the timeConflict stuff.
this.currentPageData.push(String(currentTime + timeConflict * 60));
this.save();
this.emit('process', this.currentPageData);
/**
* New comments have been highlighted.
*
* @event newCommentsHighlighted
* @param {object} cd {@link convenientDiscussions} object.
*/
mw.hook('convenientDiscussions.newCommentsHighlighted').fire(cd);
},
/**
* Remove timestamps that we don't need anymore from the visits array.
*
* @param {number} currentTime
* @param {boolean} markAsReadRequested
* @private
*/
update(currentTime, markAsReadRequested) {
for (let i = this.currentPageData.length - 1; i >= 0; i--) {
if (
this.currentPageData[i] < currentTime - 60 * settings.get('highlightNewInterval') ||
// Add this condition for rare cases when the time of the previous visit is later than the
// current time (see timeConflict). In that case, when highlightNewInterval is set to 0,
// the user shouldn't get comments highlighted again all of a sudden.
!settings.get('highlightNewInterval') ||
markAsReadRequested
) {
// Remove visits _before_ the found one.
this.currentPageData.splice(0, i);
break;
}
}
},
/**
* Convert a visits object into an optimized string and compress it.
*
* @returns {string}
* @private
*/
pack() {
// The format of the items:
// <Page ID>,<List of visits, from oldest to newest, separated by comma>\n
return LZString.compressToEncodedURIComponent(
Object.keys(this.data)
.map((key) => `${key},${this.data[key].join(',')}\n`)
.join('')
.trim()
);
},
/**
* Unpack a compressed visits string into a visits object.
*
* @param {string|undefined} compressed
* @private
*/
unpack(compressed) {
this.data = {};
if (!compressed) return;
const string = LZString.decompressFromEncodedURIComponent(compressed);
const regexp = /^(\d+),(.+)$/gm;
let match;
while ((match = regexp.exec(string))) {
this.data[match[1]] = match[2].split(',');
}
},
/**
* Save the pages visits data to the server.
*/
async save() {
let compressed = this.pack();
if (compressed.length > 20480) {
this.cleanUp(((compressed.length - 20480) / compressed.length) + 0.05);
compressed = this.pack();
}
try {
await saveLocalOption(cd.g.visitsOptionName, compressed);
} catch (e) {
if (e instanceof CdError) {
const { type, code } = e.data;
if (type === 'internal' && code === 'sizeLimit') {
this.cleanUp(0.1);
this.save();
} else {
console.error(e);
}
} else {
console.error(e);
}
}
},
/**
* Remove the oldest `share`% of visits when the size limit is hit.
*
* @param {number} share
* @private
*/
cleanUp(share = 0.1) {
const visits = Object.assign({}, this.data);
const timestamps = Object.keys(visits)
.reduce((acc, key) => acc.concat(visits[key]), [])
.sort((a, b) => a - b);
const boundary = timestamps[Math.floor(timestamps.length * share)];
Object.keys(visits).forEach((key) => {
visits[key] = visits[key].filter((visit) => visit >= boundary);
if (!visits[key].length) {
delete visits[key];
}
});
this.data = visits;
},
/**
* For tests: set the last visit to a date or a number of days before the current date. Use via
* `cd.tests.visits.rollBack()`, then refresh the page.
*
* @param {Date|number} [dateOrDays=1]
*/
rollBack(dateOrDays = 1) {
this.currentPageData.splice(1);
this.currentPageData[0] = (
(
typeof dateOrDays === 'object' ?
dateOrDays.getTime() :
(Date.now() - cd.g.msInDay * dateOrDays)
) / 1000
).toFixed();
this.save();
},
};