blob: 6b6a35e461a8fcd8738a832179f9d6e933f65641 [file] [log] [blame]
/*
Licensed to the Apache Software Foundation (ASF) under one or more
contributor license agreements. See the NOTICE file distributed with
this work for additional information regarding copyright ownership.
The ASF licenses this file to You under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance with
the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// THIS IS AN AUTOMATICALLY COMBINED FILE. PLEASE EDIT THE source/ FILES!
const PONYMAIL_REVISION = 'f3a4716';
/******************************************
Fetched from source/aavariables.js
******************************************/
/* jshint -W097 */
'use strict';
const PONYMAIL_VERSION = "1.0.1"; // Current version of Pony Mail Foal
let G_apiURL = ''; // external API URL. Usually left blank.
// Stuff regarding what we're doing right now
let G_current_json = {};
let G_current_state = {};
let G_current_list = '';
let G_current_domain = '';
let G_current_year = 0;
let G_current_month = 0;
let G_current_query = '';
let G_current_open_email = null;
let G_select_primed = false;
let G_ponymail_preferences = {};
let G_ponymail_search_list = 'this';
let G_current_listmode = 'threaded';
let G_current_listmode_compact = false;
const PONYMAIL_MAX_NESTING = 10; // max nesting level before unthreading to save space
// thread state
let G_current_email_idx;
let G_chatty_layout = true;
// emails (composer, key-commands, render-email)
let G_full_emails = {};
// listview-*.js, key-commands
let G_current_index_pos = 0;
let G_current_per_page = 0;
// sidebar calendar
const MONTHS_SHORTENED = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const CALENDAR_YEARS_SHOWN = 4; // TODO: should this be configurable?
let G_show_stats_sidebar = true; // Whether to show author stats or not
// datepicker
const DAYS_SHORTENED = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
// render_email_chatty
const PONYMAIL_DATE_FORMAT = {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
};
const PONYMAIL_TIME_FORMAT = { timeStyle: 'long'}; // ensure TZ is shown
let G_collated_json = {};
if (pm_config.apiURL) {
G_apiURL = pm_config.apiURL;
console.log("Setting API URL to " + G_apiURL);
}
// check local storage for settings
console.log("Checking localStorage availability");
let G_can_store = false;
if (window.localStorage && window.localStorage.setItem) {
try {
window.localStorage.setItem("ponymail_test", "foo");
G_can_store = true;
console.log("localStorage available!");
} catch (e) {
console.log("no localStorage available!");
}
}
/******************************************
Fetched from source/base-http-extensions.js
******************************************/
// URL calls currently 'in escrow'. This controls the spinny wheel animation
let async_escrow = {}
const ASYNC_MAXWAIT = 250; // ms to wait before displaying spinner
let async_status = 'clear';
let async_cache = {}
// Escrow spinner check
async function escrow_check() {
let now = new Date();
let show_spinner = false;
for (let k in async_escrow) {
if ((now - async_escrow[k]) > ASYNC_MAXWAIT) {
show_spinner = true;
break;
}
}
// Fetch or create the spinner
let spinner = document.getElementById('spinner');
if (!spinner) {
spinner = new HTML('div', {
id: 'spinner',
class: 'spinner'
});
let spinwheel = new HTML('div', {
id: 'spinwheel',
class: 'spinwheel'
});
spinner.inject(spinwheel);
spinner.inject(new HTML('h2', {}, "Loading, please wait.."));
document.body.appendChild(spinner);
}
// Show or don't show spinner?
if (show_spinner) {
spinner.style.display = 'block';
if (async_status === 'clear') {
console.log("Waiting for JSON resource, deploying spinner");
async_status = 'waiting';
}
} else {
spinner.style.display = 'none';
if (async_status === 'waiting') {
console.log("All URLs out of escrow, dropping spinner");
async_status = 'clear';
}
}
}
async function async_snap(error) {
let msg = await error.text();
msg = msg.replace(/<.*?>/g, ""); // strip HTML tags
if (error.status === 404) {
msg += "\n\nYou may need to be logged in with additional permissions in order to view this resource.";
if (pm_config.perm_error_postface) {
msg += pm_config.perm_error_postface;
}
}
modal("An error occured", "An error code %u occured while trying to fetch %s:\n%s".format(error.status, error.url, msg), "error");
}
// Asynchronous GET call
async function GET(url, callback, state) {
console.log("Fetching JSON resource at %s".format(url));
let pkey = "GET-%s-%s".format(callback, url);
let res;
let res_json;
state = state || {};
state.url = url;
if (state && state.cached === true && async_cache[url]) {
console.log("Fetching %s from cache".format(url));
res_json = async_cache[url];
} else {
try {
console.log("putting %s in escrow...".format(url));
async_escrow[pkey] = new Date(); // Log start of request in escrow dict
const rv = await fetch(url, {
credentials: 'same-origin'
}); // Wait for resource...
// Since this is an async request, the request may have been canceled
// by the time we get a response. Only do callback if not.
if (async_escrow[pkey] !== undefined) {
res = rv;
}
} catch (e) {
delete async_escrow[pkey]; // move out of escrow if failed
console.log("The URL %s could not be fetched: %s".format(url, e));
modal("An error occured", "An error occured while trying to fetch %s:\n%s".format(url, e), "error");
}
}
if (res !== undefined || res_json !== undefined) {
// We expect a 2xx return code (usually 200 or 201), snap otherwise
if ((res_json) || (res.status >= 200 && res.status < 300)) {
console.log("Successfully fetched %s".format(url))
let js;
if (res_json) {
js = res_json;
} else {
js = await res.json();
delete async_escrow[pkey]; // move out of escrow when fetched
async_cache[url] = js;
}
if (callback) {
callback(state, js);
} else {
console.log("No callback function was registered for %s, ignoring result.".format(url));
}
} else {
console.log("URL %s returned HTTP code %u, snapping!".format(url, res.status));
delete async_escrow[pkey]; // move out of escrow when fetched
async_snap(res);
}
}
}
/******************************************
Fetched from source/base-js-extensions.js
******************************************/
/**
* String formatting prototype
* A'la printf
*/
String.prototype.format = function() {
let args = arguments;
let n = 0;
let t = this;
let rtn = this.replace(/(?!%)?%([-+]*)([0-9.]*)([a-zA-Z])/g, function(m, pm, len, fmt) {
len = parseInt(len || '1');
// We need the correct number of args, balk otherwise, using ourselves to format the error!
if (args.length <= n) {
let err = "Error interpolating string '%s': Expected at least %u argments, only got %u!".format(t, n + 1, args.length);
console.log(err);
throw err;
}
let varg = args[n];
n++;
switch (fmt) {
case 's':
if (typeof(varg) == 'function') {
varg = '(function)';
}
return varg;
// For now, let u, d and i do the same thing
case 'd':
case 'i':
case 'u':
varg = parseInt(varg).pad(len); // truncate to Integer, pad if needed
return varg;
}
});
return rtn;
};
/**
* Number prettification prototype:
* Converts 1234567 into 1,234,567 etc
*/
Number.prototype.pretty = function(fix) {
if (fix) {
return String(this.toFixed(fix)).replace(/(\d)(?=(\d{3})+\.)/g, '$1,');
}
return String(this.toFixed(0)).replace(/(\d)(?=(\d{3})+$)/g, '$1,');
};
/**
* Number padding
* usage: 123.pad(6) -> 000123
*/
Number.prototype.pad = function(n) {
let str = String(this);
/* Do we need to pad? if so, do it using String.repeat */
if (str.length < n) {
str = "0".repeat(n - str.length) + str;
}
return str;
};
/* Func for converting TZ offset from minutes to +/-HHMM */
Date.prototype.TZ_HHMM = function() {
let off_mins = this.getTimezoneOffset();
let off_hh = Math.floor(Math.abs(off_mins/60));
let off_mm = Math.abs(off_mins%60);
let sgn = off_mins > 0 ? '-' : '+';
return sgn + off_hh.pad(2) + ':' + off_mm.pad(2);
};
/* Func for converting a date to YYYY-MM-DD HH:MM TZ */
Date.prototype.ISOBare = function() {
let M, O, d, h, m, y;
if (prefs.UTC === true) {
y = this.getUTCFullYear();
m = (this.getUTCMonth() + 1).pad(2);
d = this.getUTCDate().pad(2);
h = this.getUTCHours().pad(2);
M = this.getUTCMinutes().pad(2);
O = 'UTC';
} else {
y = this.getFullYear();
m = (this.getMonth() + 1).pad(2);
d = this.getDate().pad(2);
h = this.getHours().pad(2);
M = this.getMinutes().pad(2);
O = this.TZ_HHMM();
}
return y + "-" + m + "-" + d + " " + h + ":" + M + " " + O;
};
/* isArray: function to detect if an object is an array */
function isArray(value) {
return value && typeof value === 'object' && value instanceof Array && typeof value.length === 'number' && typeof value.splice === 'function' && !(value.propertyIsEnumerable('length'));
}
/* isHash: function to detect if an object is a hash */
function isHash(value) {
return value && typeof value === 'object' && !isArray(value);
}
/* Remove an array element by value */
Array.prototype.remove = function(val) {
let i, item, j, len;
for (i = j = 0, len = this.length; j < len; i = ++j) {
item = this[i];
if (item === val) {
this.splice(i, 1);
return this;
}
}
return this;
};
/* Check if array has value */
Array.prototype.has = function(val) {
for (let item of this) {
if (item === val) {
return true;
}
}
return false;
};
function isEmpty(obj) {
return (
obj &&
Object.keys(obj).length === 0 &&
Object.getPrototypeOf(obj) === Object.prototype
);
}
/******************************************
Fetched from source/body-fixups.js
******************************************/
const PONYMAIL_URL_RE = new RegExp("(" + "(?:(?:[a-z]+)://)" + "(?:\\S+(?::\\S*)?@)?" + "(?:" + "([01][0-9][0-9]|2[0-4][0-9]|25[0-5])" + "|" + "(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)" + "(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*" + "(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))" + "\\.?" + ")" + "(?::\\d{2,5})?" + "\\/?" + "(?:[/?#]?([^,<>()\\[\\] \\t\\r\\n]|(<[^:\\s]*?>|\\([^:\\s]*?\\)|\\[[^:\\s]*?\\]))*)?" + ")\\.?" + "\\b", "mi");
// Regex for things to potentially put inside quote objects:
// - quotes
// - forwarded emails
// - inline quoting
// - top posting with original email following
const PONYMAIL_QUOTE_RE = new RegExp("(" +
// Typical encapsulation of context by ticketing systems and/or bug trackers
"(---\\r?\\n([^\\r\\n]*?\\r?\\n)*?---$)|" +
"(" +
"(?:\\r?\\n|^)" + // start of line or after a colon
// Classic method; a signal line and the quote with '>' starting each line quoted
"(" +
"(" + // Initial line signalling a quote
"((\\d+-\\d+-\\d+)\\s+.+<\\S+@\\S+>:[ \t\r\n]+)|" + // "01-02-1982 Foo Bar <foo@bar.baz>:" OR
"(on\\s+(.+|.+\\n.+)\\s+wrote:[\r?\n]+)|" + // "On $somedate, $someone wrote:", OR...
"(le\\s+(.+|.+\\n.+)\\s+écrit:[\r?\n]+)|" + // French version of the above, OR...
"(.?am .+? schrieb\\s+.+:[\r?\n]+)|" + // German version of the above, OR...
"(envoy[ée] de mon .+|sent from my .+|von meinem .+ gesendet)[ \t\r\n]+" + // "sent from my iphone/ipad/android phone/whatever", usually means the next part is a quote.
")" + // End initial signal line
"(^$)*" + // Accept blank newlines following it...
"(((^(?!>)[\\s\\S]+$)*)|(^\\s*>[\\s\\S]+$)*)" + // Either text that follows immediately after with no '>' first, OR text with '>' first, but NOT both...
")|" +
"(" +
// Lines after the signal line; comes in one shape, generally speaking...
"(^\\s*>+[ \\t]*[^\r\n]*\r*\n+)+" + // Lines beginning with one or more '>' after the initial signal line
")" +
")+|" + //OR...
"(" +
"^(-{5,10}) .+? \\1[\r\n]+" + // ----- Forwarded Message -----
"(^\\w+:\\s+.+[\r\n]+){3,10}[\r\n]+" + // Between three and ten header fields (we ask for at least 3, so as to not quote PGP blocks)
"[\\S\\s]+" + // Whatever comes next...
")+" +
")", "mi");
// Somewhat simplified method for catching email footers/trailers that we don't need
const PONYMAIL_TRAILER_RE = new RegExp("^--[\r\n]+.*", "mi"); //(--\r?\n([^\r\n]*?\r?\n){1,6}$)|[\r\n.]+^((--+ \r?\n|--+\r?\n|__+\r?\n|--+\\s*[^\r\n]+\\s*--+\r?\n)(.*\r?\n)+)+$", "m");
// This is a regex for capturing code diff blocks in an email
const PONYMAIL_DIFF_RE = new RegExp(
"(" +
"^-{3} .+?[\r\n]+" + // Starts with a "--- /foo/bar/baz"
"^\\+{3} .+?[\r\n]+" + // Then a "+++ /foo/bar/baz"
"(" + // Then one or more of...
"^@@@? .+[\r\n]+" + // positioning
"(^ .*[\r\n]*$){0,3}" + // diff header
"(^[-+ ].*[\r\n]*)+" + // actual diff
"(^ .*[\r\n]*$){0,3}" + // diff trailer
")+" +
")", "mi");
// Function for turning URLs into <a> tags
function fixup_urls(splicer) {
if (typeof splicer == 'object') {
return splicer;
//splicer = splicer.innerText;
}
/* Array holding text and links */
let i, m, t, textbits, url, urls;
textbits = [];
/* Find the first link, if any */
i = splicer.search(PONYMAIL_URL_RE);
urls = 0;
/* While we have more links, ... */
while (i !== -1) {
urls++;
/* Only parse the first 250 URLs... srsly */
if (urls > 250) {
break;
}
/* Text preceding the link? add it to textbits frst */
if (i > 0) {
t = splicer.substr(0, i);
textbits.push(t);
splicer = splicer.substr(i);
}
/* Find the URL and cut it out as a link */
m = splicer.match(PONYMAIL_URL_RE);
if (m) {
url = m[1];
i = url.length;
t = splicer.substr(0, i);
textbits.push(new HTML('a', {
href: url
}, url));
splicer = splicer.substr(i);
}
/* Find the next link */
i = splicer.search(PONYMAIL_URL_RE);
}
/* push the remaining text into textbits */
textbits.push(splicer);
return textbits;
}
// Simple check to (attempt to) assess whether a trailer should
// remain or get cut out.
function legit_trailer(a) {
let lines = a.split(/\s*\r?\n/);
let first_line = lines.shift();
while (first_line.length == 0 && lines.length) first_line = lines.shift(); // get first meaningful line
if (!lines.length || first_line == '--') return ''; // likely a simple trailer
let last_line = lines.pop();
while (last_line.length == 0 && lines.length) last_line = lines.pop(); // get last meaningful line
// Check if first and last line are similar, which is usually indictive of a ticket system
if (last_line == first_line) {
return a;
}
// Otherwise, check if first line has two or more dashes, and it occurs again later (also tix)
if (first_line.match(/^---+/) && lines.has(first_line)) {
return "|||" + a + "|||";
}
// Lastly, if there is "sufficient" length to the dashes, allow (JIRA etc)
if (first_line.match(/^-{6,72}$/)) return a;
return '';
}
// Function for cutting away trailers
function cut_trailer(splicer) {
if (!G_chatty_layout) return splicer; // only invoke in social rendering mode
if (typeof splicer == 'object') {
splicer.innerText = splicer.innerText.replace(PONYMAIL_TRAILER_RE, legit_trailer, 3);
} else {
splicer = splicer.replace(PONYMAIL_TRAILER_RE, legit_trailer);
}
return splicer;
}
function color_diff_lines(diff) {
let lines = diff.split(/[\r\n]+/);
let ret = [];
for (let z = 0; z < lines.length; z++) {
let line = lines[z];
let color = 'grey';
if (line[0] == '@') color = 'blue';
if (line[0] == '-') color = 'red';
if (line[0] == '+') color = 'green';
if (line[0] == ' ') color = 'black';
let el = new HTML('span', {
style: {
color: color
}
}, line);
ret.push(el);
ret.push(new HTML('br'));
}
return ret;
}
// Function for coloring diffs
function fixup_diffs(splicer) {
if (!G_chatty_layout) return splicer; // only invoke in social rendering mode
if (typeof splicer == 'object') {
splicer = splicer.innerText;
}
/* Array holding text and links */
let i, m, t, diff, diffs;
let textbits = [];
/* Find the first link, if any */
i = splicer.search(PONYMAIL_DIFF_RE);
diffs = 0;
/* While we have more links, ... */
while (i !== -1) {
diffs++;
/* Only parse the first 20 diffs... srsly */
if (diffs > 25) {
break;
}
console.log(i);
/* Text preceding the diff? add it to textbits frst */
if (i > 0) {
t = splicer.substr(0, i);
textbits.push(t);
splicer = splicer.substr(i);
}
/* Find the URL and cut it out as a link */
m = splicer.match(PONYMAIL_DIFF_RE);
if (m) {
diff = m[1];
i = diff.length;
t = splicer.substr(0, i);
textbits.push(new HTML('pre', {
class: 'diff'
}, color_diff_lines(diff)));
splicer = splicer.substr(i);
}
/* Find the next link */
i = splicer.search(PONYMAIL_DIFF_RE);
}
/* push the remaining text into textbits */
textbits.push(splicer);
return textbits;
}
// Function for turning quotes into quote segments
function fixup_quotes(splicer) {
if (splicer[splicer.length - 1] !== "\n") splicer += "\n"; //tweak to make quotes match the last line if no newline on it.
let hideQuotes, i, m, qdiv, quote, quotes, t, textbits;
hideQuotes = true;
if (prefs.compactQuotes === false && !G_chatty_layout) {
hideQuotes = false;
}
if (!hideQuotes) return splicer; // We'll bail here for now. Dunno why not.
/* Array holding text and quotes */
textbits = [];
/* Find the first quote, if any */
i = splicer.search(PONYMAIL_QUOTE_RE);
quotes = 0;
/* While we have more quotes, ... */
while (i !== -1) {
quotes++;
/* Only parse the first 50 quotes... srsly */
if (quotes > 50) {
break;
}
/* Text preceding the quote? add it to textbits first */
if (i > 0) {
t = splicer.substr(0, i);
let diffed = fixup_diffs(cut_trailer(t));
if (isArray(diffed)) {
for (let z = 0; z < diffed.length; z++) textbits.push(fixup_urls(diffed[z]));
} else textbits.push(fixup_urls(diffed));
splicer = splicer.substr(i);
}
/* Find the quote and cut it out as a div */
m = splicer.match(PONYMAIL_QUOTE_RE);
if (m) {
quote = m[0];
i = quote.length;
t = splicer.substr(0, i);
quote = quote.replace(/(>*\s*\r?\n)+$/g, "");
qdiv = new HTML('div', {
"class": "email_quote_parent"
}, [
new HTML('button', {
title: "Toggle quote",
onclick: "toggle_quote(this)"
}, new HTML('span', {
class: 'glyphicon glyphicon-comment'
}, " ")), new HTML('br'), new HTML('blockquote', {
"class": "email_quote",
style: {
display: hideQuotes ? 'none' : 'block'
}
}, fixup_urls(quote))
]);
textbits.push(qdiv);
splicer = splicer.substr(i);
}
/* Find the next quotes */
i = splicer.search(PONYMAIL_QUOTE_RE);
}
/* push the remaining text into textbits */
let diffed = fixup_diffs(cut_trailer(splicer));
if (isArray(diffed)) {
for (let z = 0; z < diffed.length; z++) diffed[z] = fixup_urls(diffed[z]);
} else diffed = fixup_urls(diffed);
textbits.push(new HTML('span', {}, diffed));
return textbits;
}
function toggle_quote(el) {
let quote = el.parentNode.childNodes[2];
if (quote.style.display != 'block') {
quote.style.display = 'block';
} else {
quote.style.display = 'none';
}
}
/******************************************
Fetched from source/composer.js
******************************************/
let mua_headers = {}; // communication between compose_email and compose_send
function compose_send() {
let content = [];
for (let k in mua_headers) {
content.push(k + "=" + encodeURIComponent(mua_headers[k]));
}
// Push the subject and email body into the form data
content.push("subject=" + encodeURIComponent(document.getElementById('composer_subject').value));
content.push("body=" + encodeURIComponent(document.getElementById('composer_body').value));
if (G_ponymail_preferences.login && G_ponymail_preferences.login.alternates && document.getElementById('composer_alt')) {
content.push("alt=" + encodeURIComponent(document.getElementById('composer_alt').options[document.getElementById('composer_alt').selectedIndex].value));
}
let request = new XMLHttpRequest();
request.open("POST", "%sapi/compose.lua".format(G_apiURL));
request.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
request.send(content.join("&")); // send email as a POST string
document.getElementById('composer_modal').style.display = 'none';
modal("Message dispatched!", "Your email has been sent. Depending on moderation rules, it may take a while before it shows up in the archives.", "help");
}
function compose_email(replyto, list) {
let email = null;
let mua_trigger = null;
let loggedIn = (G_ponymail_preferences.login && G_ponymail_preferences.login.credentials) ? true : false;
if (replyto) email = G_full_emails[replyto || ''];
let listname = list;
mua_headers = {};
if (email) {
listname = email.list_raw.replace(/[<>]/g, '').replace('.', '@', 1);
mua_trigger = mua_link(email);
if (email['message-id'] && email['message-id'].length > 0) mua_headers['in-reply-to'] = email['message-id'];
if (email['message-id'] && email['message-id'].length > 0) mua_headers.references = email['message-id'];
mua_headers.eid = email.mid;
} else {
mua_trigger = mua_link(null, listname);
}
mua_headers.to = listname;
// Not logged in? MUA it is, then!
if (!loggedIn) {
if (email) {
let a = new HTML('a', {
href: mua_trigger
}, "Reply via your own email client");
let p = new HTML('p', {}, [
"In order to reply to emails using the web interface, you need to be ",
new HTML('a', {
href: '/oauth.html'
}, "logged in first"),
". You can however still reply to this email using your own email client: ",
a
]);
composer("Reply to thread:", p);
return;
} else {
modal("Please log in", "You need to be logged in before you can start a new thread.", "warning");
return
}
}
// Replying to an email and logged in?
let eml_subject = "";
let eml_body = "";
let eml_title = `Start a new thread on ${listname}:`;
if (email) {
eml_subject = email.subject;
if (!eml_subject.match(/^Re:\s+/i)) { // Add "Re: " if needed only.
eml_subject = "Re: " + eml_subject;
}
eml_body = composer_re(email);
eml_title = `Reply to email on ${listname}:`;
}
let form = [];
form.push(new HTML('b', {}, "Sending as:"));
let s = new HTML('select', {
id: 'composer_alt'
});
s.inject(new HTML('option', {}, G_ponymail_preferences.login.credentials.email));
if (G_ponymail_preferences.login && G_ponymail_preferences.login.alternates) {
for (let alternate of G_ponymail_preferences.login.alternates) {
s.inject(new HTML('option', {}, alternate));
}
}
form.push(new HTML('br'));
form.push(s);
form.push(new HTML('br'));
form.push(new HTML('b', {}, "Subject:"));
form.push(new HTML('br'));
form.push(new HTML('input', {
style: {
width: '90%'
},
id: 'composer_subject',
type: 'text',
value: eml_subject
}));
form.push(new HTML('br'));
form.push(new HTML('b', {}, "Reply:"));
form.push(new HTML('br'));
let body = new HTML('textarea', {
style: {
width: '90%',
minHeight: '400px'
},
id: 'composer_body'
}, eml_body);
form.push(body);
let btn = new HTML('button', {
onclick: 'compose_send();'
}, "Send reply");
form.push(btn);
form.push(" ");
form.push(new HTML('a', {
href: mua_trigger,
style: {
marginLeft: '10px'
}
}, "Or compose via your own email client"));
composer(eml_title, form);
if (email) document.getElementById('composer_body').focus();
}
// Generic modal function
function composer(title, contents) {
let composer_modal = document.getElementById('composer_modal');
if (composer_modal == undefined) {
composer_modal = new HTML('div', {
id: 'composer_modal'
}, [
new HTML('div', {
id: 'composer_modal_content'
}, [
new HTML('span', {
id: 'composer_modal_close',
onclick: 'document.getElementById("composer_modal").style.display = "none";'
}, 'X'),
new HTML('h2', {
id: 'composer_modal_title'
}, title),
new HTML('div', {
id: 'composer_modal_contents'
}, contents)
])
]);
document.body.appendChild(composer_modal);
} else {
document.getElementById('composer_modal_title').innerText = title;
document.getElementById('composer_modal_contents').innerHTML = '';
document.getElementById('composer_modal_contents').inject(contents || '');
}
composer_modal.style.display = 'block';
}
// Constructor for email body in replies...
function composer_re(email) {
let lines = email.body.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
lines[i] = '> ' + lines[i];
}
let re = `\n\nOn ${email.date} ${email.from.replace(/\s*<.+?>/, '')} wrote:\n`;
re += lines.join("\n");
return re;
}
// MUA mailto: link generator
function mua_link(email, xlist) {
if (!email && xlist) {
return `mailto:${xlist}?subject=Subject+goes+here`;
}
let eml_raw_short = composer_re(email);
let subject = "RE: " + email.subject || '';
let N = 16000; // Anything above 16K can cause namespace issues with links.
if (eml_raw_short.length > N) {
eml_raw_short = eml_raw_short.substring(0, N) + "\n[message truncated...]";
}
let listname = email.list_raw.replace(/[<>]/g, '').replace('.', '@', 1);
let xlink = 'mailto:' + listname + "?subject=" + encodeURIComponent(subject) + "&amp;In-Reply-To=" + encodeURIComponent(email['message-id']) + "&body=" + encodeURIComponent(eml_raw_short);
return xlink;
}
/******************************************
Fetched from source/construct-thread.js
******************************************/
function expand_email_threaded(idx, flat) {
let placeholder = document.getElementById('email_%u'.format(idx));
if (placeholder) {
// Check if email is already visible - if so, hide it!
if (placeholder.style.display == 'block') {
console.log("Collapsing thread at index %u".format(idx));
placeholder.style.display = 'none';
G_current_email_idx = undefined;
return false;
}
G_current_email_idx = idx;
console.log("Expanding thread at index %u".format(idx));
placeholder.style.display = 'block';
// Check if we've already filled out the structure here
if (placeholder.getAttribute('data-filled') == 'yes') {
console.log("Already constructed this thread, bailing!");
} else {
// Construct the base scaffolding for all emails
let eml = flat ? G_current_json.emails[idx] : G_current_json.thread_struct[idx];
if (eml) {
G_current_open_email = eml.tid || eml.mid;
}
let thread = construct_thread(eml);
placeholder.inject(thread);
placeholder.setAttribute("data-filled", 'yes');
}
}
return false;
}
function construct_thread(thread, cid, nestlevel, included) {
// First call on a thread/email, this is indef.
// Use this to plop a scroll call when loaded
// to prevent weird cache-scrolling
let doScroll = false;
if (cid === undefined) {
doScroll = true;
}
included = included || [];
cid = (cid || 0) + 1;
nestlevel = (nestlevel || 0) + 1;
let mw = calc_email_width();
let max_nesting = PONYMAIL_MAX_NESTING;
if (mw < 700) {
max_nesting = Math.floor(mw / 250);
}
cid %= 5;
let color = ['286090', 'ccab0a', 'c04331', '169e4e', '6d4ca5'][cid];
let email;
if (nestlevel < max_nesting) {
email = new HTML('div', {
class: 'email_wrapper',
id: 'email_%s'.format(thread.tid || thread.id)
});
if (G_chatty_layout) {
email.style.border = "none !important";
} else {
email.style.borderLeft = '3px solid #%s'.format(color);
}
} else {
email = new HTML('div', {
class: 'email_wrapper_nonest',
id: 'email_%s'.format(thread.tid || thread.id)
});
}
let wrapper = new HTML('div', {
class: 'email_inner_wrapper',
id: 'email_contents_%s'.format(thread.tid || thread.id)
});
email.inject(wrapper);
if (isArray(thread.children)) {
thread.children.sort((a, b) => a.epoch - b.epoch);
for (let child of thread.children) {
let reply = construct_thread(child, cid, nestlevel, included);
cid++;
if (reply) {
email.inject(reply);
}
}
}
let tid = thread.tid || thread.id;
if (!included.includes(tid)) {
included.push(tid);
console.log("Loading email %s".format(tid));
GET("%sapi/email.lua?id=%s".format(G_apiURL, encodeURIComponent(tid)), render_email, {
cached: true,
scroll: doScroll,
id: tid,
div: wrapper
});
}
return email;
}
// Singular thread construction via permalinks
function construct_single_thread(state, json) {
G_current_json = json;
if (json) {
// Old schema has json.error filled on error, simplified schema has json.message filled and json.okay set to false
let error_message = json.okay === false ? json.message : json.error;
if (error_message) {
modal("An error occured", "Sorry, we hit a snag while trying to load the email(s): \n\n%s".format(error_message), "error");
return;
}
}
let div = document.getElementById('emails');
div.innerHTML = "";
// Fix URLs if they point to an deprecated permalink
if (json.thread) {
let url_to_push = location.href.replace(/[^/]+$/, "") + json.thread.id;
if (location.href != url_to_push) {
console.log("URL differs from default permalink, pushing correct ID to history.");
window.history.pushState({}, json.thread.subject, url_to_push)
}
}
// Not top level thread?
let looked_for_parent = location.query == 'find_parent=true';
if (!looked_for_parent && json.thread['in-reply-to'] && json.thread['in-reply-to'].length > 0) {
let isign = new HTML('span', {class: 'glyphicon glyphicon-eye-close'}, " ");
let btitle = new HTML("b", {}, "This may not be the start of the conversation...");
let a = new HTML("a", {href: "javascript:void(location.href += '?find_parent=true');"}, "Find parent email");
let notice = new HTML("div", {class: "infobox"}, [
isign,
btitle,
new HTML("br"),
"This email appears to be a reply to another email, as it contains an in-reply-to reference.",
new HTML("br"),
"If you wish to attempt finding the root thread, click here: ",
a
]);
div.inject(notice);
notice.inject(a);
}
if (G_chatty_layout) {
let listname = json.thread.list_raw.replace(/[<>]/g, '').replace('.', '@', 1);
div.setAttribute("class", "email_placeholder_chatty");
div.inject(new HTML('h4', {
class: 'chatty_title'
}, json.emails[0].subject));
div.inject(new HTML('a', {
href: 'list.html?%s'.format(listname),
class: 'chatty_title'
}, 'Posted to %s'.format(listname)));
} else {
div.setAttribute("class", "email_placeholder");
}
document.title = json.emails[0].subject + "-" + prefs.title
div.style.display = "block";
let thread = json.thread;
let email = construct_thread(thread);
div.inject(email);
}
/******************************************
Fetched from source/datepicker.js
******************************************/
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
let datepicker_spawner = null
let calendarpicker_spawner = null
const DATE_UNITS = {
w: 'week',
d: 'day',
M: 'month',
y: 'year'
}
function fixupPicker(obj) {
obj.addEventListener("focus", function(event) {
$('html').on('hide.bs.dropdown', function(e) {
return false;
});
});
obj.addEventListener("blur", function(event) {
$('html').unbind('hide.bs.dropdown')
});
}
// makeSelect: Creates a <select> object with options
function makeSelect(options, id, selval) {
let sel = document.createElement('select')
sel.addEventListener("focus", function(event) {
$('html').on('hide.bs.dropdown', function(e) {
return false;
});
});
sel.addEventListener("blur", function(event) {
$('html').unbind('hide.bs.dropdown')
});
sel.setAttribute("name", id)
sel.setAttribute("id", id)
// For each options element, create it in the DOM
for (let key in options) {
let opt = document.createElement('option')
// Hash or array?
if (typeof key == "string") {
opt.setAttribute("value", key)
// Option is selected by default?
if (key == selval) {
opt.setAttribute("selected", "selected")
}
} else {
// Option is selected by default?
if (options[key] == selval) {
opt.setAttribute("selected", "selected")
}
}
opt.text = options[key]
sel.appendChild(opt)
}
return sel
}
// splitDiv: Makes a split div with 2 elements,
// and puts div2 into the right column,
// and 'name' as text in the left one.
function splitDiv(id, name, div2) {
let div = document.createElement('div')
let subdiv = document.createElement('div')
let radio = document.createElement('input')
radio.setAttribute("type", "radio")
radio.setAttribute("name", "datepicker_radio")
radio.setAttribute("value", name)
radio.setAttribute("id", "datepicker_radio_" + id)
radio.setAttribute("onclick", "calcTimespan('" + id + "')")
let label = document.createElement('label')
label.innerHTML = "&nbsp; " + name + ": "
label.setAttribute("for", "datepicker_radio_" + id)
subdiv.appendChild(radio)
subdiv.appendChild(label)
subdiv.style.float = "left"
div2.style.float = "left"
subdiv.style.width = "120px"
subdiv.style.height = "48px"
div2.style.height = "48px"
div2.style.width = "250px"
div.appendChild(subdiv)
div.appendChild(div2)
return div
}
// calcTimespan: Calculates the value and representational text
// for the datepicker choice and puts it in the datepicker's
// spawning input/select element.
function calcTimespan(what) {
let wat = ""
let tval = ""
// Less than N units ago?
if (what == 'lt') {
// Get unit and how many units
let N = document.getElementById('datepicker_lti').value
let unit = document.getElementById('datepicker_lts').value
let unitt = DATE_UNITS[unit]
if (parseInt(N) != 1) {
unitt += "s"
}
// If this makes sense, construct a humanly readable and a computer version
// of the timespan
if (N.length > 0) {
wat = "Less than " + N + " " + unitt + " ago"
tval = "lte=" + N + unit
}
}
// More than N units ago?
if (what == 'mt') {
// As above, get unit and no of units.
let N = document.getElementById('datepicker_mti').value
let unit = document.getElementById('datepicker_mts').value
let unitt = DATE_UNITS[unit]
if (parseInt(N) != 1) {
unitt += "s"
}
// construct timespan val + description
if (N.length > 0) {
wat = "More than " + N + " " + unitt + " ago"
tval = "gte=" + N + unit
}
}
// Date range?
if (what == 'cd') {
// Get From and To values
let f = document.getElementById('datepicker_cfrom').value
let t = document.getElementById('datepicker_cto').value
// construct timespan val + description if both from and to are valid
if (f.length > 0 && t.length > 0) {
wat = "From " + f + " to " + t
tval = "dfr=" + f + "|dto=" + t
}
}
// If we calc'ed a value and spawner exists, update its key/val
if (datepicker_spawner && what && wat.length > 0) {
document.getElementById('datepicker_radio_' + what).checked = true
if (datepicker_spawner.options) {
datepicker_spawner.options[0].value = tval
datepicker_spawner.options[0].text = wat
} else if (datepicker_spawner.value) {
datepicker_spawner.value = wat
datepicker_spawner.setAttribute("data", tval)
}
}
}
// datePicker: spawns a date picker with letious
// timespan options right next to the parent caller.
function datePicker(parent, seedPeriod) {
datepicker_spawner = parent
let div = document.getElementById('datepicker_popup')
// If the datepicker object doesn't exist, spawn it
if (!div) {
div = document.createElement('div')
div.setAttribute("id", "datepicker_popup")
div.setAttribute("class", "datepicker")
}
// Reset the contents of the datepicker object
div.innerHTML = ""
div.style.display = "block"
// Position the datepicker next to whatever called it
let bb = parent.getBoundingClientRect()
div.style.top = (bb.bottom + 8) + "px"
div.style.left = (bb.left + 32) + "px"
// -- Less than N $units ago
let ltdiv = document.createElement('div')
let lti = document.createElement('input')
lti.setAttribute("id", "datepicker_lti")
lti.style.width = "48px"
lti.setAttribute("onkeyup", "calcTimespan('lt')")
lti.setAttribute("onblur", "calcTimespan('lt')")
ltdiv.appendChild(lti)
let lts = makeSelect({
'd': "Day(s)",
'w': 'Week(s)',
'M': "Month(s)",
'y': "Year(s)"
}, 'datepicker_lts', 'm')
lts.setAttribute("onchange", "calcTimespan('lt')")
ltdiv.appendChild(lts)
ltdiv.appendChild(document.createTextNode(' ago'))
div.appendChild(splitDiv('lt', 'Less than', ltdiv))
// -- More than N $units ago
let mtdiv = document.createElement('div')
let mti = document.createElement('input')
mti.style.width = "48px"
mti.setAttribute("id", "datepicker_mti")
mti.setAttribute("onkeyup", "calcTimespan('mt')")
mti.setAttribute("onblur", "calcTimespan('mt')")
mtdiv.appendChild(mti)
let mts = makeSelect({
'd': "Day(s)",
'w': 'Week(s)',
'M': "Month(s)",
'y': "Year(s)"
}, 'datepicker_mts', 'm')
mtdiv.appendChild(mts)
mts.setAttribute("onchange", "calcTimespan('mt')")
mtdiv.appendChild(document.createTextNode(' ago'))
div.appendChild(splitDiv('mt', 'More than', mtdiv))
// -- Calendar timespan
// This is just two text fields, the calendarPicker sub-plugin populates them
let cdiv = document.createElement('div')
let cfrom = document.createElement('input')
cfrom.style.width = "90px"
cfrom.setAttribute("id", "datepicker_cfrom")
cfrom.setAttribute("onfocus", "showCalendarPicker(this)")
cfrom.setAttribute("onchange", "calcTimespan('cd')")
cdiv.appendChild(document.createTextNode('From: '))
cdiv.appendChild(cfrom)
let cto = document.createElement('input')
cto.style.width = "90px"
cto.setAttribute("id", "datepicker_cto")
cto.setAttribute("onfocus", "showCalendarPicker(this)")
cto.setAttribute("onchange", "calcTimespan('cd')")
cdiv.appendChild(document.createTextNode('To: '))
cdiv.appendChild(cto)
div.appendChild(splitDiv('cd', 'Date range', cdiv))
// -- Magic button that sends the timespan back to the caller
let okay = document.createElement('input')
okay.setAttribute("type", "button")
okay.setAttribute("value", "Okay")
okay.setAttribute("onclick", "setDatepickerDate()")
div.appendChild(okay)
parent.parentNode.appendChild(div)
document.body.setAttribute("onclick", "")
window.setTimeout(function() {
document.body.setAttribute("onclick", "blurDatePicker(event)")
}, 200)
lti.focus()
// This is for recalcing the set options if spawned from a
// select/input box with an existing value derived from an
// earlier call to datePicker
let ptype = ""
let pvalue = parent.hasAttribute("data") ? parent.getAttribute("data") : parent.value
if (pvalue.search(/=|-/) != -1) {
// Less than N units ago?
if (pvalue.match(/lte/)) {
let m = pvalue.match(/lte=(\d+)([dMyw])/)
ptype = 'lt'
if (m) {
document.getElementById('datepicker_lti').value = m[1]
let sel = document.getElementById('datepicker_lts')
for (let i in sel.options) {
if (parseInt(i) >= 0) {
if (sel.options[i].value == m[2]) {
sel.options[i].selected = "selected"
} else {
sel.options[i].selected = null
}
}
}
}
}
// More than N units ago?
if (pvalue.match(/gte/)) {
ptype = 'mt'
let m = pvalue.match(/gte=(\d+)([dMyw])/)
if (m) {
document.getElementById('datepicker_mti').value = m[1]
let sel = document.getElementById('datepicker_mts')
// Go through the unit values, select the one we use
for (let i in sel.options) {
if (parseInt(i) >= 0) {
if (sel.options[i].value == m[2]) {
sel.options[i].selected = "selected"
} else {
sel.options[i].selected = null
}
}
}
}
}
// Date range?
if (pvalue.match(/dfr/)) {
ptype = 'cd'
// Make sure we have both a dfr and a dto here, catch them
let mf = pvalue.match(/dfr=(\d+-\d+-\d+)/)
let mt = pvalue.match(/dto=(\d+-\d+-\d+)/)
if (mf && mt) {
// easy peasy, just set two text fields!
document.getElementById('datepicker_cfrom').value = mf[1]
document.getElementById('datepicker_cto').value = mt[1]
}
}
// Month??
if (pvalue.match(/(\d{4})-(\d+)/)) {
ptype = 'cd'
// Make sure we have both a dfr and a dto here, catch them
let m = pvalue.match(/(\d{4})-(\d+)/)
if (m.length == 3) {
// easy peasy, just set two text fields!
let dfrom = new Date(parseInt(m[1]), parseInt(m[2]) - 1, 1, 0, 0, 0)
let dto = new Date(parseInt(m[1]), parseInt(m[2]), 0, 23, 59, 59)
document.getElementById('datepicker_cfrom').value = m[0] + "-" + dfrom.getDate()
document.getElementById('datepicker_cto').value = m[0] + "-" + dto.getDate()
}
}
calcTimespan(ptype)
}
}
function datePickerValue(seedPeriod) {
// This is for recalcing the set options if spawned from a
// select/input box with an existing value derived from an
// earlier call to datePicker
let rv = seedPeriod
if (seedPeriod && seedPeriod.search && seedPeriod.search(/=|-/) != -1) {
// Less than N units ago?
if (seedPeriod.match(/lte/)) {
let m = seedPeriod.match(/lte=(\d+)([dMyw])/)
let unitt = DATE_UNITS[m[2]]
if (parseInt(m[1]) != 1) {
unitt += "s"
}
rv = "Less than " + m[1] + " " + unitt + " ago"
}
// More than N units ago?
if (seedPeriod.match(/gte/)) {
let m = seedPeriod.match(/gte=(\d+)([dMyw])/)
let unitt = DATE_UNITS[m[2]]
if (parseInt(m[1]) != 1) {
unitt += "s"
}
rv = "More than " + m[1] + " " + unitt + " ago"
}
// Date range?
if (seedPeriod.match(/dfr/)) {
let mf = seedPeriod.match(/dfr=(\d+-\d+-\d+)/)
let mt = seedPeriod.match(/dto=(\d+-\d+-\d+)/)
if (mf && mt) {
rv = "From " + mf[1] + " to " + mt[1]
}
}
// Month??
if (seedPeriod.match(/^(\d+)-(\d+)$/)) {
let mr = seedPeriod.match(/(\d+)-(\d+)/)
if (mr) {
let dfrom = new Date(parseInt(mr[1]), parseInt(mr[2]) - 1, 1, 0, 0, 0)
rv = MONTHS[dfrom.getMonth()] + ', ' + mr[1]
}
}
}
return rv
}
function datePickerDouble(seedPeriod) {
// This basically takes a date-arg and doubles it backwards
// so >=3M becomes =>6M etc. Also returns the cutoff for
// the original date and the span in days of the original
let dbl = seedPeriod
let tspan = 1
let dfrom = new Date()
let dto = new Date()
// datepicker range?
if (seedPeriod && seedPeriod.search && seedPeriod.search(/=/) != -1) {
// Less than N units ago?
if (seedPeriod.match(/lte/)) {
let m = seedPeriod.match(/lte=(\d+)([dMyw])/)
dbl = "lte=" + (parseInt(m[1]) * 2) + m[2]
// N months ago
if (m[2] == "M") {
dfrom.setMonth(dfrom.getMonth() - parseInt(m[1]), dfrom.getDate())
}
// N days ago
if (m[2] == "d") {
dfrom.setDate(dfrom.getDate() - parseInt(m[1]))
}
// N years ago
if (m[2] == "y") {
dfrom.setYear(dfrom.getFullYear() - parseInt(m[1]))
}
// N weeks ago
if (m[2] == "w") {
dfrom.setDate(dfrom.getDate() - (parseInt(m[1]) * 7))
}
// Calc total duration in days for this time span
tspan = parseInt((dto.getTime() - dfrom.getTime() + 5000) / (1000 * 86400))
}
// More than N units ago?
if (seedPeriod.match(/gte/)) {
let m = seedPeriod.match(/gte=(\d+)([dMyw])/)
dbl = "gte=" + (parseInt(m[1]) * 2) + m[2]
// Can't really figure out a timespan for this, so...null!
// This also sort of invalidates use on the trend page, but meh..
tspan = null
dfrom = null
// Months
if (m[2] == "M") {
dto.setMonth(dto.getMonth() - parseInt(m[1]), dto.getDate())
}
// Days
if (m[2] == "d") {
dto.setDate(dto.getDate() - parseInt(m[1]))
}
// Years
if (m[2] == "y") {
dto.setYear(dto.getFullYear() - parseInt(m[1]))
}
// Weeks
if (m[2] == "w") {
dto.setDate(dto.getDate() - (parseInt(m[1]) * 7))
}
}
// Date range?
if (seedPeriod.match(/dfr/)) {
// Find from and to
let mf = seedPeriod.match(/dfr=(\d+)-(\d+)-(\d+)/)
let mt = seedPeriod.match(/dto=(\d+)-(\d+)-(\d+)/)
if (mf && mt) {
// Starts at 00:00:00 on from date
dfrom = new Date(parseInt(mf[1]), parseInt(mf[2]) - 1, parseInt(mf[3]), 0, 0, 0)
// Ends at 23:59:59 on to date
dto = new Date(parseInt(mt[1]), parseInt(mt[2]) - 1, parseInt(mt[3]), 23, 59, 59)
// Get duration in days, add 5 seconds to we can floor the value and get an integer
tspan = parseInt((dto.getTime() - dfrom.getTime() + 5000) / (1000 * 86400))
// double the distance
let dpast = new Date(dfrom)
dpast.setDate(dpast.getDate() - tspan)
dbl = seedPeriod.replace(/dfr=[^|]+/, "dfr=" + (dpast.getFullYear()) + '-' + (dpast.getMonth() + 1) + '-' + dpast.getDate())
} else {
tspan = 0
}
}
}
// just N days?
else if (parseInt(seedPeriod).toString() == seedPeriod.toString()) {
tspan = parseInt(seedPeriod)
dfrom.setDate(dfrom.getDate() - tspan)
dbl = "lte=" + (tspan * 2) + "d"
}
// Specific month?
else if (seedPeriod.match(/^(\d+)-(\d+)$/)) {
// just a made up thing...(month range)
let mr = seedPeriod.match(/(\d+)-(\d+)/)
if (mr) {
// Same as before, start at 00:00:00
dfrom = new Date(parseInt(mr[1]), parseInt(mr[2]) - 1, 1, 0, 0, 0)
// end at 23:59:59
dto = new Date(parseInt(mr[1]), parseInt(mr[2]), 0, 23, 59, 59)
// B-A, add 5 seconds so we can floor the no. of days into an integer neatly
tspan = parseInt((dto.getTime() - dfrom.getTime() + 5000) / (1000 * 86400))
// Double timespan
let dpast = new Date(dfrom)
dpast.setDate(dpast.getDate() - tspan)
dbl = "dfr=" + (dpast.getFullYear()) + '-' + (dpast.getMonth() + 1) + '-' + dpast.getDate() + "|dto=" + (dto.getFullYear()) + '-' + (dto.getMonth() + 1) + '-' + dto.getDate()
} else {
tspan = 0
}
}
return [dbl, dfrom, dto, tspan]
}
// set date in caller and hide datepicker again.
function setDatepickerDate() {
calcTimespan()
blurDatePicker()
}
// findParent: traverse DOM and see if we can find a parent to 'el'
// called 'name'. This is used for figuring out whether 'el' has
// lost focus or not.
function findParent(el, name) {
if (el.getAttribute && el.getAttribute("id") == name) {
return true
}
if (el.parentNode && el.parentNode.getAttribute) {
if (el.parentNode.getAttribute("id") != name) {
return findParent(el.parentNode, name)
} else {
return true
}
} else {
return false;
}
}
// function for hiding the date picker
function blurDatePicker(evt) {
let es = evt ? (evt.target || evt.srcElement) : null;
if ((!es || !es.parentNode || (!findParent(es, "datepicker_popup") && !findParent(es, "calendarpicker_popup"))) && !(es ? es : "null").toString().match(/javascript:void/)) {
document.getElementById('datepicker_popup').style.display = "none"
$('html').trigger('hide.bs.dropdown')
}
}
// draws the actual calendar inside a calendarPicker object
function drawCalendarPicker(obj, date) {
obj.focus()
// Default to NOW for calendar.
let now = new Date()
// if called with an existing date (YYYY-MM-DD),
// convert it to a JS date object and use that for
// rendering the calendar
if (date) {
let ar = date.split(/-/)
now = new Date(ar[0], parseInt(ar[1]) - 1, ar[2])
}
let mat = now
// Go to first day of the month
mat.setDate(1)
obj.innerHTML = "<h3>" + MONTHS[mat.getMonth()] + ", " + mat.getFullYear() + ":</h3>"
let tm = mat.getMonth()
// -- Nav buttons --
// back-a-year button
let a = document.createElement('a')
fixupPicker(a)
a.setAttribute("onclick", "drawCalendarPicker(this.parentNode, '" + (mat.getFullYear() - 1) + '-' + (mat.getMonth() + 1) + '-' + mat.getDate() + "');")
a.setAttribute("href", "javascript:void(0);")
a.setAttribute("class", "glyphicon glyphicon-fast-backward");
obj.appendChild(a)
// back-a-month button
a = document.createElement('a')
fixupPicker(a)
a.setAttribute("onclick", "drawCalendarPicker(this.parentNode, '" + mat.getFullYear() + '-' + (mat.getMonth()) + '-' + mat.getDate() + "');")
a.setAttribute("href", "javascript:void(0);")
a.setAttribute("class", "glyphicon glyphicon-step-backward");
obj.appendChild(a)
// forward-a-month button
a = document.createElement('a')
fixupPicker(a)
a.setAttribute("onclick", "drawCalendarPicker(this.parentNode, '" + mat.getFullYear() + '-' + (mat.getMonth() + 2) + '-' + mat.getDate() + "');")
a.setAttribute("href", "javascript:void(0);")
a.setAttribute("class", "glyphicon glyphicon-step-forward");
obj.appendChild(a)
// forward-a-year button
a = document.createElement('a')
fixupPicker(a)
a.setAttribute("onclick", "drawCalendarPicker(this.parentNode, '" + (mat.getFullYear() + 1) + '-' + (mat.getMonth() + 1) + '-' + mat.getDate() + "');")
a.setAttribute("href", "javascript:void(0);")
a.setAttribute("class", "glyphicon glyphicon-fast-forward");
obj.appendChild(a)
obj.appendChild(document.createElement('br'))
// Table containing the dates of the selected month
let table = document.createElement('table')
table.setAttribute("border", "1")
table.style.margin = "0 auto"
// Add header day names
let tr = document.createElement('tr');
for (let m = 0; m < 7; m++) {
let td = document.createElement('th')
td.innerHTML = DAYS_SHORTENED[m]
tr.appendChild(td)
}
table.appendChild(tr)
// Until we hit the first day in a month, add blank days
tr = document.createElement('tr');
let weekday = mat.getDay()
if (weekday == 0) {
weekday = 7
}
weekday--;
for (let i = 0; i < weekday; i++) {
let td = document.createElement('td')
tr.appendChild(td)
}
// While still in this month, add day then increment date by 1 day.
while (mat.getMonth() == tm) {
weekday = mat.getDay()
if (weekday == 0) {
weekday = 7
}
weekday--;
if (weekday == 0) {
table.appendChild(tr)
tr = document.createElement('tr');
}
let td = document.createElement('td')
// onclick for setting the calendarPicker's parent to this val.
td.setAttribute("onclick", "setCalendarDate('" + mat.getFullYear() + '-' + (mat.getMonth() + 1) + '-' + mat.getDate() + "');")
td.innerHTML = mat.getDate()
mat.setDate(mat.getDate() + 1)
tr.appendChild(td)
}
table.appendChild(tr)
obj.appendChild(table)
}
// callback for datePicker; sets the cd value to what date was picked
function setCalendarDate(what) {
$('html').on('hide.bs.dropdown', function(e) {
return false;
});
setTimeout(function() {
$('html').unbind('hide.bs.dropdown');
}, 250);
calendarpicker_spawner.value = what
let div = document.getElementById('calendarpicker_popup')
div.parentNode.focus()
div.style.display = "none"
calcTimespan('cd')
}
// caller for when someone clicks on a calendarPicker enabled field
function showCalendarPicker(parent, seedDate) {
calendarpicker_spawner = parent
// If supplied with a YYYY-MM-DD date, use this to seed the calendar
if (!seedDate) {
let m = parent.value.match(/(\d+-\d+(-\d+)?)/)
if (m) {
seedDate = m[1]
}
}
// Show or create the calendar object
let div = document.getElementById('calendarpicker_popup')
if (!div) {
div = document.createElement('div')
div.setAttribute("id", "calendarpicker_popup")
div.setAttribute("class", "calendarpicker")
document.getElementById('datepicker_popup').appendChild(div)
div.innerHTML = "Calendar goes here..."
}
div.style.display = "block"
let bb = parent.getBoundingClientRect()
// Align with the calling object, slightly below
div.style.top = (bb.bottom + 8) + "px"
div.style.left = (bb.right - 32) + "px"
drawCalendarPicker(div, seedDate)
}
/******************************************
Fetched from source/init.js
******************************************/
console.log("/******* Apache Pony Mail (Foal v/%s) Initializing ********/".format(PONYMAIL_VERSION))
// Adjust titles:
document.title = prefs.title;
for (let title of document.getElementsByClassName("title")) {
title.innerText = prefs.title;
}
console.log("Initializing escrow checks");
window.setInterval(escrow_check, 250);
console.log("Initializing key command logger");
window.addEventListener('keyup', keyCommands);
window.addEventListener('load', function() {
let powered_by = "Powered by Apache Pony Mail (Foal v/%s ~%s)".format(PONYMAIL_VERSION, PONYMAIL_REVISION);
let pb = document.getElementById("powered_by");
if (pb) {
pb.innerHTML = powered_by
}
document.body.appendChild(new HTML('footer', {
class: 'footer hidden-xs'
}, [
new HTML('div', {
class: 'container'
}, [
new HTML('p', {
class: 'muted'
}, powered_by)
])
]));
});
console.log("initializing pop state checker");
window.onpopstate = function(event) {
console.log("Popping state");
return parseURL({
cached: true
});
};
/******************************************
Fetched from source/key-commands.js
******************************************/
// Generic modal function
function modal(title, msg, type, isHTML) {
let modalId = document.getElementById('modal');
let text = document.getElementById('modal_text');
if (modalId == undefined) {
text = new HTML('p', {
id: 'modal_text'
}, "");
modalId = new HTML('div', {
id: 'modal'
}, [
new HTML('div', {
id: 'modal_content'
}, [
new HTML('span', {
id: 'modal_close',
onclick: 'document.getElementById("modal").style.display = "none";'
}, 'X'),
new HTML('h2', {
id: 'modal_title'
}, title),
new HTML('div', {}, text)
])
]);
document.body.appendChild(modalId);
}
if (type) {
modalId.setAttribute("class", "modal_" + type);
} else {
modalId.setAttribute("class", undefined);
}
modalId.style.display = 'block';
document.getElementById('modal_title').innerText = title;
// If we trust HTML, use it. Otherwise only show as textNode.
if (isHTML) {
text.innerHTML = msg;
} else {
msg = msg.replace(/<.*?>/g, ""); // strip HTML tags
text.innerText = msg;
}
}
// Helper for determining if an email is open or not...
function anyOpen() {
let open = (G_current_email_idx !== undefined) ? true : false;
console.log("Emails open? " + open);
return open;
}
// Helper function for hiding windows and open tabs
// Hide previous action on first escape, hide everything on second escape
function hideWindows(force_all) {
// First, check if we want to hide a modal
let modalId = document.getElementById('modal');
if (modalId && modalId.style.display == 'block') {
modalId.style.display = 'none';
if (force_all !== true) return;
}
// RThen, check if we want to hide a composer modal
let cmodal = document.getElementById('composer_modal');
if (cmodal && cmodal.style.display == 'block') {
cmodal.style.display = 'none';
if (force_all !== true) return;
}
// Check Advanced Search
let as = document.getElementById('advanced_search');
if (as && as.style.display == 'block') {
as.style.display = 'none';
if (force_all !== true) return;
}
// Check for individually opened email
if (G_current_email_idx !== undefined) {
console.log("Hiding placeholder at index %u".format(G_current_email_idx));
let placeholder = document.getElementById('email_%u'.format(G_current_email_idx));
if (placeholder) {
placeholder.style.display = 'none';
}
G_current_email_idx = undefined; // undef this even if we can't find the email
if (force_all !== true) return;
}
// if viewing a single thread, disregard the collapses below - the won't make sense!
if (location.href.match(/thread(?:\.html)?/)) return;
// Finally, check for other opened emails, close 'em all
let placeholders = document.getElementsByClassName('email_placeholder');
for (let placeholder of placeholders) {
if (placeholder.style.display == 'block') {
console.log("Hiding placeholder %s".format(placeholder.getAttribute('id')));
placeholder.style.display = 'none';
// Reset scroll cache
try {
window.scrollTo(0, 0);
} catch (e) {}
}
}
placeholders = document.getElementsByClassName('email_placeholder_chatty');
for (let placeholder of placeholders) {
if (placeholder.style.display == 'block') {
console.log("Hiding placeholder %s".format(placeholder.getAttribute('id')));
placeholder.style.display = 'none';
// Reset scroll cache
try {
window.scrollTo(0, 0);
} catch (e) {}
}
}
}
// Show keyboard commands
function showHelp() {
modal("Keyboard shortcuts:", "<pre><kbd>H</kbd>: Show this help window.\n<kbd>C</kbd>: Compose a new email to this list.\n<kbd>R</kbd>: Reply to the currently active thread.\n<kbd>S</kbd>: Go to the search bar.\n<kbd>Escape</kbd>: Hide modals or collapse threads.\n<kbd>RightArrow</kbd>: Go to next bunch of emails in list view.\n<kbd>LeftArrow</kbd>: Go to previous bunch of emails in list view.</pre>", "help", true);
}
// Function for capturing and evaluating key strokes
// If it matches a known shortcut, execute that then..
function keyCommands(e) {
if (!e.ctrlKey) {
// Get calling element and its type
let target = e.target || e.srcElement;
let type = target.tagName;
// We won't jump out of an input field!
if (['INPUT', 'TEXTAREA', 'SELECT'].has(type)) {
return;
}
switch (e.key) {
case 's':
document.getElementById('q').focus();
return;
case 'h':
showHelp();
return;
case 'c':
compose_email(null, `${G_current_list}@${G_current_domain}`);
return;
case 'r':
console.log(G_current_open_email);
if (G_current_open_email && G_full_emails[G_current_open_email]) {
compose_email(G_current_open_email);
}
return;
case 'Escape':
hideWindows();
return;
case 'ArrowRight': // quick-next
if (G_current_json) { // IF list view...
let blobs = G_current_json.emails;
if (G_current_listmode == 'threaded') blobs = G_current_json.thread_struct;
let no_emails = blobs.length;
if (G_current_email_idx == undefined && G_current_json && (G_current_index_pos + G_current_per_page) < no_emails) {
listview_header({
pos: G_current_index_pos + G_current_per_page
}, G_current_json);
}
}
return;
case 'ArrowLeft': // quick previous
if (G_current_json) { // IF list view...
if (G_current_email_idx == undefined && G_current_json && (G_current_index_pos - G_current_per_page) >= 0) {
listview_header({
pos: G_current_index_pos - G_current_per_page
}, G_current_json);
}
}
return;
}
}
}
// swipe left/right for next/previous page on mobile
function ponymail_swipe(event) {
// Only accept "big" swipes
let len = Math.abs(event.detail.swipestart.coords[0] - event.detail.swipestop.coords[0]);
let direction = event.detail.swipestart.coords[0] > event.detail.swipestop.coords[0] ? 'left' : 'right';
console.log("swipe %s of %u pixels detected".format(direction, len));
if (len < 20) return false;
if (direction == 'right') {
if (G_current_json) { // IF list view...
if (G_current_email_idx == undefined && G_current_json && (G_current_index_pos - G_current_per_page) >= 0) {
listview_header({
pos: G_current_index_pos - G_current_per_page
}, G_current_json);
}
}
} else if (direction == 'left') {
if (G_current_json) { // IF list view...
let blobs = G_current_json.emails;
if (G_current_listmode == 'threaded') blobs = G_current_json.thread_struct;
let no_emails = blobs.length;
if (G_current_email_idx == undefined && G_current_json && (G_current_index_pos + G_current_per_page) < no_emails) {
listview_header({
pos: G_current_index_pos + G_current_per_page
}, G_current_json);
}
}
}
return false;
}
/******************************************
Fetched from source/list-index.js
******************************************/
let list_json = {}
function list_index(state, json) {
if (json) {
list_json = json;
}
let letter = 'a';
let lists = document.getElementById('list_picker_ul');
if (state && state.letter) {
letter = state.letter;
for (let xtab of lists.childNodes) {
if (xtab.innerText == state.letter) {
xtab.setAttribute("class", 'active');
} else if (xtab.setAttribute) {
xtab.setAttribute("class", "");
}
}
} else {
let letters = 'abcdefghijklmnopqrstuvwxyz#';
for (let char of letters) {
let xletter = char.toUpperCase();
let li = new HTML('li', {
onclick: 'list_index({letter: "%s"});'.format(xletter),
class: (xletter == 'A') ? 'active' : null
}, xletter);
lists.inject(li);
}
}
let list_ul = document.getElementById('list_index_wide_lists');
list_ul.textContent = "";
let domains = Object.keys(list_json.lists);
domains.sort();
for (let domain_name of domains) {
if (is_letter(domain_name, letter)) {
console.log(domain_name);
let li = new HTML('li', {});
let a = new HTML('a', {
href: 'list.html?%s'.format(domain_name)
}, domain_name);
li.inject(a);
list_ul.inject(li);
}
}
}
function is_letter(domain, letter) {
if (letter == '#' && domain.match(/^([^a-zA-Z]+)/)) return true
else return domain.toLowerCase().startsWith(letter.toLowerCase());
}
function list_index_onepage(state, json) {
let obj = document.getElementById('list_index_child');
obj.style.padding = '8px';
let domains = Object.keys(json.lists);
domains.sort();
let letter = '';
for (let domain of domains) {
let l = domain[0];
if (l != letter) {
letter = l;
let lhtml = new HTML('h2', {}, l.toUpperCase());
obj.inject(lhtml);
}
let a = new HTML('a', {
href: 'list.html?%s'.format(domain)
}, domain);
obj.inject(['- ', a]);
obj.inject(new HTML('br'));
}
if (domains.length > pm_config.LOTS_OF_LISTS) {
list_index(state, json);
} else {
let wide_obj = document.getElementById('list_index_child_wide');
let new_obj = obj.cloneNode(true);
new_obj.setAttribute("id", "list_index_child_wide");
wide_obj.replaceWith(new_obj);
console.log(new_obj);
}
}
// onload function for index.html
function prime_list_index() {
GET('%sapi/preferences.lua'.format(G_apiURL), list_index_onepage, {});
}
/******************************************
Fetched from source/listview-flat.js
******************************************/
let compact_email_height = 24; // a normal email element is 24 pixels high
let preview_email_height = 40;
let narrow_width = 600; // <= 600 pixels and we're in narrow view
function calc_per_page() {
// Figure out how many emails per page
let body = document.body;
let html = document.documentElement;
let height = Math.max(body.scrollHeight,
html.clientHeight, html.scrollHeight);
let width = Math.max(body.scrollWidth,
html.clientWidth, html.scrollWidth);
let email_h = G_current_listmode_compact ? compact_email_height : preview_email_height;
if (width < narrow_width) {
console.log("Using narrow view, reducing emails per page...");
email_h = G_current_listmode_compact ? compact_email_height * 1.5 : preview_email_height * 2;
}
height -= document.getElementById("emails").getBoundingClientRect().y + 16; // top area height plus footer
email_h += 2;
let per_page = Math.max(5, Math.floor(height / email_h));
per_page -= per_page % 5;
console.log("Viewport is %ux%u. We can show %u emails per page".format(width, height, per_page));
return per_page;
}
function listview_flat(json, start) {
let list = document.getElementById('emails');
list.innerHTML = "";
let s = start || 0;
let n;
if (json.emails && json.emails.length) {
for (n = s; n < (s + G_current_per_page); n++) {
let z = json.emails.length - n - 1; // reverse order by default
if (json.emails[z]) {
let item = listview_flat_element(json.emails[z], z);
list.inject(item);
// Hidden placeholder for expanding email(s)
let placeholder = new HTML('div', {
class: G_chatty_layout ? 'email_placeholder_chatty' : 'email_placeholder',
id: 'email_%u'.format(z)
});
list.inject(placeholder);
}
}
} else {
list.inject(txt("No emails found..."));
}
}
function listview_flat_element(eml, idx) {
let link_wrapper = new HTML('a', {
href: 'thread/%s'.format(eml.id),
onclick: 'return(expand_email_threaded(%u, true));'.format(idx)
});
let element = new HTML('div', {
class: G_current_listmode_compact ? "listview_email_compact" : "listview_email_flat"
}, " ");
// Add gravatar
let gravatar = new HTML('img', {
class: "gravatar",
src: GRAVATAR_URL.format(eml.gravatar)
});
element.inject(gravatar);
// Add author
let authorName = eml.from.replace(/\s*<.+>/, "").replace(/"/g, '');
let authorEmail = eml.from.match(/\s*<(.+@.+)>\s*/);
if (authorName.length == 0) authorName = authorEmail ? authorEmail[1] : "(No author?)";
let author = new HTML('span', {
class: "listview_email_author"
}, authorName);
element.inject(author);
// reasons to show the list name
let showList = G_current_domain == 'inbox' || G_current_list == '*' || G_current_domain == '*';
// If space and needed, inject ML name
if (!G_current_listmode_compact && showList) {
author.style.lineHeight = '16px';
author.inject(new HTML('br'));
author.inject(new HTML('span', {
class: "label label-primary",
style: "font-style: italic; font-size: 1rem;"
}, eml.list_raw.replace(/[<>]/g, '').replace('.', '@', 1)));
}
// Combined space for subject + body teaser
let as = new HTML('div', {
class: 'listview_email_as'
});
let suba = new HTML('a', {}, eml.subject === '' ? '(No subject)' : eml.subject);
if (G_current_listmode_compact && showList) {
let kbd = new HTML('kbd', {
class: 'listview_kbd'
}, eml.list_raw.replace(/[<>]/g, '').replace('.', '@', 1))
suba = [kbd, suba];
}
let subject = new HTML('div', {
class: 'listview_email_subject email_unread'
}, suba);
as.inject(subject);
if (!G_current_listmode_compact) { // No body in compact mode
let body = new HTML('div', {
class: 'listview_email_body'
}, eml.body);
as.inject(body);
}
element.inject(as);
// Labels
let labels = new HTML('div', {
class: 'listview_email_labels'
});
let date = new Date(eml.epoch * 1000.0);
let now = new Date();
let dl = new HTML('span', {
class: 'label label-default'
}, date.ISOBare());
if (now - date < 86400000) {
dl.setAttribute("class", "label label-primary");
}
labels.inject(dl);
element.inject(labels);
link_wrapper.inject(element);
return link_wrapper;
}
/******************************************
Fetched from source/listview-header.js
******************************************/
let prev_listview_json = {};
let prev_listview_state = {};
function listview_header(state, json) {
if (isEmpty(json)) { // Bad search request?
modal("Bad search request", "Your request could not be parsed.", "warning");
return;
}
let list_title = json.list;
prev_listview_json = json;
prev_listview_state = state;
if (G_current_list == 'virtual' && G_current_domain == 'inbox') {
list_title = "Virtual inbox, past 30 days";
}
let blobs = json.emails ? json.emails : [];
if (G_current_listmode == 'threaded' || G_current_listmode == 'treeview') blobs = json.thread_struct;
if (G_current_year && G_current_month) {
list_title += ", %s %u".format(MONTHS[G_current_month - 1], G_current_year);
} else {
list_title += ", past month";
}
if (json.searchParams && (
json.searchParams.q &&
json.searchParams.q.length ||
(json.searchParams.d || "").match(/=/))
){
list_title = "Custom search";
}
document.title = list_title + " - " + prefs.title;
document.getElementById('listview_title').innerText = list_title + ":";
let download = new HTML('button', {
title: 'Download as mbox archive',
download: 'true'
}, new HTML('span', {
class: 'glyphicon glyphicon-save'
}, " "));
document.getElementById('listview_title').inject(download);
download.addEventListener('click', () => {
let sep = '?';
let dl_url = G_apiURL + 'api/mbox.lua';
for (let key in json.searchParams || {}) {
dl_url += sep + key + "=" + encodeURIComponent(json.searchParams[key]);
sep = '&';
}
location.href = dl_url;
});
let chevrons = document.getElementById('listview_chevrons');
G_current_per_page = calc_per_page();
G_current_index_pos = state.pos || 0;
let first = 1;
if (state && state.pos) {
first = 1 + state.pos;
}
if (!blobs || blobs.length == 0) {
chevrons.innerHTML = "No topics to show";
blobs = [];
} else {
chevrons.innerHTML = "Showing <b>%u through %u</b> of <b>%u</b> topics&nbsp;".format(first, Math.min(first + G_current_per_page - 1, blobs.length), blobs.length || 0);
}
let pprev = Math.max(0, first - G_current_per_page - 1);
let cback = new HTML('button', {
onclick: 'listview_header({pos: %u}, G_current_json);'.format(pprev),
disabled: (first == 1) ? 'true' : null
}, new HTML('span', {
class: 'glyphicon glyphicon-chevron-left'
}, " "));
chevrons.inject(cback);
let pnext = first + G_current_per_page - 1;
let cforward = new HTML('button', {
onclick: 'listview_header({pos: %u}, G_current_json);'.format(pnext),
disabled: (first + G_current_per_page - 1 >= blobs.length) ? 'true' : null
}, new HTML('span', {
class: 'glyphicon glyphicon-chevron-right'
}, " "));
chevrons.inject(cforward);
let crefresh = new HTML('button', {
onclick: 'parseURL({noprefs: true});',
title: 'Refresh results',
style: {
marginLeft: '8px'
}
}, new HTML('span', {
class: 'glyphicon glyphicon-refresh'
}, " "));
chevrons.inject(crefresh);
console.log(G_current_listmode)
if (state && state.pos != undefined) {
if (G_current_listmode == 'threaded') {
listview_threaded(json, state.pos);
} else if (G_current_listmode == 'flat') {
listview_flat(json, state.pos);
} else {
listview_treeview(json, state.pos);
}
}
}
function listview_list_lists(state, json) {
let lists = document.getElementById('list_picker_ul');
let searching = (state && state.search === true) ? true : false;
if (state && state.to) {
let tab;
let tabs = lists.childNodes;
for (let xtab of tabs) {
if ((state.to == 'search' && xtab.getAttribute('id') == 'tab_search') || (xtab.innerText == state.to || xtab.getAttribute('data-list') == state.to)) {
tab = xtab;
tab.setAttribute("class", state.to == 'search' ? 'search' : 'active');
} else if (xtab.getAttribute("class") != 'list_all_narrow' && xtab.getAttribute("class") != 'others') {
xtab.setAttribute("class", "");
}
}
return;
}
if (!json) {
json = G_ponymail_preferences;
}
if (lists) {
lists.innerHTML = "";
if (isHash(json.lists) && json.lists[G_current_domain]) {
let lists_sorted = [];
for (let list in json.lists[G_current_domain]) {
lists_sorted.push([list, json.lists[G_current_domain][list]]);
}
lists_sorted.sort((a, b) => b[1] - a[1]);
let alists = [];
for (let list of lists_sorted) alists.push(list[0]);
if (G_current_list != '*' && G_current_domain != '*') {
alists.remove(G_current_list);
alists.unshift(G_current_list);
}
let maxlists = (searching && 3 || 4);
if (alists.length == maxlists + 1) maxlists++; // skip drop-down if only one additional list (#54)
for (let i = 0; i < alists.length; i++) {
if (i >= maxlists) break;
let listname = alists[i];
let listnametxt = listname;
if (pm_config.long_tabs) {
listnametxt = '%s@%s'.format(listname, G_current_domain);
}
let li = new HTML('li', {
onclick: 'switch_list(this, "tab");',
class: (listname == G_current_list && !searching) ? 'active' : null
}, listnametxt);
li.setAttribute("data-list", '%s@%s'.format(listname, G_current_domain));
lists.inject(li);
}
if (alists.length > maxlists) {
let other_lists_sorted = [];
for (let i = maxlists; i < alists.length; i++) {
other_lists_sorted.push(alists[i]);
}
other_lists_sorted.sort();
let li = new HTML('li', {
class: 'others'
});
let otherlists = new HTML('select', {
class: 'listview_others',
onchange: 'switch_list(this.value);'
});
otherlists.inject(new HTML('option', {
disabled: 'disabled',
selected: 'selected'
}, 'Other lists (%u):'.format(other_lists_sorted.length)));
li.inject(otherlists);
for (let listname of other_lists_sorted) {
let opt = new HTML('option', {
value: "%s@%s".format(listname, G_current_domain)
}, listname);
otherlists.inject(opt);
}
lists.inject(li);
}
// All lists, for narrow UI
let all_lists_narrow = [];
for (let alist of alists) {
all_lists_narrow.push(alist);
}
all_lists_narrow.sort();
let li = new HTML('li', {
class: 'list_all_narrow'
});
let otherlists = new HTML('select', {
class: 'listview_others',
onchange: 'switch_list(this.value);'
});
otherlists.inject(new HTML('option', {
disabled: 'disabled',
selected: 'selected'
}, "%s@%s".format(G_current_list, G_current_domain)));
li.inject(otherlists);
for (let listname of all_lists_narrow) {
let opt = new HTML('option', {
value: "%s@%s".format(listname, G_current_domain)
}, listname);
otherlists.inject(opt);
}
lists.inject(li);
}
}
if (searching) {
let li = new HTML('li', {
onclick: 'switch_list(this, "tab");',
id: 'tab_search',
class: 'search'
}, "Search: %s".format(state.query));
li.setAttribute("data-url", state.url);
li.setAttribute("data-href", location.href);
li.setAttribute("data-list", '%s@%s'.format(state.list, state.domain));
lists.inject(li);
}
// Populate the project selector
if (isHash(json.lists)) {
let no_projects = 0;
let select = document.getElementById('project_select');
if (!select || G_select_primed) return;
let opts = {}
let doms = [];
for (let domain in json.lists) {
let option = new HTML('option', {
value: domain
}, domain);
opts[domain] = option;
doms.push(domain);
no_projects++;
}
if (no_projects > 1 || G_current_domain == '*') {
select.innerHTML = "";
let title = new HTML('option', {
disabled: 'disabled',
selected: 'true',
value: ''
}, "Available projects (%u):".format(no_projects));
select.inject(title);
doms.sort();
for (let dom of doms) {
select.inject(opts[dom]);
}
select.style.display = "inline-block";
G_select_primed = true; // mark it primed so we don't generate it again later
}
}
}
function switch_project(domain) {
// TODO: improve this
if (G_ponymail_preferences && G_ponymail_preferences.lists[domain]) {
// Switch to the most populous, but not commits/cvs
let lists_sorted = [];
for (let list in G_ponymail_preferences.lists[domain]) {
lists_sorted.push([list, G_ponymail_preferences.lists[domain][list]]);
}
lists_sorted.sort((a, b) => b[1] - a[1]);
let lists = [];
for (let list of lists_sorted) lists.push(list[0]);
let listname = lists[0];
let n = 1;
if (lists.length > n) {
while (pm_config.boring_lists.has(listname) && lists.length > n) {
listname = lists[n];
n++;
}
if (lists.has(pm_config.favorite_list)) {
listname = pm_config.favorite_list;
}
}
switch_list('%s@%s'.format(listname, domain));
} else {
switch_list('%s@%s'.format(pm_config.favorite_list, domain));
}
}
function switch_list(list, from) {
let listid = list;
if (typeof list == 'object') {
listid = list.getAttribute("data-list") || list.innerText;
let dataURL = list.getAttribute('data-url');
if (dataURL) {
let bits = listid.split("@");
G_current_list = bits[0];
G_current_domain = bits[1];
GET(dataURL, renderListView, {
search: true,
cached: true
});
let newhref = list.getAttribute('data-href');
if (location.href !== newhref) {
window.history.pushState({}, null, newhref);
}
listview_list_lists({
to: 'search'
});
return;
}
}
let bits = listid.split("@");
G_current_list = bits[0];
G_current_domain = bits[1];
G_current_year = 0;
G_current_month = 0;
let newhref = "list.html?%s".format(listid);
if (location.href !== newhref) {
window.history.pushState({}, null, newhref);
}
console.log("Switching list to %s...".format(listid));
listview_list_lists({
to: from ? listid : undefined
});
post_prime({
cached: true,
from: from
});
}
window.addEventListener('orientationchange', function() {
window.setTimeout(function() {
if (anyOpen() == false && location.href.match(/\/list(\.html)?/) && location.search.length) {
listview_header(prev_listview_state, prev_listview_json);
}
}, 100);
}, false);
/******************************************
Fetched from source/listview-threaded.js
******************************************/
function calc_email_width() {
// Get email width; used for calculating reply nesting offsets
let body = document.body;
let html = document.documentElement;
return Math.max(body.scrollWidth, body.offsetWidth,
html.clientWidth, html.scrollWidth, html.offsetWidth);
}
function listview_threaded(json, start) {
let list = document.getElementById('emails');
list.innerHTML = "";
let s = start || 0;
if (json.thread_struct && json.thread_struct.length) {
for (let n = s; n < (s + G_current_per_page); n++) {
let z = json.thread_struct.length - n - 1; // reverse order by default
if (json.thread_struct[z]) {
let item = listview_threaded_element(json.thread_struct[z], z);
list.inject(item);
// Hidden placeholder for expanding email(s)
let placeholder = new HTML('div', {
class: G_chatty_layout ? 'email_placeholder_chatty' : 'email_placeholder',
id: 'email_%u'.format(z)
});
list.inject(placeholder);
}
}
} else {
list.inject(txt("No emails found..."));
}
}
function find_email(id) {
if (!G_current_json.emails) return null;
for (let email of G_current_json.emails) {
if (email.id == id) return email;
}
return null;
}
function count_replies(thread) {
let reps = 0;
if (isArray(thread.children)) {
for (let child of thread.children) {
if (child.tid == thread.tid) reps--;
reps++;
reps += count_replies(child);
}
}
return reps;
}
function count_people(thread, hash) {
let ppl = hash || {};
let eml = find_email(thread.tid);
if (eml) ppl[eml.from] = true;
if (isArray(thread.children)) {
for (let child of thread.children) {
count_people(child, ppl);
}
}
let n = 0;
for (let _ in ppl) n++;
return n;
}
function last_email(thread) {
let newest = thread.epoch;
if (isArray(thread.children)) {
for (let child of thread.children) {
newest = Math.max(newest, last_email(child));
}
}
return newest;
}
function listview_threaded_element(thread, idx) {
let eml = find_email(thread.tid);
if (!eml) {
return;
}
let link_wrapper = new HTML('a', {
href: 'thread/%s'.format(eml.id),
onclick: 'return(expand_email_threaded(%u));'.format(idx)
});
let element = new HTML('div', {
class: G_current_listmode_compact ? "listview_email_compact" : "listview_email_flat"
}, " ");
let date = new Date(eml.epoch * 1000.0);
let now = new Date();
// Add gravatar
let gravatar = new HTML('img', {
class: "gravatar",
src: GRAVATAR_URL.format(eml.gravatar)
});
element.inject(gravatar);
// Add author
let authorName = eml.from.replace(/\s*<.+>/, "").replace(/"/g, '');
let authorEmail = eml.from.match(/\s*<(.+@.+)>\s*/);
if (authorName.length == 0) authorName = authorEmail ? authorEmail[1] : "(No author?)";
let author = new HTML('span', {
class: "listview_email_author"
}, authorName);
element.inject(author);
// reasons to show the list name
let showList = G_current_domain == 'inbox' || G_current_list == '*' || G_current_domain == '*';
// If space and needed, inject ML name
if (!G_current_listmode_compact && showList) {
author.style.lineHeight = '16px';
author.inject(new HTML('br'));
author.inject(new HTML('span', {
class: "label label-primary",
style: "font-style: italic; font-size: 1rem;"
}, eml.list_raw.replace(/[<>]/g, '').replace('.', '@', 1)));
}
// Combined space for subject + body teaser
let as = new HTML('div', {
class: 'listview_email_as'
});
let suba = new HTML('a', {}, eml.subject === '' ? '(No subject)' : eml.subject);
if (G_current_listmode_compact && showList) {
let kbd = new HTML('kbd', {
class: 'listview_kbd'
}, eml.list_raw.replace(/[<>]/g, '').replace('.', '@', 1))
suba = [kbd, suba];
}
let subject = new HTML('div', {
class: 'listview_email_subject email_unread'
}, suba);
as.inject(subject);
if (!G_current_listmode_compact) { // No body teaser in compact mode
let body = new HTML('div', {
class: 'listview_email_body'
}, eml.body);
as.inject(body);
}
element.inject(as);
// Labels
let labels = new HTML('div', {
class: 'listview_email_labels'
});
// Participants
let ppl = count_people(thread);
let ptitle = (ppl == 1) ? "one participant" : "%u participants".format(ppl);
let people = new HTML('span', {
class: 'label label-default',
title: ptitle
}, [
new HTML('span', {
class: 'glyphicon glyphicon-user'
}, ' '),
" %u".format(ppl)
]);
labels.inject(people);
// Replies
let reps = count_replies(thread);
let rtitle = (reps == 1) ? "one reply" : "%u replies".format(reps);
let repl = new HTML('span', {
class: 'label label-default',
title: rtitle
}, [
new HTML('span', {
class: 'glyphicon glyphicon-envelope'
}, ' '),
" %u".format(reps)
]);
labels.inject(repl);
// Date
date = new Date(last_email(thread) * 1000.0);
let dl = new HTML('span', {
class: 'label label-default'
}, date.ISOBare());
if (now - date < 86400000) {
dl.setAttribute("class", "label label-primary");
}
labels.inject(dl);
element.inject(labels);
link_wrapper.inject(element);
return link_wrapper;
}
/******************************************
Fetched from source/listview-treeview.js
******************************************/
function email_idx(email) {
// Locates the index position of an email in our current json storage
for (const [idx, eml] of G_current_json.emails.entries()) {
if (eml.id === email.id) {
return idx
}
}
return 0
}
function listview_treeview(json, start) {
let list = document.getElementById('emails');
list.innerHTML = "";
let s = start || 0;
let email_ordered = [];
for (let thread of json.thread_struct) {
let eml = find_email(thread.tid);
if (eml) email_ordered.push(eml);
for (let child of thread.children) {
let eml = find_email(child.tid);
if (eml) email_ordered.push(eml);
}
}
if (email_ordered.length) {
for (let n = s; n < (s + G_current_per_page); n++) {
let z = email_ordered.length - n - 1; // reverse order by default
if (email_ordered[z]) {
let item = listview_flat_element(email_ordered[z], email_idx(email_ordered[z]));
list.inject(item);
// Hidden placeholder for expanding email(s)
let placeholder = new HTML('div', {
class: G_chatty_layout ? 'email_placeholder_chatty' : 'email_placeholder',
id: 'email_%u'.format(z)
});
list.inject(placeholder);
}
}
} else {
list.inject(txt("No emails found..."));
}
}
/******************************************
Fetched from source/mgmt.js
******************************************/
let admin_current_email = null;
let admin_email_meta = {};
let audit_page = 0;
let audit_size = 30;
let mgmt_prefs = {}
async function POST(url, formdata, state) {
const resp = await fetch(url, {
credentials: "same-origin",
mode: "same-origin",
method: "post",
headers: {
"Content-Type": "application/json"
},
body: formdata
});
return resp
}
// Removes an attachment from the archives
async function admin_del_attachment(hash) {
if (!confirm("Are you sure you wish remove this attachment from the archives?")) {
return
}
// rewrite attachments for email
let new_attach = [];
for (let el of admin_email_meta.attachments) {
if (el.hash != hash) {
new_attach.push(el);
}
}
admin_email_meta.attachments = new_attach;
let formdata = JSON.stringify({
action: "delatt",
document: hash
});
// remove attachment
let rv = await POST('%sapi/mgmt.json'.format(G_apiURL), formdata, {});
let response = await rv.text();
// Edit email in place
admin_save_email(true);
if (rv.status == 200) {
modal("Attachment removed", "Server responded with: " + response, "help");
} else {
modal("Something went wrong!", "Server responded with: " + response, "error");
}
}
// Hides an email from the archives
async function admin_hide_email() {
if (!confirm("Are you sure you wish to hide this email from the archives?")) {
return
}
let formdata = JSON.stringify({
action: "hide",
document: admin_current_email
});
let rv = await POST('%sapi/mgmt.json'.format(G_apiURL), formdata, {});
let response = await rv.text();
if (rv.status == 200) {
modal("Email hidden", "Server responded with: " + response, "help");
} else {
modal("Something went wrong!", "Server responded with: " + response, "error");
}
}
async function admin_unhide_email() {
if (!confirm("Are you sure you wish to unhide this email?")) {
return
}
let formdata = JSON.stringify({
action: "unhide",
document: admin_current_email
});
let rv = await POST('%sapi/mgmt.json'.format(G_apiURL), formdata, {});
let response = await rv.text();
if (rv.status == 200) {
modal("Email unhidden", "Server responded with: " + response, "help");
} else {
modal("Something went wrong!", "Server responded with: " + response, "error");
}
}
// Fully deletes an email from the archives
async function admin_delete_email() {
if (!confirm("Are you sure you wish to remove this email from the archives?")) {
return
}
let formdata = JSON.stringify({
action: "delete",
document: admin_current_email
});
let rv = await POST('%sapi/mgmt.json'.format(G_apiURL), formdata, {});
let response = await rv.text();
if (rv.status == 200) {
modal("Email removed", "Server responded with: " + response, "help");
} else {
modal("Something went wrong!", "Server responded with: " + response, "error");
}
}
// Saves an email with edits
async function admin_save_email(edit_attachment = false) {
let from = document.getElementById('email_from').value;
let subject = document.getElementById('email_subject').value;
let listname = document.getElementById('email_listname').value;
let is_private = document.getElementById('email_private').value;
let body = document.getElementById('email_body').value;
let attach = null;
if (edit_attachment) {
attach = admin_email_meta.attachments;
}
let formdata = JSON.stringify({
action: "edit",
document: admin_current_email,
from: from,
subject: subject,
list: listname,
private: is_private,
body: body,
attachments: attach
})
let rv = await POST('%sapi/mgmt.json'.format(G_apiURL), formdata, {});
let response = await rv.text();
if (edit_attachment && rv.status == 200) return
if (rv.status == 200) {
modal("Email changed", "Server responded with: " + response, "help");
} else {
modal("Something went wrong!", "Server responded with: " + response, "error");
}
}
function admin_email_preview(stats, json) {
admin_current_email = json.mid;
admin_email_meta = json;
let cp = document.getElementById("panel");
let div = new HTML('div', {
style: {
margin: '5px'
}
});
cp.inject(div);
div.inject(new HTML('h1', {}, "Editing email " + json.mid + ":"));
// Author
let author_field = new HTML('div', {
class: 'email_kv_edit'
});
let author_key = new HTML('div', {
class: 'email_key'
}, "From: ");
let author_value = new HTML('input', {
id: 'email_from',
style: {
width: "480px"
},
value: json.from
});
author_field.inject([author_key, author_value]);
div.inject(author_field);
// Subject
let subject_field = new HTML('div', {
class: 'email_kv_edit'
});
let subject_key = new HTML('div', {
class: 'email_key'
}, "Subject: ");
let subject_value = new HTML('input', {
id: 'email_subject',
style: {
width: "480px"
},
value: json.subject
});
subject_field.inject([subject_key, subject_value]);
div.inject(subject_field);
// Date
let date_field = new HTML('div', {
class: 'email_kv_edit'
});
let date_key = new HTML('div', {
class: 'email_key'
}, "Date: ");
let date_value = new HTML('div', {
class: 'email_value'
}, new Date(json.epoch * 1000.0).ISOBare());
date_field.inject([date_key, date_value]);
div.inject(date_field);
// List
let listname = json.list_raw.replace(".", "@", 1).replace(/[<>]/g, "");
let list_field = new HTML('div', {
class: 'email_kv_edit'
});
let list_key = new HTML('div', {
class: 'email_key'
}, "List: ");
let list_value = new HTML('input', {
id: 'email_listname',
style: {
width: "480px"
},
value: listname
});
list_field.inject([list_key, list_value]);
div.inject(list_field);
// Private email?
let priv_field = new HTML('div', {
class: 'email_kv_edit'
});
let priv_key = new HTML('div', {
class: 'email_key'
}, "Visibility: ");
let priv_value = new HTML('select', {
id: 'email_private'
});
priv_value.inject(new HTML('option', {
value: 'no',
style: {
color: 'green'
},
selected: json.private ? null : "selected"
}, "Public"));
priv_value.inject(new HTML('option', {
value: 'yes',
style: {
color: 'red'
},
selected: json.private ? "selected" : null
}, "Private"));
priv_field.inject([priv_key, priv_value]);
div.inject(priv_field);
// Attachments?
if (json.attachments && json.attachments.length > 0) {
let attach_field = new HTML('div', {
class: 'email_kv'
});
let attach_key = new HTML('div', {
class: 'email_key'
}, "Attachment(s): ");
let alinks = [];
for (let attachment of json.attachments) {
let link = `${G_apiURL}api/email.lua?attachment=true&id=${encodeURIComponent(json.mid)}&file=${encodeURIComponent(attachment.hash)}`;
let a = new HTML('a', {
href: link,
target: '_blank'
}, attachment.filename);
alinks.push(a);
let fs = ` ${attachment.size} bytes`;
if (attachment.size >= 1024) fs = ` ${Math.floor(attachment.size/1024)} KB`;
if (attachment.size >= 1024 * 1024) fs = ` ${Math.floor(attachment.size/(1024*10.24))/100} MB`;
alinks.push(fs);
let adel = new HTML('a', {
onclick: `admin_del_attachment('${attachment.hash}');`,
href: "javascript:void(0);"
}, "Delete attachment");
alinks.push(adel);
alinks.push(new HTML('br'));
}
let attach_value = new HTML('div', {
class: 'email_value'
}, alinks);
attach_field.inject([attach_key, attach_value]);
div.inject(attach_field);
}
let text = new HTML('textarea', {
id: 'email_body',
style: {
width: "100%",
height: "480px"
}
}, json.body);
div.inject(text);
let btn_edit = new HTML('button', {
onclick: "admin_save_email();"
}, "Save changes to archive");
let btn_del = new HTML('button', {
onclick: "admin_delete_email();",
style: {
marginLeft: "36px",
color: 'red'
}
}, "Delete email from archives");
let btn_hide = new HTML('button', {
onclick: "admin_hide_email();",
style: {
marginLeft: "36px",
color: 'purple'
}
}, "Hide email from archives");
if (admin_email_meta.deleted) {
btn_hide = new HTML('button', {
onclick: "admin_unhide_email();",
style: {
marginLeft: "36px",
color: 'purple'
}
}, "Unhide email from archives");
}
div.inject(new HTML('br'));
div.inject(btn_edit);
div.inject(btn_hide);
div.inject(btn_del);
div.inject(new HTML('br'));
div.inject(new HTML('small', {}, "Modifying emails will remove the option to view their sources via the web interface, as the source may contain traces that reveal the edit."))
div.inject(new HTML('br'));
if (!mgmt_prefs.login.credentials.fully_delete) {
div.inject(new HTML('small', {}, "Emails that are deleted may still be recovered by the base system administrator. For complete expungement, please contact the system administrator."))
} else {
div.inject(new HTML('small', {style:{color: 'red'}}, "As full delete enforcement is enabled on this server, emails are removed forever from the archive when deleted, and cannot be recovered."))
}
}
function admin_audit_view(state, json) {
let headers = ['Date', 'Author', 'Remote', 'Action', 'Target', 'Log'];
let cp = document.getElementById("panel");
let div = document.getElementById('auditlog_entries');
if (!div) {
div = new HTML('div', {
id: "auditlog",
style: {
margin: '5px'
}
});
cp.inject(div);
div.inject(new HTML('h1', {}, "Audit log:"));
}
let table = document.getElementById('auditlog_entries');
if (json.entries && json.entries.length > 0 || table) {
if (!table) {
table = new HTML('table', {
border: "1",
id: "auditlog_entries",
class: "auditlog_entries"
});
let trh = new HTML('tr');
for (let header of headers) {
let th = new HTML('th', {}, header + ":");
trh.inject(th);
}
table.inject(trh)
div.inject(table);
let btn = new HTML('button', {
onclick: "admin_audit_next();"
}, "Load more entries");
div.inject(btn);
}
for (let entry of json.entries) {
let tr = new HTML('tr', {
class: "auditlog_entry"
});
for (let header of headers) {
let key = header.toLowerCase();
let value = entry[key];
if (key == 'target') {
value = new HTML('a', {
href: "/admin/" + value
}, value);
}
if (key == 'action') {
let action_colors = {
edit: 'blue',
delete: 'red',
default: 'black'
};
value = new HTML('spam', {
style: {
color: action_colors[value] ? action_colors[value] : action_colors['default']
}
}, value);
}
let th = new HTML('td', {}, value);
tr.inject(th);
}
table.inject(tr);
}
} else {
div.inject("Audit log is empty");
}
}
function admin_audit_next() {
audit_page++;
GET('%sapi/mgmt.json?action=log&page=%u&size=%u'.format(G_apiURL, audit_page, audit_size), admin_audit_view, null);
}
// Onload function for admin.html
function admin_init() {
init_preferences(); // blank call to load defaults like social rendering
GET('%sapi/preferences.lua'.format(G_apiURL), (state, json) => {
mgmt_prefs = json
init_preferences(state, json);
}, null);
let mid = decodeURIComponent(location.href.split('/').pop());
// Specific email/list handling?
if (mid.length > 0) {
// List handling?
if (mid.match(/^<.+>$/)) {
}
// Email handling?
else {
GET('%sapi/email.json?id=%s'.format(G_apiURL, encodeURIComponent(mid)), admin_email_preview, null);
}
} else { // View audit log
GET('%sapi/mgmt.json?action=log&page=%s&size=%u'.format(G_apiURL, audit_page, audit_size), admin_audit_view, null);
}
}
/******************************************
Fetched from source/preferences.js
******************************************/
// logout: log out a user
// call the logout URL, then refresh this page - much simple!
function logout() {
GET("%sapi/preferences.lua?logout=true".format(G_apiURL), () => location.href = document.location);
}
function init_preferences(state, json) {
G_ponymail_preferences = json || {};
// First, load session local settings, if possible
if (G_can_store) {
let local_preferences = window.localStorage.getItem('G_ponymail_preferences');
if (local_preferences) {
let ljson = JSON.parse(local_preferences);
if (ljson.G_chatty_layout !== undefined) {
G_chatty_layout = ljson.G_chatty_layout;
}
if (ljson.G_current_listmode !== undefined) {
G_current_listmode = ljson.G_current_listmode;
}
if (ljson.G_current_listmode_compact !== undefined) {
G_current_listmode_compact = ljson.G_current_listmode_compact;
}
if (ljson.G_show_stats_sidebar !== undefined) {
G_show_stats_sidebar = ljson.G_show_stats_sidebar;
}
}
}
// Set chatty/plain email rendering mode:
let cl = document.getElementById('chatty_link'); // legacy button
if (cl) {
cl.setAttribute("class", G_chatty_layout ? "enabled" : "disabled");
}
let cle = document.getElementById('email_mode_chatty');
if (cle) {
cle.checked = G_chatty_layout;
}
let cld = document.getElementById('email_mode_plain');
if (cld) {
cld.checked = !G_chatty_layout;
}
let cla = document.getElementById('G_show_stats_sidebar');
if (cla) {
cla.checked = G_show_stats_sidebar;
}
// Set list display mode:
let dmt = document.getElementById('display_mode_threaded');
if (dmt) {
dmt.checked = (G_current_listmode == 'threaded');
}
let dmf = document.getElementById('display_mode_flat');
if (dmf) {
dmf.checked = (G_current_listmode == 'flat');
}
let dmtr = document.getElementById('display_mode_treeview');
if (dmtr) {
dmtr.checked = (G_current_listmode == 'treeview');
}
// Compact list view
let dmc = document.getElementById('display_mode_compact');
if (dmc) {
dmc.checked = G_current_listmode_compact;
}
if (G_ponymail_preferences.login && G_ponymail_preferences.login.credentials) {
let prefsmenu = document.getElementById('login_dropdown') || document.getElementById('prefs_dropdown');
let uimg = document.getElementById('uimg');
uimg.setAttribute("src", "images/user.png");
uimg.setAttribute("title", "Logged in as %s".format(G_ponymail_preferences.login.credentials.fullname));
// Generate user menu
prefsmenu.innerHTML = "";
let logout = new HTML('a', {
href: "javascript:void(logout());"
}, "Log out");
let li = new HTML('li', {}, logout)
prefsmenu.inject(li);
} else {
let prefsmenu = document.getElementById('login_dropdown') || document.getElementById('prefs_dropdown');
if (prefsmenu) {
prefsmenu.innerHTML = "";
let login = new HTML('a', {
href: "javascript:location.href='oauth.html';"
}, "Log In");
let li = new HTML('li', {}, login)
prefsmenu.inject(li);
}
}
if (json) {
listview_list_lists(state, json);
if (state && state.prime) {
// If lists is accessible, show it
if (json.lists[G_current_domain] && (G_current_list == '*' || json.lists[G_current_domain][G_current_list] != undefined)) {
post_prime(state);
} else if (G_current_domain == '*') { // assume a match
post_prime(state);
} else { // otherwise, bork
if (G_current_list.length > 0 && (!json.lists[G_current_domain] || Object.keys(json.lists[G_current_domain]).length > 0)) {
let eml = document.getElementById('emails');
eml.innerText = "We couldn't find this list. It may not exist or require you to be logged in with specific credentials.";
eml.inject(new HTML('br'));
eml.inject(new HTML('a', {
href: 'oauth.html',
onclick: 'location.href="oauth.html";'
}, "Click here to log in via OAuth"));
} else {
switch_project(G_current_domain);
}
}
}
}
}
function save_preferences() {
if (G_can_store) {
let ljson = {
G_chatty_layout: G_chatty_layout,
G_current_listmode: G_current_listmode,
G_current_listmode_compact: G_current_listmode_compact,
G_show_stats_sidebar: G_show_stats_sidebar
};
let lstring = JSON.stringify(ljson);
window.localStorage.setItem('G_ponymail_preferences', lstring);
console.log("Saved local preferences");
}
}
function set_theme(theme, compact_mode) {
G_current_listmode = theme;
if (compact_mode !== undefined) {
G_current_listmode_compact = compact_mode;
}
renderListView(G_current_state, G_current_json);
save_preferences();
}
function set_skin(skin) {
if (typeof(enable_chatty) === "boolean") {
G_chatty_layout = enable_chatty;
} else {
G_chatty_layout = !G_chatty_layout;
}
let cl = document.getElementById('chatty_link');
if (cl) {
cl.setAttribute("class", G_chatty_layout ? "enabled" : "disabled");
}
hideWindows(true);
renderListView(G_current_state, G_current_json);
save_preferences();
}
// set_skin, but for permalinks
function set_skin_permalink(enable_chatty) {
if (typeof(enable_chatty) === "boolean") {
G_chatty_layout = enable_chatty;
} else {
G_chatty_layout = !G_chatty_layout;
}
let cl = document.getElementById('chatty_link');
if (cl) {
cl.setAttribute("class", G_chatty_layout ? "enabled" : "disabled");
}
hideWindows(true);
save_preferences();
parse_permalink();
}
function set_show_stats(display) {
G_show_stats_sidebar = display;
if (display === false) {
document.getElementById('sidebar_stats').style.display = "none";
document.getElementById('sidebar_wordcloud').style.display = "none";
} else {
document.getElementById('sidebar_stats').style.display = "block";
document.getElementById('sidebar_wordcloud').style.display = "block";
}
save_preferences();
renderCalendar();
}
/******************************************
Fetched from source/primer.js
******************************************/
/* List View Rendering main func */
function renderListView(state, json) {
if (json) {
G_current_json = json;
}
G_current_state = state;
async_escrow['rendering'] = new Date();
if (!state || state.update_calendar !== false) {
renderCalendar({
FY: json.firstYear,
FM: json.firstMonth,
LY: json.lastYear,
LM: json.lastMonth,
activity: json.active_months
});
}
// sort threads by date
if (isArray(json.thread_struct)) {
G_current_json.thread_struct.sort((a, b) => last_email(a) - last_email(b));
}
listview_header(state, json);
if (G_current_listmode == 'threaded') {
listview_threaded(json, 0);
} else if (G_current_listmode == 'treeview') {
listview_treeview(json, 0);
} else {
listview_flat(json, 0);
}
sidebar_stats(json); // This comes last, takes the longest with WC enabled.
delete async_escrow['rendering'];
if (state && state.to) {
listview_list_lists(state);
}
}
/* Primer function for List View
* Fetches the following:
* - user preferences (api/preferences.lua)
* When done, we create the scaffolding and list view
*/
function primeListView(state) {
console.log("Priming user interface for List View..");
state = state || {};
state.prime = true;
GET('%sapi/preferences.lua'.format(G_apiURL), init_preferences, state);
}
// callback from when prefs have loaded
function post_prime(state) {
let sURL = '%sapi/stats.lua?list=%s&domain=%s'.format(G_apiURL, encodeURIComponent(G_current_list), encodeURIComponent(G_current_domain));
if (G_current_year && G_current_month) {
sURL += "&d=%u-%u".format(G_current_year, G_current_month);
}
if (!(state && state.search)) {
if (state && state.array) {
G_collated_json = {};
for (let entry of state.array) {
let list = entry.split('@');
sURL = '%sapi/stats.lua?list=%s&domain=%s'.format(G_apiURL, encodeURIComponent(list[0]), encodeURIComponent(list[1]));
GET(sURL, render_virtual_inbox, state);
}
} else {
GET(sURL, renderListView, state);
}
} else {
search(state.query, state.date);
}
}
// onload function for list.html
function parseURL(state) {
console.log("Running ParseURL");
console.log(state);
let bits = window.location.search.substring(1).split(":", 3);
let list = bits[0];
let month = bits[1];
let query = bits[2];
state = state || {};
G_current_query = query || "";
G_current_month = 0;
G_current_year = 0;
// If "month" (year-month) is specified,
// we should set the current vars
if (month) {
try {
let dbits = month.split("-");
G_current_year = dbits[0];
G_current_month = dbits[1];
} catch (e) {}
}
// Is this a valid list?
if (list !== '') {
// multi-list??
if (list.match(/,/)) {
state.array = list.split(',');
G_current_domain = 'inbox';
G_current_list = 'virtual';
} else {
let lbits = list.split("@");
if (lbits.length > 1) {
G_current_list = lbits[0];
G_current_domain = lbits[1];
} else {
G_current_domain = lbits;
G_current_list = '';
}
}
}
// Are we initiating a search?
if (query || (month && !month.match(/^\d\d\d\d-\d+$/))) { // single-month isn't a search, but any other date marker is
state.search = true;
state.query = decodeURIComponent(query||"");
state.date = month;
}
// If hitting the refresh button, don't refresh preferences, just do the search.
if (state.noprefs) {
post_prime(state);
} else {
primeListView(state);
}
}
// Parse a permalink and fetch the thread
// URL is expected to be of the form /thread[.html]/<msgid>?<list.id>|find_parent=true
// onload function for thread.html
function parse_permalink() {
// message id is the bit after the last /
// TODO: could look for thread[.html]/ instead
let mid = decodeURIComponent(location.pathname.split('/').pop());
// List-ID specified?
// query needs decodeURIComponent with '+' conversion
const query = decodeURIComponent(location.search.substring(1).replace(/\+/g, ' '));
let list_id = null;
let find_parent = false;
if (query.length) {
if (query.match(/^<.+>$/)) {
list_id = query;
}
find_parent = query == 'find_parent=true';
}
mid = unshortenID(mid); // In case of old school shortened links
init_preferences(); // blank call to load defaults like social rendering
GET('%sapi/preferences.lua'.format(G_apiURL), init_preferences, null);
// Fetch the thread data and pass to build_single_thread
if (list_id) {
GET('%sapi/thread.lua?id=%s&listid=%s'.format(G_apiURL, encodeURIComponent(mid), encodeURIComponent(list_id)), construct_single_thread, {
cached: true
});
}
else {
let encoded_mid = encodeURIComponent(mid);
if (find_parent) {
GET('%sapi/thread.lua?id=%s&find_parent=true'.format(G_apiURL, encoded_mid), construct_single_thread, {
cached: true
});
} else {
GET('%sapi/thread.lua?id=%s'.format(G_apiURL, encoded_mid), construct_single_thread, {
cached: true
});
}
}
}
// Virtual inbox Å•endering
function render_virtual_inbox(state, json) {
if (json) {
G_collated_json.emails = G_collated_json.emails || [];
G_collated_json.thread_struct = G_collated_json.thread_struct || [];
for (let email of json.emails) {
G_collated_json.emails.push(email);
}
for (let thread_struct of json.thread_struct) {
G_collated_json.thread_struct.push(thread_struct);
}
}
for (let _ in async_escrow) {
return;
}
if (true) {
console.log("Rendering multi-list")
G_current_json = G_collated_json;
G_current_json.participants = [];
async_escrow['rendering'] = new Date();
if (!state || state.update_calendar !== false) {
renderCalendar({
FY: json.firstYear,
FM: json.firstMonth,
LY: json.lastYear,
LM: json.lastMonth,
activity: json.active_months
});
}
// sort threads by date
if (isArray(json.thread_struct)) {
G_current_json.thread_struct.sort((a, b) => last_email(a) - last_email(b));
}
listview_header(state, G_current_json);
if (G_current_listmode == 'threaded') {
listview_threaded(G_current_json, 0);
} else if (G_current_listmode == 'treeview') {
listview_treeview(G_current_json, 0);
} else {
listview_flat(G_current_json, 0);
}
sidebar_stats(G_current_json); // This comes last, takes the longest with WC enabled.
delete async_escrow['rendering'];
}
}
// hex <- base 36 conversion, reverses short links
function unshortenID(mid) {
// all short links begin with 'Z'. If not, it's not a short link
// so let's just pass it through unaltered if so.
// Some old shortlinks begin with 'B', so let's be backwards compatible for now.
// Shortlinks are also 15 chars (including prefix)
// They should also consist of base 36 chars or '-'
if ((mid[0] == 'Z' || mid[0] == 'B') && mid.length == 15){
// remove padding
let id1 = parseInt(mid.substr(1, 7).replace(/-/g, ""), 36)
let id2 = parseInt(mid.substr(8, 7).replace(/-/g, ""), 36)
id1 = id1.toString(16)
id2 = id2.toString(16)
// add 0-padding
while (id1.length < 9) id1 = '0' + id1
while (id2.length < 9) id2 = '0' + id2
return id1+id2
}
return mid
}
/******************************************
Fetched from source/render-email.js
******************************************/
// Function for parsing email addresses from a to or cc line
function get_rcpts(addresses) {
let list_of_emails = []
if (!addresses) return [] // cc or to may be null
for (let a of addresses.split(/,\s*/)) {
let m = a.match(/<(.+)>/);
if (m) {
a = m[1];
}
if (a && a.length > 5) { // more than a@b.c
list_of_emails.push(a);
console.log(a);
}
}
return list_of_emails;
}
async function render_email(state, json) {
let div = state.div;
G_full_emails[json.mid] = json; // Save for composer if replying later...
if (state.scroll) {
let rect = div.getBoundingClientRect();
try {
window.setTimeout(function() {
window.scrollTo(0, rect.top - 48);
}, 200);
console.log("Scrolled to %u".format(rect.top - 48));
} catch (e) {}
}
if (G_chatty_layout) {
return render_email_chatty(state, json);
}
// Author
let author_field = new HTML('div', {
class: 'email_kv'
});
let author_key = new HTML('div', {
class: 'email_key'
}, "From: ");
let author_value = new HTML('div', {
class: 'email_value'
}, json.from);
author_field.inject([author_key, author_value]);
div.inject(author_field);
// Subject
let subject_field = new HTML('div', {
class: 'email_kv'
});
let subject_key = new HTML('div', {
class: 'email_key'
}, "Subject: ");
let subject_value = new HTML('div', {
class: 'email_value'
}, json.subject == '' ? '(No subject)' : json.subject);
subject_field.inject([subject_key, subject_value]);
div.inject(subject_field);
// Date
let date_field = new HTML('div', {
class: 'email_kv'
});
let date_key = new HTML('div', {
class: 'email_key'
}, "Date: ");
let date_value = new HTML('div', {
class: 'email_value'
}, new Date(json.epoch * 1000.0).ISOBare());
date_field.inject([date_key, date_value]);
div.inject(date_field);
// List
let listname = json.list_raw.replace(".", "@", 1).replace(/[<>]/g, "");
let list_field = new HTML('div', {
class: 'email_kv'
});
let list_key = new HTML('div', {
class: 'email_key'
}, "List: ");
let list_value = new HTML('div', {
class: 'email_value'
},
new HTML('a', {
href: 'list?%s'.format(listname)
}, listname)
);
list_field.inject([list_key, list_value]);
div.inject(list_field);
// To + CC if need be
let rcpts = get_rcpts(json.to);
rcpts.push(...get_rcpts(json.cc));
rcpts.remove(listname);
if (rcpts.length) {
let rcpt_field = new HTML('div', {
class: 'email_kv'
});
let rcpt_key = new HTML('div', {
class: 'email_key'
}, "To/Cc: ");
let rcpt_value = new HTML('div', {
class: 'email_value'
},
new HTML('span', {}, rcpts.join(", "))
);
rcpt_field.inject([rcpt_key, rcpt_value]);
div.inject(rcpt_field);
}
// Private email??
if (json.private === true) {
let priv_field = new HTML('div', {
class: 'email_kv'
});
let priv_key = new HTML('div', {
class: 'email_key'
}, "Private: ");
let priv_value = new HTML('div', {
class: 'email_value_emphasis'
}, "YES");
priv_field.inject([priv_key, priv_value]);
div.inject(priv_field);
}
// Attachments?
if (json.attachments && json.attachments.length > 0) {
let attach_field = new HTML('div', {
class: 'email_kv'
});
let attach_key = new HTML('div', {
class: 'email_key'
}, "Attachment(s): ");
let alinks = [];
for (let attachment of json.attachments) {
let link = `${G_apiURL}api/email.lua?attachment=true&id=${json.mid}&file=${attachment.hash}`;
let a = new HTML('a', {
href: link,
target: '_blank'
}, attachment.filename);
alinks.push(a);
let fs = ` ${attachment.size} bytes`;
if (attachment.size >= 1024) fs = ` ${Math.floor(attachment.size/1024)} KB`;
if (attachment.size >= 1024 * 1024) fs = ` ${Math.floor(attachment.size/(1024*10.24))/100} MB`;
alinks.push(fs);
alinks.push(new HTML('br'));
}
let attach_value = new HTML('div', {
class: 'email_value'
}, alinks);
attach_field.inject([attach_key, attach_value]);
div.inject(attach_field);
}
let text = new HTML('pre', {}, fixup_quotes(json.body));
div.inject(text);
// Private text?
if (json.private === true) {
text.style.backgroundImage = "url(images/private.png)";
}
let toolbar = new HTML('div', {
class: 'toolbar'
});
// reply to email button
let replybutton = new HTML('button', {
title: "Reply to this email",
onclick: `compose_email('${json.mid}');`,
class: 'btn toolbar_btn toolbar_button_reply'
}, new HTML('span', {
class: 'glyphicon glyphicon-pencil'
}, ' '));
toolbar.inject(replybutton);
// permalink button
let linkbutton = new HTML('a', {
href: 'thread/%s'.format(json.mid),
target: '_blank',
title: "Permanent link to this email",
class: 'btn toolbar_btn toolbar_button_link'
}, new HTML('span', {
class: 'glyphicon glyphicon-link'
}, ' '));
toolbar.inject(linkbutton);
// Source-view button
let sourcebutton = new HTML('a', {
href: '%sapi/source.lua?id=%s'.format(G_apiURL, encodeURIComponent(json.mid)),
target: '_blank',
title: "View raw source",
class: 'btn toolbar_btn toolbar_button_source'
}, new HTML('span', {
class: 'glyphicon glyphicon-file'
}, ' '));
toolbar.inject(sourcebutton);
// Admin button?
if (G_ponymail_preferences.login && G_ponymail_preferences.login.credentials && G_ponymail_preferences.login.credentials.admin) {
let adminbutton = new HTML('a', {
href: 'admin/%s'.format(json.mid),
target: '_blank',
title: "Modify email",
class: 'btn toolbar_btn toolbar_button_admin'
}, new HTML('span', {
class: 'glyphicon glyphicon-cog'
}, ' '));
toolbar.inject(adminbutton);
}
text.inject(toolbar);
}
async function render_email_chatty(state, json) {
let div = state.div;
div.parentNode.style.border = 'none';
// Author
let when = new Date(json.epoch * 1000.0);
let ldate = when.toISOString();
try {
ldate = "%s %s".format(when.toLocaleDateString(undefined, PONYMAIL_DATE_FORMAT), when.toLocaleTimeString(undefined, PONYMAIL_TIME_FORMAT));
} catch (e) {
}
let author_field = new HTML('div', {
class: 'chatty_author'
});
let gravatar = new HTML('img', {
class: "chatty_gravatar",
src: GRAVATAR_URL.format(json.gravatar)
});
let author_name = json.from.replace(/\s*<.+>/, "").replace(/"/g, '');
let author_email = json.from.match(/\s*<(.+@.+)>\s*/);
if (author_name.length == 0) author_name = author_email ? author_email[1] : "(No author?)";
let author_nametag = new HTML('div', {
class: 'chatty_author_name'
}, [
new HTML('b', {}, author_name),
" - %s".format(ldate)
]);
author_field.inject([gravatar, author_nametag]);
div.inject(author_field);
let chatty_body = fixup_quotes(json.body);
if (json.mid == G_current_open_email) {
let header = new HTML('h4', {
class: 'chatty_title_inline'
}, json.subject);
chatty_body.unshift(header);
}
let text = new HTML('pre', {
class: 'chatty_body'
}, chatty_body);
div.inject(text);
// Private text?
if (json.private === true) {
text.style.backgroundImage = "url(images/private.png)";
}
// Attachments?
if (json.attachments && json.attachments.length > 0) {
let attach_field = new HTML('div', {
class: 'email_kv'
});
let attach_key = new HTML('div', {
class: 'email_key'
}, "Attachment(s):");
let alinks = [];
for (let attachment of json.attachments) {
let link = `${G_apiURL}api/email.lua?attachment=true&id=${json.mid}&file=${attachment.hash}`;
let a = new HTML('a', {
href: link,
target: '_blank'
}, attachment.filename);
alinks.push(a);
let fs = ` ${attachment.size} bytes`;
if (attachment.size >= 1024) fs = ` ${Math.floor(attachment.size/1024)} KB`;
if (attachment.size >= 1024 * 1024) fs = ` ${Math.floor(attachment.size/(1024*10.24))/100} MB`;
alinks.push(fs);
alinks.push(new HTML('br'));
}
let attach_value = new HTML('div', {
class: 'email_value'
}, alinks);
attach_field.inject([attach_key, attach_value]);
text.inject(attach_field);
}
let toolbar = new HTML('div', {
class: 'toolbar_chatty'
});
// reply to email button
let replybutton = new HTML('button', {
title: "Reply to this email",
onclick: `compose_email('${json.mid}');`,
class: 'btn toolbar_btn toolbar_button_reply'
}, new HTML('span', {
class: 'glyphicon glyphicon-pencil'
}, ' '));
toolbar.inject(replybutton);
// permalink button
let linkbutton = new HTML('a', {
href: 'thread/%s'.format(json.mid),
title: "Permanent link to this email",
target: '_blank',
class: 'btn toolbar_btn toolbar_button_link'
}, new HTML('span', {
class: 'glyphicon glyphicon-link'
}, ' '));
toolbar.inject(linkbutton);
// Source-view button
let sourcebutton = new HTML('a', {
href: '%sapi/source.lua?id=%s'.format(G_apiURL, encodeURIComponent(json.mid)),
target: '_blank',
title: "View raw source",
class: 'btn toolbar_btn toolbar_button_source'
}, new HTML('span', {
class: 'glyphicon glyphicon-file'
}, ' '));
toolbar.inject(sourcebutton);
// Admin button?
if (G_ponymail_preferences.login && G_ponymail_preferences.login.credentials && G_ponymail_preferences.login.credentials.admin) {
let adminbutton = new HTML('a', {
href: 'admin/%s'.format(encodeURIComponent(json.mid)),
target: '_blank',
title: "Modify email",
class: 'btn toolbar_btn toolbar_button_admin'
}, new HTML('span', {
class: 'glyphicon glyphicon-cog'
}, ' '));
toolbar.inject(adminbutton);
}
text.inject(toolbar);
}
/******************************************
Fetched from source/scaffolding-html.js
******************************************/
/**
* HTML: DOM creator class
* args:
* - type: HTML element type (div, table, p etc) to produce
* - params: hash of element params to add (class, style etc)
* - children: optional child or children objects to insert into the new element
* Example:
* div = new HTML('div', {
* class: "footer",
* style: {
* fontWeight: "bold"
* }
#}, "Some text inside a div")
*/
const txt = (msg) => document.createTextNode(msg);
const HTML = (function() {
function HTML(type, params, children) {
/* create the raw element, or clone if passed an existing element */
if (typeof type === 'object') {
this.element = type.cloneNode();
} else {
this.element = document.createElement(type);
}
/* If params have been passed, set them */
if (isHash(params)) {
for (let key in params) {
let val = params[key];
/* Standard string value? */
if (typeof val === "string" || typeof val === 'number') {
this.element.setAttribute(key, val);
} else if (isArray(val)) {
/* Are we passing a list of data to set? concatenate then */
this.element.setAttribute(key, val.join(" "));
} else if (isHash(val)) {
/* Are we trying to set multiple sub elements, like a style? */
for (let subkey in val) {
let subval = val[subkey];
if (!this.element[key]) {
throw "No such attribute, " + key + "!";
}
this.element[key][subkey] = subval;
}
}
}
}
/* If any children have been passed, add them to the element */
if (children) {
/* If string, convert to textNode using txt() */
if (typeof children === "string") {
this.element.inject(txt(children));
} else {
/* If children is an array of elems, iterate and add */
if (isArray(children)) {
let child, j, len;
for (j = 0, len = children.length; j < len; j++) {
child = children[j];
/* String? Convert via txt() then */
if (typeof child === "string") {
this.element.inject(txt(child));
} else {
/* Plain element, add normally */
this.element.inject(child);
}
}
} else {
/* Just a single element, add it */
this.element.inject(children);
}
}
}
return this.element;
}
return HTML;
})();
/**
* prototype injector for HTML elements:
* Example: mydiv.inject(otherdiv)
*/
HTMLElement.prototype.inject = function(child) {
let item, j, len;
if (isArray(child)) {
for (j = 0, len = child.length; j < len; j++) {
item = child[j];
if (typeof item === 'string') {
item = txt(item);
}
this.appendChild(item);
}
} else {
if (typeof child === 'string') {
child = txt(child);
}
this.appendChild(child);
}
return child;
};
/**
* prototype for emptying an html element
*/
HTMLElement.prototype.empty = function() {
let ndiv;
ndiv = this.cloneNode();
this.parentNode.replaceChild(ndiv, this);
return ndiv;
};
function toggleView(id) {
let obj = document.getElementById(id);
if (obj) {
obj.style.display = (obj.style.display == 'block') ? 'none' : 'block';
}
}
/******************************************
Fetched from source/search.js
******************************************/
function search(query, date) {
let list = G_current_list;
let global = false;
let domain = G_current_domain;
if (G_ponymail_search_list == 'global') {
list = '*';
domain = '*';
global = true;
}
if (G_ponymail_search_list == 'domain') {
list = '*';
global = true;
}
let listid = '%s@%s'.format(list, domain);
G_current_list = list;
G_current_domain = domain;
let newhref = "list?%s:%s:%s".format(listid, date, query);
let header_from = document.getElementById('header_from');
let header_subject = document.getElementById('header_subject');
let header_to = document.getElementById('header_to');
let header_body = document.getElementById('header_body');
let sURL = '%sapi/stats.lua?d=%s&list=%s&domain=%s&q=%s'.format(
G_apiURL, encodeURIComponent(date), encodeURIComponent(list), encodeURIComponent(domain), encodeURIComponent(query)
);
if (header_from.value.length > 0) {
sURL += "&header_from=%s".format(encodeURIComponent(header_from.value));
newhref += "&header_from=%s".format(header_from.value);
header_from.value = "";
}
if (header_subject.value.length > 0) {
sURL += "&header_subject=%s".format(encodeURIComponent(header_subject.value));
newhref += "&header_subject=%s".format(header_subject.value);
header_subject.value = "";
}
if (header_to.value.length > 0) {
sURL += "&header_to=%s".format(encodeURIComponent(header_to.value));
newhref += "&header_to=%s".format(header_to.value);
header_to.value = "";
}
if (header_body.value.length > 0) {
sURL += "&header_body=%s".format(encodeURIComponent(header_body.value));
newhref += "&header_body=%s".format(header_body.value);
header_body.value = "";
}
GET(sURL, renderListView, {
search: true,
global: global
});
if (location.href !== newhref) {
window.history.pushState({}, null, newhref);
}
listview_list_lists({
url: sURL,
search: true,
query: query,
list: list,
domain: domain
});
hideWindows(true);
document.getElementById('q').value = query;
return false;
}
// set the list(s) to search, update links
function search_set_list(what) {
G_ponymail_search_list = what;
let links = document.getElementsByClassName('searchlistoption');
let whatxt = "this list"
for (let el of links) {
if (el.getAttribute("id").match(what)) {
el.setAttribute("class", "searchlistoption checked");
whatxt = el.innerText.toLowerCase();
} else {
el.setAttribute("class", "searchlistoption");
}
}
document.getElementById('q').setAttribute("placeholder", "Search %s...".format(whatxt));
}
/******************************************
Fetched from source/sidebar-calendar.js
******************************************/
let calendar_index = 0;
let current_calendar_size = CALENDAR_YEARS_SHOWN;
let calendar_state = {}
function calendar_max_height() {
let body = document.body;
let html = document.documentElement;
let height = Math.max(body.scrollHeight,
html.clientHeight, html.scrollHeight);
let width = Math.max(body.scrollWidth,
html.clientWidth, html.scrollWidth);
let year_height = 48; // Height of one calendar year
height -= document.getElementById("emails").getBoundingClientRect().y + 16; // top area height plus footer
let number_of_years = Math.max(5, Math.floor(height / year_height));
return number_of_years;
}
function renderCalendar(state) {
calendar_state = state ? state : calendar_state;
calendar_index = 0;
current_calendar_size = G_show_stats_sidebar ? CALENDAR_YEARS_SHOWN : calendar_max_height();
// Only render if calendar div is present
let cal = document.getElementById('sidebar_calendar');
if (!cal) {
return;
}
let now = new Date();
let CY = now.getFullYear();
let CM = now.getMonth() + 1;
let SY = Math.min(calendar_state.LY, CY); // last year in calendar, considering current date
// If Last Year is into the future, set Last Month to this one.
if (calendar_state.LY > CY) {
calendar_state.LM = CM;
}
let cdiv = new HTML('div', {
class: 'sidebar_calendar'
})
let N = 0;
// Chevron for moving to later years
let chevron = new HTML('div', {
class: 'sidebar_calendar_chevron'
});
chevron.inject(new HTML('span', {
onclick: 'calendar_scroll(this, -1);',
style: {
display: 'none'
},
id: 'sidebar_calendar_up',
class: 'glyphicon glyphicon-collapse-up',
title: "Show later years"
}, " "));
cdiv.inject(chevron);
// Create divs for each year, assign all visible
for (let Y = SY; Y >= calendar_state.FY; Y--) {
let ydiv = new HTML('div', {
class: 'sidebar_calendar_year',
id: 'sidebar_calendar_' + N++
});
ydiv.inject(txt(Y));
ydiv.inject(new HTML('br'));
for (let i = 0; i < MONTHS_SHORTENED.length; i++) {
let mon = MONTHS_SHORTENED[i];
let mdiv = new HTML('div', {
onclick: 'calendar_click(%u, %u);'.format(Y, i + 1),
class: 'sidebar_calendar_month'
}, mon);
// Mark out-of-bounds segments
let ym = '%04u-%02u'.format(Y, i+1);
let c_active = true;
if (calendar_state.activity && !calendar_state.activity[ym]) {
c_active = false;
}
if ((Y == SY && i >= calendar_state.LM) || (Y == CY && i > CM)) {
c_active = false;
}
if (Y == calendar_state.FY && ((i + 1) < calendar_state.FM)) {
c_active = false;
}
if (!c_active) {
mdiv.setAttribute("class", "sidebar_calendar_month_nothing");
mdiv.setAttribute("onclick", "javascript:void(0);");
} else if (calendar_state.activity && calendar_state.activity[ym]) {
let count = calendar_state.activity[ym];
if (count >= 1000) {
count = Math.round(count/100.0); // nearest century
count = Math.floor(count/10) + "k" + (count % 10); // thousands and remainder
} else {
count = count.toString();
}
mdiv.inject(new HTML('span', {title: `${calendar_state.activity[ym].pretty()} emails this month`, class: 'calendar_count'}, count));
}
ydiv.inject(mdiv);
}
cdiv.inject(ydiv);
}
cal.innerHTML = "<p style='text-align: center;'>Archives (%u - %u):</p>".format(calendar_state.FY, SY);
cal.inject(cdiv);
chevron = new HTML('div', {
class: 'sidebar_calendar_chevron'
});
chevron.inject(new HTML('span', {
onclick: 'calendar_scroll(this, 1);',
style: {
display: 'none'
},
id: 'sidebar_calendar_down',
class: 'glyphicon glyphicon-collapse-down',
title: "Show earlier years"
}, " "));
cdiv.inject(chevron);
// If we have > N years, hide the rest
if (N > current_calendar_size) {
for (let i = current_calendar_size; i < N; i++) {
let obj = document.getElementById('sidebar_calendar_' + i);
if (obj) {
obj.style.display = "none";
}
}
document.getElementById('sidebar_calendar_down').style.display = 'block';
}
}
function calendar_scroll(me, direction) {
let x = direction * current_calendar_size;
let years = document.getElementsByClassName('sidebar_calendar_year');
calendar_index = Math.max(Math.min(years.length - x, calendar_index + x), 0);
if (calendar_index > 0) {
document.getElementById('sidebar_calendar_up').style.display = 'block';
} else {
document.getElementById('sidebar_calendar_up').style.display = 'none';
}
if (calendar_index < (years.length - x)) {
document.getElementById('sidebar_calendar_down').style.display = 'block';
} else {
document.getElementById('sidebar_calendar_down').style.display = 'none';
}
for (let i = 0; i < years.length; i++) {
let year = years[i];
if (typeof(year) == 'object') {
if (i >= calendar_index && i < (calendar_index + Math.abs(x))) {
year.style.display = "block";
} else {
year.style.display = "none";
}
}
}
}
function calendar_click(year, month) {
G_current_year = year;
G_current_month = month;
let searching = false;
let q = "";
let calendar_current_list = G_current_list;
let calendar_current_domain = G_current_domain;
if (G_current_json && G_current_json.searchParams) {
q = G_current_json.searchParams.q || "";
calendar_current_list = G_current_json.searchParams.list;
calendar_current_domain = G_current_json.searchParams.domain;
// Weave in header parameters
for (let key of Object.keys((G_current_json.searchParams || {}))) {
if (key.match(/^header_/)) {
let value = G_current_json.searchParams[key];
q += `&${key}=${value}`;
}
}
}
let newhref = "list?%s@%s:%u-%u".format(calendar_current_list, calendar_current_domain, year, month);
if (q && q.length > 0) newhref += ":" + q;
if (location.href !== newhref) {
window.history.pushState({}, null, newhref);
}
GET('%sapi/stats.lua?list=%s&domain=%s&d=%u-%u&q=%s'.format(
G_apiURL, encodeURIComponent(calendar_current_list),
encodeURIComponent(calendar_current_domain),
encodeURIComponent(year), encodeURIComponent(month),
encodeURIComponent(q)
),
renderListView, {
to: (q && q.length > 0) ? 'search' : '%s@%s'.format(calendar_current_list, calendar_current_domain),
update_calendar: false,
search: (q && q.length > 0)
});
}
/******************************************
Fetched from source/sidebar-stats.js
******************************************/
async function sidebar_stats(json) {
let obj = document.getElementById('sidebar_stats');
if (!obj) {
return;
}
obj.innerHTML = ""; // clear stats bar
// Subscribe button
if (prefs && prefs.subscribeLinks) {
let sb = document.getElementById('sidebar_subscribe');
if (sb) sb.textContent = "";
if (sb && json.list && !json.list.match(/\*/)) {
sb.textContent = "";
let sublink = json.list.replace("@", "-subscribe@");
let subbutton = new HTML("a", {href: `mailto:${sublink}`, id: "subscribe_button"}, "Subscribe to list");
sb.inject(subbutton);
}
}
let wc = document.getElementById('sidebar_wordcloud');
if (!json.emails || isHash(json.emails) || json.emails.length == 0) {
obj.innerText = "No emails found...";
if (wc) {
wc.innerHTML = "";
}
return;
}
// Top 10 participants
obj.inject("Found %u emails by %u authors, divided into %u topics.".format(json.emails.length, json.numparts, json.no_threads));
obj.inject(new HTML('h5', {}, "Most active authors: "));
for (let i = 0; i < json.participants.length; i++) {
if (i >= 5) {
break;
}
let par = json.participants[i];
if (par.name.length > 24) {
par.name = par.name.substr(0, 23) + "...";
}
if (par.name.length == 0) {
par.name = par.email;
}
let pdiv = new HTML('div', {
class: "sidebar_stats_participant"
});
let pimg = new HTML('img', {
class: "gravatar_sm",
src: GRAVATAR_URL.format(par.gravatar)
})
pdiv.inject(pimg);
pdiv.inject(new HTML('b', {}, par.name + ": "));
pdiv.inject(new HTML('br'));
pdiv.inject("%u emails sent".format(par.count));
obj.inject(pdiv);
}
// Word cloud, if applicable
if (wc && json.cloud) {
wc.innerHTML = "";
wc.inject(new HTML('h5', {}, "Popular topics:"));
// word cloud is delayed by 50ms to let the rest render first
// this is a chrome-specific slowdown we're addressing.
window.setTimeout(function() {
wordCloud(json.cloud, 220, 100, wc);
}, 50);
}
if (G_show_stats_sidebar === false) {
obj.style.display = "none";
wc.style.display = "none";
}
}
/******************************************
Fetched from source/swipe.js
******************************************/
const SWIPE_THRESHOLD = 50; // Need at least this long a swipe before we register it
class SwipeDetector {
constructor(target = document, threshold = SWIPE_THRESHOLD) {
this.xDown = null;
this.yDown = null;
this.xUp = null;
this.yUp = null;
this.threshold = threshold;
this.target = target;
console.log("Attaching swipe detector to element ", target);
target.addEventListener("touchstart", this.touchStart, false);
target.addEventListener("touchend", this.touchEnd, false);
}
setCallback(direction, callback_function ) {
document.addEventListener(`swipe${direction}`, callback_function);
}
touchStart(evt) {
let firstTouch = (evt.touches || evt.originalEvent.touches)[0];
this.xDown = firstTouch.clientX;
this.yDown = firstTouch.clientY;
}
touchEnd(evt) {
if (!this.xDown || !this.yDown) return
this.xUp = evt.changedTouches[0].clientX;
this.yUp = evt.changedTouches[0].clientY;
let xDiff = Math.abs(this.xDown - this.xUp);
let yDiff = Math.abs(this.yDown - this.yUp);
let coords = {
detail: {
swipestart: {
coords: [this.xDown, this.yDown]
},
swipestop: {
coords: [this.xUp, this.yUp]
}
}
};
// If the swipe was too short, abort
if (Math.sqrt(xDiff ** 2 + yDiff ** 2) < this.threshold) return
// Which direction??
if (xDiff > yDiff) {
if (xDiff > 0) {
document.dispatchEvent(new CustomEvent("swipeleft", coords));
} else {
document.dispatchEvent(new CustomEvent("swiperight", coords));
}
} else {
if (yDiff > 0) {
document.dispatchEvent(new CustomEvent("swipeup", coords));
} else {
document.dispatchEvent(new CustomEvent("swipedown", coords));
}
}
this.xDown = null;
this.yDown = null;
}
}