Commit 2b146ccf authored by Eugene Shen's avatar Eugene Shen

Add polling time list and fix minor bugs

Remove FastPriorityQueue because jIO already sorts everything
and gadget.state.message_list_dict[room] is always sorted,
fix the merge algorithm to not use a priority queue at all,
add POLL_DELAY_LIST to customize increasing polling intervals,
add current_refresh_dict as a mutex over refreshChat(),
and add requirements to function docstrings.
parent fdcbdb06
/*jslint nomen: true, indent: 2, maxerr: 3, maxlen: 80 */
/*global window, document, RSVP, rJS, Handlebars,
FastPriorityQueue, promiseEventListener */
(function (window, document, RSVP, rJS, Handlebars,
FastPriorityQueue, promiseEventListener) {
/*global window, document, RSVP, rJS, Handlebars, promiseEventListener */
(function (window, document, RSVP, rJS, Handlebars, promiseEventListener) {
"use strict";
/* Settings required:
......@@ -14,8 +12,19 @@
* - default_dav_url, default: ""
*/
// Handlebars templates
var chat_list_template,
contact_list_template;
contact_list_template,
// arbitrary limit to number of jIO text posts to return
// must include limit: [0, JIO_QUERY_MAX_LIMIT] because the default is 10
JIO_QUERY_MAX_LIMIT = 1000000000,
// list of how long to wait when polling, i.e. wait for 0.5 seconds, poll,
// then wait for 1 second, poll, etc. until always waiting for 5 minutes
POLL_DELAY_LIST = [500, 1000, 2000, 5000, 10000, 15000, 20000, 25000,
30000, 40000, 60000, 90000, 120000, 150000, 180000, 240000, 300000];
/* Check if a string ends with another string.
......@@ -46,7 +55,7 @@
/* Reset a text input.
* Parameters:
* - element: the text input element to reset
* Effects: set the value of the text input to the empty string
* Effects: set the value of the text input to the empty string
* Returns: the previous value of the text input before resetting it
*/
......@@ -69,6 +78,19 @@
}
/* Check if two messages are the same.
* Parameters:
* - lhs, rhs: the two messages that may or may not be the same
* Effects: none
* Returns: true if both messages exist and are the same, otherwise false
*/
function isSameMessage(lhs, rhs) {
return lhs !== undefined && rhs !== undefined
&& lhs.name === rhs.name && lhs.content === rhs.content
&& lhs.room === rhs.room && getTime(lhs) === getTime(rhs);
}
/* Create a new JSON message.
* Parameters:
* - name: the name of the sender of the message
......@@ -94,7 +116,7 @@
/* Translate a JSON message to an HTML chat element.
* Parameters:
* - message: the JavaScript object to display in HTML
* - message: the JavaScript object to display in HTML, from createMessage
* Effects: nothing
* Returns: a properly escaped HTML representation of the given message
*/
......@@ -176,11 +198,10 @@
// true if the room is a chat box, false if the room is a contact panel
is_chat: false,
// a dict of room IDs to the ir names, i.e. {foo_bar_com: "foo@bar.com"}
// a dict of room IDs to their names, i.e. {foo_bar_com: "foo@bar.com"}
id_to_name: {},
// a dict of room names to whether each has unread messages
// i.e. {read_room: false, unread_room: true}
unread_room_dict: {},
// a dict of room names to the list of messages in each,
......@@ -195,6 +216,9 @@
// i.e. {room: new RSVP.Queue().push(function () { return ... })}
delay_refresh_dict: {},
// a dict of room names to whether each is currently running refreshChat
current_refresh_dict: {},
// true to use alert_icon_url, false to use default_icon_url
favicon_alert: false,
alert_icon_url: "https://pricespy.co.nz/favicon.ico",
......@@ -216,21 +240,37 @@
.declareAcquiredMethod("getSetting", "getSetting")
// The following function is acquired by gadget_erp5_chat_room.
/* Join a new room room.
* This function is acquired by gadget_erp5_chat_room.
* Parameters:
* - room: the name of the room to join
* Requirements:
* - room has an associated room gadget with a valid jIO storage
* Effects:
* - send a message when room is joined
*/
.allowPublicAcquisition("changeRoom", function (param_list) {
.allowPublicAcquisition("joinNewRoom", function (param_list) {
var gadget = this;
return gadget.changeRoom.apply(gadget, param_list);
return gadget.changeState({room: param_list[0], is_chat: true})
.push(function () {
gadget.deployMessage({
name: gadget.state.name,
room: param_list[0],
content: gadget.state.name + " has joined.",
color: "orange"
});
});
})
/* Render everything again when the current state changes.
* Parameters: all properties in gadget.state
* Effects:
* - in right panel, only display the currently active room or contact
* - in right panel, set the title based on the room or contact
* - in contact list, only add styles for unread messages and current room
* - set favicon depending on whether there are new unread messages
* - in right panel, only display the currently active room or contact
* - in right panel, set the title based on the room or contact
*/
.onStateChange(function (modification_dict) {
......@@ -301,34 +341,48 @@
chat_list_element.scrollTop = chat_list_element.scrollHeight;
}
// set update to false so that setting update to true calls onStateChange
gadget.state.update = false;
// refresh the current room
if (gadget.state.refresh_chat) {
gadget.state.refresh_chat = false;
gadget.delayRefresh(gadget.state.room, 5000);
gadget.delayRefresh(gadget.state.room, 0);
}
// set update to false so that setting update to true calls onStateChange
gadget.state.update = false;
})
// Do nothing; everything is in following declareService(), because
// clicking on Chat Box in the panel calls render() without refreshing
.declareMethod("render", function () { return; })
/* Render the gadget.
* Parameters:
* - getSetting: user_email, jio_storage_description,
* default_jio_type, default_erp5_url, default_dav_url
* Effects:
* - update header, page_title to "OfficeJS Chat"
* - compile Handlebars templates
* - redirect if no jIO storage available
* - create the user contact, whose name is the user name
* - change to the user contact panel
*/
.declareMethod("render", function () {
.declareService(function () {
var gadget = this,
user_email;
return gadget.requireSetting(
"jio_storage_description",
"jio_configurator",
new RSVP.Queue()
.push(function () {
return gadget.updateHeader({page_title: "OfficeJS Chat"});
})
.push(function () {
return gadget.updateHeader({page_title: "OfficeJS Chat"});
})
.push(function () {
chat_list_template = Handlebars.compile(
Object.getPrototypeOf(gadget).constructor.__template_element
......@@ -342,11 +396,6 @@
.querySelector(".chat-title");
gadget.state.chat_box_element = gadget.element
.querySelector(".chat-right-panel-chat");
return gadget.updateHeader({
page_title: "OfficeJS Chat"
});
})
.push(function () {
return RSVP.all([
gadget.getSetting("user_email"),
gadget.getSetting("jio_storage_description"),
......@@ -372,9 +421,13 @@
gadget.element.querySelector(".send-form input[type='text']")
.onfocus = function () {
gadget.state.unread_room_dict[gadget.state.room] = false;
return gadget.changeState({update: true});
return gadget.changeState({
refresh_chat: true,
favicon_alert: false,
update: true
});
};
return gadget.createContact(user_email);
return gadget.createRoom(user_email);
})
.push(function () {
return gadget.changeState({room: user_email});
......@@ -383,39 +436,28 @@
})
/* Create a new contact.
* Parameters:
* - room: the name of the contact
* Effects:
* - if the name is not blank and not a duplicate, then:
* - create a new contact to be rendered in the contact list
* - create a new room gadget
*/
.declareMethod("createContact", function (room) {
var gadget = this;
if (!room.trim()) {
throw "An invisible name is not allowed! You couldn't click on it!";
}
if (gadget.state.message_list_dict.hasOwnProperty(room)) {
throw "A contact with the same name already exists!";
}
return gadget.createRoom(room);
})
/* Create a new room gadget.
/* Create a new room.
* Parameters:
* - room: the name of the room
* Requirements:
* - room is not blank and not already in the contact list
* Effects:
* - declare a new gadget with scope "room-gadget-" + room
* - set its ID to a querySelector-safe translation of its scope
* - set its ID to a querySelector-safe translation of room
* - initialize its state and render the room gadget
* - update the chat box gadget state dicts with the new room
* - change to the room contact panel
*/
.declareMethod("createRoom", function (room) {
var gadget = this,
room_gadget;
if (!room.trim()) {
throw "An invisible name is not allowed! You couldn't click on it!";
}
if (gadget.state.message_list_dict.hasOwnProperty(room)) {
throw "A contact with the same name already exists!";
}
return gadget.declareGadget("gadget_erp5_chat_room.html", {
scope: "room-gadget-" + room
})
......@@ -423,10 +465,8 @@
room_gadget = sub_gadget;
room_gadget.element.setAttribute("id",
"room-gadget-" + nameToId(room));
gadget.element.querySelector(".chat-right-panel").insertBefore(
room_gadget.element,
gadget.element.querySelector(".chat-max-height-wrapper")
);
gadget.element.querySelector(".chat-max-height-wrapper")
.appendChild(room_gadget.element);
return room_gadget.changeState({
room: room,
local_sub_storage: gadget.state.local_sub_storage,
......@@ -434,8 +474,8 @@
default_erp5_url: gadget.state.default_erp5_url,
default_dav_url: gadget.state.default_dav_url,
query: {
limit: [0, 1000000000],
query: 'portal_type: "Text Post"' // AND room: "' + room + '"'
query: 'portal_type: "Text Post" AND room: "' + room + '"',
limit: [0, JIO_QUERY_MAX_LIMIT]
}
});
})
......@@ -445,6 +485,8 @@
.push(function () {
gadget.state.message_list_dict[room] = [];
gadget.state.message_count_dict[room] = 0;
gadget.state.current_refresh_dict[room] = false;
gadget.state.delay_refresh_dict[room] = new RSVP.Queue();
gadget.state.id_to_name["chat-contact-" + nameToId(room)] = room;
return gadget.changeState({room: room, is_chat: false});
});
......@@ -454,44 +496,45 @@
/* Change to a different room.
* Parameters:
* - room: the name of the room to change to
* Requirements:
* - room is already in the contact list
* Effects:
* - hide the room gadget contact panel
* - show the chat box
* - overwrite the chat box with chats from the jIO storage
* - if the room is already joined, then change to the chat panel
* - otherwise, change to the corresponding contact panel
*/
.declareMethod("changeRoom", function (room) {
var gadget = this;
return gadget.changeState({room: room, is_chat: true})
.push(function () {
if (gadget.state.message_list_dict[room].length === 0) {
return gadget.deployMessage({
name: gadget.state.name,
room: gadget.state.room,
content: gadget.state.name + " has joined.",
color: "orange"
});
}
});
if (gadget.state.message_list_dict[room].length > 0) {
return gadget.changeState({room: room, is_chat: true});
}
return gadget.changeState({room: room, is_chat: false, update: true});
})
/* Deploy a new message.
* Parameters:
* - param_dict: the parameters to pass to createMessage
* Requirements:
* - param_dict.room is already in the contact list
* - createMessage(param_dict) creates a valid message
* Effects:
* - create a new message
* - append the message to the chat
* - store the message in jIO storage
* - update the chat box gadget state dicts
* - refresh the chat
*/
.declareMethod("deployMessage", function (param_dict) {
var gadget = this,
message = createMessage(param_dict);
gadget.state.message_list_dict[param_dict.room].push(message);
// increase so that rerreshChat() does not also call changeState()
gadget.state.message_count_dict[param_dict.room] += 1;
gadget.state.unread_room_dict[param_dict.room] = false;
return gadget.storeArchive(message)
.push(function () {
return gadget.changeState({refresh_chat: true});
return gadget.changeState({refresh_chat: true, favicon_alert: false});
});
})
......@@ -499,22 +542,27 @@
/* Deploy a new notification.
* Parameters:
* - param_dict: the parameters to pass to createMessage
* Requirements:
* - param_dict.room is already in the contact list
* - createMessage(param_dict) creates a valid message
* Effects:
* - create a new message
* - append the message to the chat
* - append the message to the local message_list_dict but not jIO storage
* - refresh the chat
*/
.declareMethod("deployNotification", function (param_dict) {
var gadget = this,
message;
var gadget = this;
param_dict.type = "notification";
message = createMessage(param_dict);
gadget.state.message_list_dict[param_dict.room].push(message);
gadget.state.message_list_dict[param_dict.room]
.push(createMessage(param_dict));
return gadget.changeState({refresh_chat: true});
})
/* Store a message in jIO storage.
* Requirements:
* - message.room has an associated room gadget with a valid jIO storage
* Parameters:
* - message: the message object to store in jIO storage
* Effects: store the message with all necessary properties into jIO
......@@ -542,27 +590,32 @@
/* Refresh the chat by polling with increasing delays.
* Parameters:
* - room: the room to refresh
* - delay: the time in milliseconds to wait before refreshing again
* - poll_delay_index: the index in POLL_DELAY_LIST of
* the time in milliseconds to wait before refreshing again
* Requirements:
* - gadget.state.delay_refresh_dict[room] has new RSVP.Queue() in it
* Effects:
* - call refreshChat and wait delay milliseconds before calling it again
* - call refreshChat and wait a while before calling it again
*/
.declareMethod("delayRefresh", function (room, delay) {
.declareMethod("delayRefresh", function (room, poll_delay_index) {
var gadget = this;
return new RSVP.Queue()
.push(function () {
if (poll_delay_index >= POLL_DELAY_LIST.length) {
poll_delay_index = POLL_DELAY_LIST.length - 1;
}
// do not wait for refreshChat to finish before starting the delay
return RSVP.all([
RSVP.delay(delay),
RSVP.delay(POLL_DELAY_LIST[poll_delay_index]),
gadget.refreshChat(room)
]);
})
.push(function () {
if (gadget.state.delay_refresh_dict.hasOwnProperty(room)) {
gadget.state.delay_refresh_dict[room].cancel();
}
gadget.state.delay_refresh_dict[room].cancel();
gadget.state.delay_refresh_dict[room] = new RSVP.Queue()
.push(function () {
return gadget.delayRefresh(room, delay + 10000);
return gadget.delayRefresh(room, poll_delay_index + 1);
});
});
})
......@@ -571,15 +624,31 @@
/* Refresh the message list with chats from the jIO storage.
* Parameters:
* - room: the room to refresh
* Requirements:
* - refreshChat() is not currently being executed
* - room has an associated room gadget with a valid jIO storage
* - there are no duplicate messages in that jIO storage
* - gadget.state.message_count_dict[room] is the total number of messages
* retrieved from allDocs() the last time refreshChat() was triggered
* - gadget.state.message_list_dict[room] is a chronologically ascending
* list of the unique messages loaded in the current room
* Effects:
* - get a sorted list of all chats in the current room from jIO storage
* - merge the list with the current messages in a priority queue
* - update the current messages with the sorted message queue
* - merge the list with the sorted list of unique current messages
* - overwrite the latter with the resulting sorted list of unique messages
*/
.declareMethod("refreshChat", function (room) {
var gadget = this,
room_gadget;
// lock so that at most one refreshChat() is running at any time
// multiple calls to repair() results in unmanageable duplication
if (gadget.state.current_refresh_dict[room]) {
return;
}
gadget.state.current_refresh_dict[room] = true;
return gadget.getDeclaredGadget("room-gadget-" + room)
.push(function (sub_gadget) {
room_gadget = sub_gadget;
......@@ -588,55 +657,61 @@
.push(function () {
return room_gadget.wrapJioCall("allDocs", [{
query: 'portal_type: "Text Post" AND room: "' + room + '"',
limit: [0, 1000000],
sort_on: [["date_ms", "ascending"]],
select_list: ["content"]
limit: [0, JIO_QUERY_MAX_LIMIT],
select_list: ["content"],
sort_on: [
["date_ms", "ascending"],
["content", "ascending"]
]
}]);
})
.push(function (result_list) {
var i, message, new_list = [],
old_list = gadget.state.message_list_dict[room],
message_queue = new FastPriorityQueue(function (lhs, rhs) {
return getTime(lhs) < getTime(rhs);
});
var i, j, message, new_list = [],
old_list = gadget.state.message_list_dict[room];
// only run if there are new messages, since messages are not deleted
// calling refreshChat() after deployMessage() does nothing
// because deployMessage() already added one new message
if (result_list.data.total_rows >
gadget.state.message_count_dict[room]) {
gadget.state.message_count_dict[room] = result_list.data.total_rows;
gadget.state.unread_room_dict[room] = true;
// merge two sorted lists of unique messages together
j = 0;
for (i = 0; i < result_list.data.total_rows; i += 1) {
try {
message = JSON.parse(result_list.data.rows[i].value.content);
if (message && typeof message === "object") {
message_queue.add(message);
}
} catch (ignore) {}
}
for (i = 0; i < old_list.length; i += 1) {
if (!message_queue.isEmpty()) {
message = message_queue.poll();
while (getTime(old_list[i]) < getTime(message)
&& i < old_list.length) {
new_list.push(old_list[i]);
i += 1;
}
while (getTime(old_list[i]) === getTime(message)) {
i += 1;
}
new_list.push(message);
} else {
new_list.push(old_list[i]);
message = JSON.parse(result_list.data.rows[i].value.content);
while (j < old_list.length
&& getTime(old_list[j]) < getTime(message)) {
new_list.push(old_list[j]);
j += 1;
}
// ignore duplicates between the lists
while (j < old_list.length
&& isSameMessage(old_list[j], message)) {
j += 1;
}
new_list.push(message);
}
while (!message_queue.isEmpty()) {
new_list.push(message_queue.poll());
while (j < old_list.length) {
new_list.push(old_list[j]);
j += 1;
}
// override the current list of messages and notify the user
gadget.state.message_list_dict[room] = new_list;
return gadget.changeState({refresh_chat: true});
gadget.state.unread_room_dict[room] = true;
return gadget.changeState({
refresh_chat: true,
favicon_alert: true
});
}
})
// release the lock no matter what errors occur
.push(function () {
gadget.state.current_refresh_dict[room] = false;
}, function () {
gadget.state.current_refresh_dict[room] = false;
});
})
......@@ -663,16 +738,15 @@
];
switch (command) {
// change to a room that has already been joined
// change to the given room
case "join":
if (gadget.state.message_list_dict[argument] > 0) {
if (gadget.state.message_list_dict[argument]) {
return gadget.changeRoom(argument);
}
return gadget.deployNotification({
name: gadget.state.name,
room: gadget.state.room,
content: "You must first be connected to room '"
+ argument + "' via a shared jIO storage to join it!",
content: "You must first add '" + argument + "' as a contact!",
color: "red"
});
......@@ -727,7 +801,7 @@
})
// Call changeRoom or changeState when a chat contact is clicked.
// Call changeRoom when a chat contact is clicked.
.onEvent("click", function (event) {
var gadget = this,
......@@ -735,10 +809,10 @@
if (event.target.classList.contains("chat-contact")) {
room = gadget.state.id_to_name[event.target.id];
gadget.state.unread_room_dict[room] = false;
if (gadget.state.message_list_dict[room] > 0) {
return gadget.changeRoom(room);
}
return gadget.changeState({room: room, is_chat: false, update: true});
return gadget.changeState({favicon_alert: false})
.push(function () {
return gadget.changeRoom(room);
});
}
}, false, false)
......@@ -753,7 +827,7 @@
return gadget.changeState({is_chat: false});
case "join-form":
content = resetInputValue(event.target.elements.content);
return gadget.createContact(content);
return gadget.createRoom(content);
case "send-form":
content = resetInputValue(event.target.elements.content);
if (content.indexOf("/") === 0) {
......@@ -783,7 +857,7 @@
var promise_list = [], room;
for (room in gadget.state.message_list_dict) {
if (gadget.state.message_list_dict.hasOwnProperty(room)
&& gadget.state.message_list_dict[room] > 0) {
&& gadget.state.message_list_dict[room].length > 0) {
promise_list.push(gadget.deployMessage({
name: gadget.state.name,
content: gadget.state.name + " has quit.",
......@@ -796,5 +870,4 @@
});
});
}(window, document, RSVP, rJS, Handlebars,
FastPriorityQueue, promiseEventListener));
\ No newline at end of file
}(window, document, RSVP, rJS, Handlebars, promiseEventListener));
......@@ -62,7 +62,6 @@
/* Render the gadget.
* Parameters: nothing
* Effects: update header, page_title to "Connect to Chat"
*/
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment