ADDED src/chat.c
Index: src/chat.c
==================================================================
--- /dev/null
+++ src/chat.c
@@ -0,0 +1,415 @@
+/*
+** Copyright (c) 2020 D. Richard Hipp
+**
+** This program is free software; you can redistribute it and/or
+** modify it under the terms of the Simplified BSD License (also
+** known as the "2-Clause License" or "FreeBSD License".)
+**
+** This program is distributed in the hope that it will be useful,
+** but without any warranty; without even the implied warranty of
+** merchantability or fitness for a particular purpose.
+**
+** Author contact information:
+** drh@hwaci.com
+** http://www.hwaci.com/drh/
+**
+*******************************************************************************
+**
+** This file contains code used to implement the Fossil chatroom.
+**
+** Initial design goals:
+**
+** * Keep it simple. This chatroom is not intended as a competitor
+** or replacement for IRC, Discord, Telegram, Slack, etc. The goal
+** is zero- or near-zero-configuration, not an abundance of features.
+**
+** * Intended as a place for insiders to have ephemeral conversations
+** about a project. This is not a public gather place. Think
+** "boardroom", not "corner pub".
+**
+** * One chatroom per repository.
+**
+** * Chat content lives in a single repository. It is never synced.
+** Content expires and is deleted after a set interval (a week or so).
+**
+** Notification is accomplished using the "hanging GET" or "long poll" design
+** in which a GET request is issued but the server does not send a reply until
+** new content arrives. Newer Web Sockets and Server Sent Event protocols are
+** more elegant, but are not compatible with CGI, and would thus complicate
+** configuration.
+*/
+#include "config.h"
+#include
+#include "chat.h"
+
+/*
+** WEBPAGE: chat
+**
+** Start up a browser-based chat session.
+*/
+void chat_webpage(void){
+ login_check_credentials();
+ style_set_current_feature("chat");
+ if( !g.perm.Chat ){
+ style_header("Chat Not Authorized");
+ @
Not Authorized
+ @
You do not have permission to use the chatroom on this
+ @ repository.
+ style_finish_page();
+ return;
+ }
+ style_header("Chat");
+ @
+ @
+ @
+
+ /* New chat messages get inserted immediately after this element */
+ @
+
+ builtin_fossil_js_bundle_or("popupwidget", NULL);
+ /* Always in-line the javascript for the chat page */
+ @
+
+ style_finish_page();
+}
+
+/* Definition of repository tables used by chat
+*/
+static const char zChatSchema1[] =
+@ CREATE TABLE repository.chat(
+@ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
+@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
+@ xfrom TEXT, -- Login of the sender
+@ xmsg TEXT, -- Raw, unformatted text of the message
+@ file BLOB, -- Text of the uploaded file, or NULL
+@ fname TEXT, -- Filename of the uploaded file, or NULL
+@ fmime TEXT, -- MIMEType of the upload file, or NULL
+@ mdel INT -- msgid of another message to delete
+@ );
+;
+
+
+/*
+** Make sure the repository data tables used by chat exist. Create them
+** if they do not.
+*/
+static void chat_create_tables(void){
+ if( !db_table_exists("repository","chat") ){
+ db_multi_exec(zChatSchema1/*works-like:""*/);
+ }else if( !db_table_has_column("repository","chat","mdel") ){
+ db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
+ }
+}
+
+/*
+** WEBPAGE: chat-send
+**
+** This page receives (via XHR) a new chat-message and/or a new file
+** to be entered into the chat history.
+*/
+void chat_send_webpage(void){
+ int nByte;
+ const char *zMsg;
+ login_check_credentials();
+ if( !g.perm.Chat ) return;
+ chat_create_tables();
+ nByte = atoi(PD("file:bytes",0));
+ zMsg = PD("msg","");
+ if( nByte==0 ){
+ if( zMsg[0] ){
+ db_multi_exec(
+ "INSERT INTO chat(mtime,xfrom,xmsg)"
+ "VALUES(julianday('now'),%Q,%Q)",
+ g.zLogin, zMsg
+ );
+ }
+ }else{
+ Stmt q;
+ Blob b;
+ db_prepare(&q,
+ "INSERT INTO chat(mtime, xfrom,xmsg,file,fname,fmime)"
+ "VALUES(julianday('now'),%Q,%Q,:file,%Q,%Q)",
+ g.zLogin, zMsg, PD("file:filename",""),
+ PD("file:mimetype","application/octet-stream"));
+ blob_init(&b, P("file"), nByte);
+ db_bind_blob(&q, ":file", &b);
+ db_step(&q);
+ db_finalize(&q);
+ blob_reset(&b);
+ }
+}
+
+/*
+** WEBPAGE: chat-poll
+**
+** The chat page generated by /chat using a XHR to this page in order
+** to ask for new chat content. The "name" argument should begin with
+** an integer which is the largest "msgid" that the chat page currently
+** holds. If newer content is available, this routine returns that
+** content straight away. If no new content is available, this webpage
+** blocks until the new content becomes available. In this way, the
+** system implements "hanging-GET" or "long-poll" style event notification.
+**
+** /chat-poll/N
+**
+** If N is negative, then the return value is the N most recent messages.
+** Hence a request like /chat-poll/-100 can be used to initialize a new
+** chat session to just the most recent messages.
+**
+** Some webservers (althttpd) do not allow a term of the URL path to
+** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
+** have to say "/chat-poll?name=-100".
+**
+** The reply from this webpage is JSON that describes the new content.
+** Format of the json:
+**
+** | {
+** | "msg":[
+** | {
+** | "msgid": integer // message id
+** | "mtime": text // When sent: YYYY-MM-DD HH:MM:SS UTC
+** | "xfrom": text // Login name of sender
+** | "uclr": text // Color string associated with the user
+** | "xmsg": text // HTML text of the message
+** | "fsize": integer // file attachment size in bytes
+** | "fname": text // Name of file attachment
+** | "fmime": text // MIME-type of file attachment
+** | "mdel": integer // message id of prior message to delete
+** | }
+** | ]
+** | }
+**
+** The "fname" and "fmime" fields are only present if "fsize" is greater
+** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
+**
+** The "msgid" values will be in increasing order.
+**
+** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
+*/
+void chat_poll_webpage(void){
+ Blob json; /* The json to be constructed and returned */
+ sqlite3_int64 dataVersion; /* Data version. Used for polling. */
+ int iDelay = 1000; /* Delay until next poll (milliseconds) */
+ const char *zSep = "{\"msgs\":[\n"; /* List separator */
+ int msgid = atoi(PD("name","0"));
+ Stmt q1;
+ login_check_credentials();
+ if( !g.perm.Chat ) return;
+ chat_create_tables();
+ cgi_set_content_type("text/json");
+ dataVersion = db_int64(0, "PRAGMA data_version");
+ if( msgid<0 ){
+ msgid = db_int(0,
+ "SELECT msgid FROM chat WHERE mdel IS NOT true"
+ " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
+ }
+ db_prepare(&q1,
+ "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file),"
+ " fname, fmime, mdel"
+ " FROM chat"
+ " WHERE msgid>%d"
+ " ORDER BY msgid",
+ msgid
+ );
+ blob_init(&json, 0, 0);
+ while(1){
+ int cnt = 0;
+ while( db_step(&q1)==SQLITE_ROW ){
+ int id = db_column_int(&q1, 0);
+ const char *zDate = db_column_text(&q1, 1);
+ const char *zFrom = db_column_text(&q1, 2);
+ const char *zRawMsg = db_column_text(&q1, 3);
+ int nByte = db_column_int(&q1, 4);
+ const char *zFName = db_column_text(&q1, 5);
+ const char *zFMime = db_column_text(&q1, 6);
+ int iToDel = db_column_int(&q1, 7);
+ char *zMsg;
+ cnt++;
+ blob_append(&json, zSep, -1);
+ zSep = ",\n";
+ blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
+ blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
+ blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
+
+ /* TBD: Convert the raw message into HTML, perhaps by running it
+ ** through a text formatter, or putting markup on @name phrases,
+ ** etc. */
+ zMsg = mprintf("%h", zRawMsg ? zRawMsg : "");
+ blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
+ fossil_free(zMsg);
+
+ if( nByte==0 ){
+ blob_appendf(&json, "\"fsize\":0");
+ }else{
+ blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
+ nByte, zFName, zFMime);
+ }
+ if( iToDel ){
+ blob_appendf(&json, ",\"mdel\":%d}", iToDel);
+ }else{
+ blob_append(&json, "}", 1);
+ }
+ }
+ db_reset(&q1);
+ if( cnt ){
+ blob_append(&json, "\n]}", 3);
+ cgi_set_content(&json);
+ break;
+ }
+ sqlite3_sleep(iDelay);
+ while( 1 ){
+ sqlite3_int64 newDataVers = db_int64(0,"PRAGMA repository.data_version");
+ if( newDataVers!=dataVersion ){
+ dataVersion = newDataVers;
+ break;
+ }
+ sqlite3_sleep(iDelay);
+ }
+ } /* Exit by "break" */
+ db_finalize(&q1);
+ return;
+}
+
+/*
+** WEBPAGE: chat-download
+**
+** Download the CHAT.FILE attachment associated with a single chat
+** entry. The "name" query parameter begins with an integer that
+** identifies the particular chat message.
+*/
+void chat_download_webpage(void){
+ int msgid;
+ Blob r;
+ const char *zMime;
+ login_check_credentials();
+ if( !g.perm.Chat ){
+ style_header("Chat Not Authorized");
+ @
Not Authorized
+ @
You do not have permission to use the chatroom on this
+ @ repository.
+ style_finish_page();
+ return;
+ }
+ chat_create_tables();
+ msgid = atoi(PD("name","0"));
+ blob_zero(&r);
+ zMime = db_text(0, "SELECT fmime FROM chat wHERE msgid=%d", msgid);
+ if( zMime==0 ) return;
+ db_blob(&r, "SELECT file FROM chat WHERE msgid=%d", msgid);
+ cgi_set_content_type(zMime);
+ cgi_set_content(&r);
+}
+
+
+/*
+** WEBPAGE: chat-delete
+**
+** Delete the chat entry identified by the name query parameter.
+** Invoking fetch("chat-delete/"+msgid) from javascript in the client
+** will delete a chat entry from the CHAT table.
+**
+** This routine both deletes the identified chat entry and also inserts
+** a new entry with the current timestamp and with:
+**
+** * xmsg = NULL
+** * file = NULL
+** * mdel = The msgid of the row that was deleted
+**
+** This new entry will then be propagated to all listeners so that they
+** will know to delete their copies of the message too.
+*/
+void chat_delete_webpage(void){
+ int mdel;
+ char *zOwner;
+ login_check_credentials();
+ if( !g.perm.Chat ) return;
+ chat_create_tables();
+ mdel = atoi(PD("name","0"));
+ zOwner = db_text(0, "SELECT xfrom FROM chat WHERE msgid=%d", mdel);
+ if( zOwner==0 ) return;
+ if( fossil_strcmp(zOwner, g.zLogin)!=0 && !g.perm.Admin ) return;
+ db_multi_exec(
+ "BEGIN;\n"
+ "DELETE FROM chat WHERE msgid=%d;\n"
+ "INSERT INTO chat(mtime, xfrom, mdel)"
+ " VALUES(julianday('now'), %Q, %d);\n"
+ "COMMIT;",
+ mdel, g.zLogin, mdel
+ );
+}
ADDED src/chat.js
Index: src/chat.js
==================================================================
--- /dev/null
+++ src/chat.js
@@ -0,0 +1,243 @@
+(function(){
+ const form = document.querySelector('#chat-form');
+ let mxMsg = -50;
+ const F = window.fossil, D = F.dom;
+ const _me = F.user.name;
+ /* State for paste and drag/drop */
+ const BlobXferState = {
+ dropDetails: document.querySelector('#chat-drop-details'),
+ blob: undefined
+ };
+ /** Updates the paste/drop zone with details of the pasted/dropped
+ data. */
+ const updateDropZoneContent = function(blob){
+ const bx = BlobXferState, dd = bx.dropDetails;
+ bx.blob = blob;
+ D.clearElement(dd);
+ if(!blob){
+ form.file.value = '';
+ return;
+ }
+ D.append(dd, "Name: ", blob.name,
+ D.br(), "Size: ",blob.size);
+ if(blob.type && blob.type.startsWith("image/")){
+ const img = D.img();
+ D.append(dd, D.br(), img);
+ const reader = new FileReader();
+ reader.onload = (e)=>img.setAttribute('src', e.target.result);
+ reader.readAsDataURL(blob);
+ }
+ const btn = D.button("Cancel");
+ D.append(dd, D.br(), btn);
+ btn.addEventListener('click', ()=>updateDropZoneContent(), false);
+ };
+ form.file.addEventListener('change', function(ev){
+ //console.debug("this =",this);
+ updateDropZoneContent(this.files && this.files[0] ? this.files[0] : undefined)
+ });
+
+ form.addEventListener('submit',(e)=>{
+ e.preventDefault();
+ const fd = new FormData(form);
+ if(BlobXferState.blob/*replace file content with this*/){
+ fd.set("file", BlobXferState.blob);
+ }
+ if( form.msg.value.length>0 || form.file.value.length>0 || BlobXferState.blob ){
+ fetch("chat-send",{
+ method: 'POST',
+ body: fd
+ });
+ }
+ BlobXferState.blob = undefined;
+ D.clearElement(BlobXferState.dropDetails);
+ form.msg.value = "";
+ form.file.value = "";
+ form.msg.focus();
+ });
+ /* Handle image paste from clipboard. TODO: figure out how we can
+ paste non-image binary data as if it had been selected via the
+ file selection element. */
+ document.onpaste = function(event){
+ const items = event.clipboardData.items,
+ item = items[0];
+ if(!item || !item.type) return;
+ //console.debug("pasted item =",item);
+ if('file'===item.kind){
+ updateDropZoneContent(false/*clear prev state*/);
+ updateDropZoneContent(items[0].getAsFile());
+ }else if('string'===item.kind){
+ item.getAsString((v)=>form.msg.value = v);
+ }
+ };
+ if(true){/* Add help button for drag/drop/paste zone */
+ const help = D.div();
+ form.file.parentNode.insertBefore(help, form.file);
+ F.helpButtonlets.create(
+ help,
+ "Select a file to upload, drag/drop a file into this spot, ",
+ "or paste an image from the clipboard if supported by ",
+ "your environment."
+ );
+ }
+ ////////////////////////////////////////////////////////////
+ // File drag/drop visual notification.
+ const dropHighlight = form.file /* target zone */;
+ const dropEvents = {
+ drop: function(ev){
+ D.removeClass(dropHighlight, 'dragover');
+ },
+ dragenter: function(ev){
+ ev.preventDefault();
+ ev.dataTransfer.dropEffect = "copy";
+ D.addClass(dropHighlight, 'dragover');
+ },
+ dragleave: function(ev){
+ D.removeClass(dropHighlight, 'dragover');
+ },
+ dragend: function(ev){
+ D.removeClass(dropHighlight, 'dragover');
+ }
+ };
+ Object.keys(dropEvents).forEach(
+ (k)=>form.file.addEventListener(k, dropEvents[k], true)
+ );
+
+ /* Injects element e as a new row in the chat, at the top of the list */
+ const injectMessage = function f(e){
+ if(!f.injectPoint){
+ f.injectPoint = document.querySelector('#message-inject-point');
+ }
+ if(f.injectPoint.nextSibling){
+ f.injectPoint.parentNode.insertBefore(e, f.injectPoint.nextSibling);
+ }else{
+ f.injectPoint.parentNode.appendChild(e);
+ }
+ };
+ /* Returns a new TEXT node with the given text content. */
+ const textNode = (T)=>document.createTextNode(T);
+ /** Returns the local time string of Date object d, defaulting
+ to the current time. */
+ const localTimeString = function ff(d){
+ if(!ff.pad){
+ ff.pad = (x)=>(''+x).length>1 ? x : '0'+x;
+ }
+ d || (d = new Date());
+ return [
+ d.getFullYear(),'-',ff.pad(d.getMonth()+1/*sigh*/),
+ '-',ff.pad(d.getDate()),
+ ' ',ff.pad(d.getHours()),':',ff.pad(d.getMinutes()),
+ ':',ff.pad(d.getSeconds())
+ ].join('');
+ };
+ /* Returns an almost-ISO8601 form of Date object d. */
+ const iso8601ish = function(d){
+ return d.toISOString()
+ .replace('T',' ').replace(/\.\d+/,'').replace('Z', ' GMT');
+ };
+ /* Event handler for clicking .message-user elements to show their
+ timestamps. */
+ const handleLegendClicked = function f(ev){
+ if(!f.popup){
+ /* Timestamp popup widget */
+ f.popup = new F.PopupWidget({
+ cssClass: ['fossil-tooltip', 'chat-timestamp'],
+ refresh:function(){
+ const D = F.dom;
+ D.clearElement(this.e);
+ const d = new Date(this._timestamp+"Z");
+ if(d.getMinutes().toString()!=="NaN"){
+ // Date works, render informative timestamps
+ D.append(this.e, localTimeString(d)," client-local", D.br(),
+ iso8601ish(d));
+ }else{
+ // Date doesn't work, so dumb it down...
+ D.append(this.e, this._timestamp," GMT");
+ }
+ }
+ });
+ f.popup.installClickToHide();
+ }
+ const rect = ev.target.getBoundingClientRect();
+ f.popup._timestamp = ev.target.dataset.timestamp;
+ let x = rect.left, y = rect.top - 10;
+ f.popup.show(ev.target)/*so we can get its computed size*/;
+ if('right'===ev.target.getAttribute('align')){
+ // Shift popup to the left for right-aligned messages to avoid
+ // truncation off the right edge of the page.
+ const pRect = f.popup.e.getBoundingClientRect();
+ x -= pRect.width/3*2;
+ }
+ f.popup.show(x, y);
+ };
+ /** Callback for poll() to inject new content into the page. */
+ function newcontent(jx){
+ var i;
+ for(i=0; imxMsg ) mxMsg = m.msgid;
+ row.classList.add('message-row');
+ injectMessage(row);
+ const eWho = document.createElement('legend');
+ eWho.dataset.timestamp = m.mtime;
+ eWho.addEventListener('click', handleLegendClicked, false);
+ if( m.xfrom==_me && window.outerWidth<1000 ){
+ eWho.setAttribute('align', 'right');
+ row.style.justifyContent = "flex-end";
+ }else{
+ eWho.setAttribute('align', 'left');
+ }
+ eWho.style.backgroundColor = m.uclr;
+ row.appendChild(eWho);
+ eWho.classList.add('message-user');
+ let whoName = m.xfrom;
+ var d = new Date(m.mtime + "Z");
+ if( d.getMinutes().toString()!="NaN" ){
+ /* Show local time when we can compute it */
+ eWho.append(textNode(whoName+' @ '+
+ d.getHours()+":"+(d.getMinutes()+100).toString().slice(1,3)
+ ))
+ }else{
+ /* Show UTC on systems where Date() does not work */
+ eWho.append(textNode(whoName+' @ '+m.mtime.slice(11,16)))
+ }
+ let span = document.createElement("div");
+ span.classList.add('message-content');
+ span.style.backgroundColor = m.uclr;
+ row.appendChild(span);
+ if( m.fsize>0 ){
+ if( m.fmime && m.fmime.startsWith("image/") ){
+ let img = document.createElement("img");
+ img.src = "chat-download/" + m.msgid;
+ span.appendChild(img);
+ }else{
+ let a = document.createElement("a");
+ let txt = "(" + m.fname + " " + m.fsize + " bytes)";
+ a.href = window.fossil.rootPath+
+ 'chat-download/' + m.msgid+'/'+encodeURIComponent(m.fname);
+ // ^^^ add m.fname to URL to cause downloaded file to have that name.
+ a.appendChild(textNode(txt));
+ span.appendChild(a);
+ }
+ let br = document.createElement("br");
+ br.style.clear = "both";
+ span.appendChild(br);
+ }
+ if(m.xmsg){
+ span.innerHTML += m.xmsg;
+ }
+ span.classList.add('chat-message');
+ }
+ }
+ async function poll(){
+ if(poll.running) return;
+ poll.running = true;
+ fetch("chat-poll?name=" + mxMsg)
+ .then(x=>x.json())
+ .then(y=>newcontent(y))
+ .catch(e=>console.error(e))
+ .finally(()=>poll.running=false)
+ }
+ poll();
+ setInterval(poll, 1000);
+})();
Index: src/default.css
==================================================================
--- src/default.css
+++ src/default.css
@@ -1461,5 +1461,43 @@
position: absolute !important;
opacity: 0 !important;
pointer-events: none !important;
display: none !important;
}
+
+/* Chat-related */
+span.at-name { /* for @USERNAME references */
+ text-decoration: underline;
+ font-weight: bold;
+}
+/* A wrapper for a single single message (one row of the UI) */
+.message-row {
+ margin-bottom: 0.5em;
+ border: none;
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ /*border: 1px solid rgba(0,0,0,0.2);
+ border-radius: 0.25em;
+ box-shadow: 0.2em 0.2em 0.2em rgba(0, 0, 0, 0.29);*/
+ border: none;
+}
+/* The content area of a message (the body element of a FIELDSET) */
+.message-content {
+ display: inline-block;
+ border-radius: 0.25em;
+ border: 1px solid rgba(0,0,0,0.2);
+ box-shadow: 0.2em 0.2em 0.2em rgba(0, 0, 0, 0.29);
+ padding: 0.25em 1em;
+ margin-top: -0.75em;
+ min-width: 9em /*avoid unsightly "underlap" with the user name label*/;
+}
+/* User name for the post (a LEGEND element) */
+.message-row .message-user {
+ border-radius: 0.25em 0.25em 0 0;
+ padding: 0 0.5em;
+ /*text-align: left; Firefox requires the 'align' attribute */
+ margin: 0 0.15em;
+ padding: 0 0.5em 0em 0.5em;
+ margin-bottom: 0.4em;
+ cursor: pointer;
+}
Index: src/dispatch.c
==================================================================
--- src/dispatch.c
+++ src/dispatch.c
@@ -349,10 +349,12 @@
** followed by two spaces and a non-space.
elements can begin
** on the same line as long as they are separated by at least
** two spaces.
**
** * Indented text is show verbatim (
...
)
+**
+** * Lines that begin with "|" at the left margin are in
...
*/
static void help_to_html(const char *zHelp, Blob *pHtml){
int i;
char c;
int nIndent = 0;
@@ -361,10 +363,11 @@
int aIndent[10];
const char *azEnd[10];
int iLevel = 0;
int isLI = 0;
int isDT = 0;
+ int inPRE = 0;
static const char *zEndDL = "";
static const char *zEndPRE = "";
static const char *zEndUL = "";
static const char *zEndDD = "
";
@@ -380,16 +383,32 @@
wantBR = 1;
continue;
}
i++;
}
- if( i>2 && zHelp[0]=='>' && zHelp[1]==' ' ){
- isDT = 1;
- for(nIndent=1; nIndent2 && (zHelp[0]=='>' || zHelp[0]=='|') && zHelp[1]==' ' ){
+ if( zHelp[0]=='>' ){
+ isDT = 1;
+ for(nIndent=1; nIndent\n", -1);
+ inPRE = 1;
+ }
+ }
}else{
+ if( inPRE ){
+ blob_append(pHtml, "\n", -1);
+ inPRE = 0;
+ }
isDT = 0;
for(nIndent=0; nIndent */
@@ -491,11 +510,11 @@
blob_append(pText, "fossil", 6);
zHelp += i+7;
i = -1;
continue;
}
- if( c=='\n' && strncmp(zHelp+i+1,"> ",2)==0 ){
+ if( c=='\n' && (zHelp[i+1]=='>' || zHelp[i+1]=='|') && zHelp[i+2]==' ' ){
blob_append(pText, zHelp, i+1);
blob_append(pText, " ", 1);
zHelp += i+2;
i = -1;
continue;
Index: src/fossil.popupwidget.js
==================================================================
--- src/fossil.popupwidget.js
+++ src/fossil.popupwidget.js
@@ -182,11 +182,27 @@
this.e.style.removeProperty('top');
}
return this;
},
- hide: function(){return this.show(false)}
+ hide: function(){return this.show(false)},
+
+ /**
+ A convenience method which adds click handlers to this popup's
+ main element and document.body to hide the popup when either
+ element is clicked or the ESC key is pressed. Only call this
+ once per instance, if at all. Returns this;
+ */
+ installClickToHide: function f(){
+ this.e.addEventListener('click', ()=>this.show(false), false);
+ document.body.addEventListener('click', ()=>this.show(false), true);
+ const self = this;
+ document.body.addEventListener('keydown', function(ev){
+ if(self.isShown() && 27===ev.which) self.show(false);
+ }, true);
+ return this;
+ }
}/*F.PopupWidget.prototype*/;
/**
Internal impl for F.toast() and friends.
@@ -297,18 +313,11 @@
cssClass: ['fossil-tooltip', 'help-buttonlet-content'],
refresh: function(){
}
});
fch.popup.e.style.maxWidth = '80%'/*of body*/;
- const hide = ()=>fch.popup.hide();
- fch.popup.e.addEventListener('click', hide, false);
- document.body.addEventListener('click', hide, true);
- document.body.addEventListener('keydown', function(ev){
- if(fch.popup.isShown() && 27===ev.which){
- fch.popup.hide();
- }
- }, true);
+ fch.popup.installClickToHide();
}
D.append(D.clearElement(fch.popup.e), ev.target.$helpContent);
var popupRect = ev.target.getClientRects()[0];
var x = popupRect.left, y = popupRect.top;
if(x<0) x = 0;
Index: src/login.c
==================================================================
--- src/login.c
+++ src/login.c
@@ -1230,11 +1230,11 @@
p->ApndWiki = p->Hyperlink = p->Clone =
p->NewTkt = p->Password = p->RdAddr =
p->TktFmt = p->Attach = p->ApndTkt =
p->ModWiki = p->ModTkt =
p->RdForum = p->WrForum = p->ModForum =
- p->WrTForum = p->AdminForum =
+ p->WrTForum = p->AdminForum = p->Chat =
p->EmailAlert = p->Announce = p->Debug = 1;
/* Fall thru into Read/Write */
case 'i': p->Read = p->Write = 1; break;
case 'o': p->Read = 1; break;
case 'z': p->Zip = 1; break;
@@ -1267,10 +1267,11 @@
case '3': p->WrForum = 1;
case '2': p->RdForum = 1; break;
case '7': p->EmailAlert = 1; break;
case 'A': p->Announce = 1; break;
+ case 'C': p->Chat = 1; break;
case 'D': p->Debug = 1; break;
/* The "u" privilege recursively
** inherits all privileges of the user named "reader" */
case 'u': {
@@ -1350,10 +1351,11 @@
case '4': rc = p->WrTForum; break;
case '5': rc = p->ModForum; break;
case '6': rc = p->AdminForum;break;
case '7': rc = p->EmailAlert;break;
case 'A': rc = p->Announce; break;
+ case 'C': rc = p->Chat; break;
case 'D': rc = p->Debug; break;
default: rc = 0; break;
}
}
return rc;
Index: src/main.c
==================================================================
--- src/main.c
+++ src/main.c
@@ -107,10 +107,11 @@
char WrTForum; /* 4: Post to forums not subject to moderation */
char ModForum; /* 5: Moderate (approve or reject) forum posts */
char AdminForum; /* 6: Grant capability 4 to other users */
char EmailAlert; /* 7: Sign up for email notifications */
char Announce; /* A: Send announcements */
+ char Chat; /* C: read or write the chatroom */
char Debug; /* D: show extra Fossil debugging features */
/* These last two are included to block infinite recursion */
char XReader; /* u: Inherit all privileges of "reader" */
char XDeveloper; /* v: Inherit all privileges of "developer" */
};
Index: src/main.mk
==================================================================
--- src/main.mk
+++ src/main.mk
@@ -32,10 +32,11 @@
$(SRCDIR)/bundle.c \
$(SRCDIR)/cache.c \
$(SRCDIR)/capabilities.c \
$(SRCDIR)/captcha.c \
$(SRCDIR)/cgi.c \
+ $(SRCDIR)/chat.c \
$(SRCDIR)/checkin.c \
$(SRCDIR)/checkout.c \
$(SRCDIR)/clearsign.c \
$(SRCDIR)/clone.c \
$(SRCDIR)/comformat.c \
@@ -219,10 +220,11 @@
$(SRCDIR)/../skins/xekri/css.txt \
$(SRCDIR)/../skins/xekri/details.txt \
$(SRCDIR)/../skins/xekri/footer.txt \
$(SRCDIR)/../skins/xekri/header.txt \
$(SRCDIR)/accordion.js \
+ $(SRCDIR)/chat.js \
$(SRCDIR)/ci_edit.js \
$(SRCDIR)/copybtn.js \
$(SRCDIR)/default.css \
$(SRCDIR)/diff.tcl \
$(SRCDIR)/forum.js \
@@ -290,10 +292,11 @@
$(OBJDIR)/bundle_.c \
$(OBJDIR)/cache_.c \
$(OBJDIR)/capabilities_.c \
$(OBJDIR)/captcha_.c \
$(OBJDIR)/cgi_.c \
+ $(OBJDIR)/chat_.c \
$(OBJDIR)/checkin_.c \
$(OBJDIR)/checkout_.c \
$(OBJDIR)/clearsign_.c \
$(OBJDIR)/clone_.c \
$(OBJDIR)/comformat_.c \
@@ -438,10 +441,11 @@
$(OBJDIR)/bundle.o \
$(OBJDIR)/cache.o \
$(OBJDIR)/capabilities.o \
$(OBJDIR)/captcha.o \
$(OBJDIR)/cgi.o \
+ $(OBJDIR)/chat.o \
$(OBJDIR)/checkin.o \
$(OBJDIR)/checkout.o \
$(OBJDIR)/clearsign.o \
$(OBJDIR)/clone.o \
$(OBJDIR)/comformat.o \
@@ -776,10 +780,11 @@
$(OBJDIR)/bundle_.c:$(OBJDIR)/bundle.h \
$(OBJDIR)/cache_.c:$(OBJDIR)/cache.h \
$(OBJDIR)/capabilities_.c:$(OBJDIR)/capabilities.h \
$(OBJDIR)/captcha_.c:$(OBJDIR)/captcha.h \
$(OBJDIR)/cgi_.c:$(OBJDIR)/cgi.h \
+ $(OBJDIR)/chat_.c:$(OBJDIR)/chat.h \
$(OBJDIR)/checkin_.c:$(OBJDIR)/checkin.h \
$(OBJDIR)/checkout_.c:$(OBJDIR)/checkout.h \
$(OBJDIR)/clearsign_.c:$(OBJDIR)/clearsign.h \
$(OBJDIR)/clone_.c:$(OBJDIR)/clone.h \
$(OBJDIR)/comformat_.c:$(OBJDIR)/comformat.h \
@@ -1054,10 +1059,18 @@
$(OBJDIR)/cgi.o: $(OBJDIR)/cgi_.c $(OBJDIR)/cgi.h $(SRCDIR)/config.h
$(XTCC) -o $(OBJDIR)/cgi.o -c $(OBJDIR)/cgi_.c
$(OBJDIR)/cgi.h: $(OBJDIR)/headers
+
+$(OBJDIR)/chat_.c: $(SRCDIR)/chat.c $(OBJDIR)/translate
+ $(OBJDIR)/translate $(SRCDIR)/chat.c >$@
+
+$(OBJDIR)/chat.o: $(OBJDIR)/chat_.c $(OBJDIR)/chat.h $(SRCDIR)/config.h
+ $(XTCC) -o $(OBJDIR)/chat.o -c $(OBJDIR)/chat_.c
+
+$(OBJDIR)/chat.h: $(OBJDIR)/headers
$(OBJDIR)/checkin_.c: $(SRCDIR)/checkin.c $(OBJDIR)/translate
$(OBJDIR)/translate $(SRCDIR)/checkin.c >$@
$(OBJDIR)/checkin.o: $(OBJDIR)/checkin_.c $(OBJDIR)/checkin.h $(SRCDIR)/config.h
Index: src/makemake.tcl
==================================================================
--- src/makemake.tcl
+++ src/makemake.tcl
@@ -43,10 +43,11 @@
bundle
cache
capabilities
captcha
cgi
+ chat
checkin
checkout
clearsign
clone
comformat
Index: src/rebuild.c
==================================================================
--- src/rebuild.c
+++ src/rebuild.c
@@ -394,11 +394,11 @@
" WHERE type='table'"
" AND name NOT IN ('admin_log', 'blob','delta','rcvfrom','user','alias',"
"'config','shun','private','reportfmt',"
"'concealed','accesslog','modreq',"
"'purgeevent','purgeitem','unversioned',"
- "'subscriber','pending_alert','alert_bounce')"
+ "'subscriber','pending_alert','alert_bounce','chat')"
" AND name NOT GLOB 'sqlite_*'"
" AND name NOT GLOB 'fx_*'"
);
while( db_step(&q)==SQLITE_ROW ){
blob_appendf(&sql, "DROP TABLE IF EXISTS \"%w\";\n", db_column_text(&q,0));
@@ -942,10 +942,11 @@
"UPDATE user SET photo=NULL, info='';\n"
"DROP TABLE IF EXISTS purgeevent;\n"
"DROP TABLE IF EXISTS purgeitem;\n"
"DROP TABLE IF EXISTS admin_log;\n"
"DROP TABLE IF EXISTS vcache;\n"
+ "DROP TABLE IF EXISTS chat;\n"
);
}
db_protect_pop();
}
if( !bNeedRebuild ){
Index: src/setupuser.c
==================================================================
--- src/setupuser.c
+++ src/setupuser.c
@@ -681,10 +681,12 @@
@ Supervise Forum%s(B('6'))
@
Index: src/timeline.c
==================================================================
--- src/timeline.c
+++ src/timeline.c
@@ -122,10 +122,13 @@
#define TIMELINE_DELTA 0x10000000 /* Background color shows delta manifests */
#endif
/*
** Hash a string and use the hash to determine a background color.
+**
+** This value returned is in static space and is overwritten with
+** each subsequent call.
*/
char *hash_color(const char *z){
int i; /* Loop counter */
unsigned int h = 0; /* Hash on the branch name */
int r, g, b; /* Values for red, green, and blue */
ADDED tools/chat.tcl
Index: tools/chat.tcl
==================================================================
--- /dev/null
+++ tools/chat.tcl
@@ -0,0 +1,509 @@
+#!/usr/bin/wapptclsh
+#
+# A chat program designed to run using the extcgi mechanism of Fossil.
+#
+encoding system utf-8
+
+# The name of the chat database file
+#
+proc chat-db-name {} {
+ set x [wapp-param SCRIPT_FILENAME]
+ set dir [file dir $x]
+ set fn [file tail $x]
+ return $dir/-$fn.db
+}
+
+# Verify permission to use chat. Return true if not authorized.
+# Return false if the Fossil user is allowed to access chat.
+#
+proc not-authorized {} {
+ set cap [wapp-param FOSSIL_CAPABILITIES]
+ return [expr {![string match *i* $cap]}]
+}
+
+# The default page.
+# Load the initial chat screen.
+#
+proc wapp-default {} {
+ wapp-content-security-policy off
+ wapp-trim {
+
+ CGI environment |
+ Wapp script
+
+ }
+ set nonce [wapp-param FOSSIL_NONCE]
+ set submiturl [wapp-param SCRIPT_NAME]/send
+ set pollurl [wapp-param SCRIPT_NAME]/poll
+ set downloadurl [wapp-param SCRIPT_NAME]/download
+ set me [wapp-param FOSSIL_USER]
+ wapp-trim {
+
+ }
+
+ # Make sure the chat database exists
+ sqlite3 db [chat-db-name]
+ if {[db one {PRAGMA journal_mode}]!="wal"} {
+ db eval {PRAGMA journal_mode=WAL}
+ }
+ db eval {
+ CREATE TABLE IF NOT EXISTS chat(
+ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
+ mtime JULIANDAY,
+ xfrom TEXT,
+ xto TEXT,
+ xmsg TEXT,
+ file BLOB,
+ fname TEXT,
+ fmime TEXT
+ );
+ CREATE TABLE IF NOT EXISTS ustat(
+ uname TEXT PRIMARY KEY,
+ mtime JULIANDAY, -- Last interaction
+ seen INT, -- Last message seen
+ logout JULIANDAY
+ ) WITHOUT ROWID;
+ }
+ db close
+}
+
+# Show the CGI environment. Used for testing only.
+#
+proc wapp-page-env {} {
+ wapp-trim {
+
+
%html([wapp-debug-env])
+
+ }
+}
+
+# Log the CGI environment into the "-logfile.txt" file in the same
+# directory as the script. Used for testing and development only.
+#
+proc logenv {} {
+ set fn [file dir [wapp-param SCRIPT_FILENAME]]/-logfile.txt
+ set out [open $fn a]
+ puts $out {************************************************************}
+ puts $out [wapp-debug-env]
+ close $out
+}
+
+# A no-op page. Used for testing and development only.
+#
+proc noop-page {} {
+ wapp-trim {
+
No-Op
+ }
+}
+
+# Accept a new post via XHR.
+# No reply expected.
+#
+proc wapp-page-send {} {
+ if {[not-authorized]} return
+ set user [wapp-param FOSSIL_USER]
+ set fcontent [wapp-param file.content]
+ set fname [wapp-param file.filename]
+ set fmime [wapp-param file.mimetype]
+ set msg [wapp-param msg]
+ sqlite3 db [chat-db-name]
+ db eval BEGIN
+ if {$fcontent!=""} {
+ db eval {
+ INSERT INTO chat(mtime,xfrom,xmsg,file,fname,fmime)
+ VALUES(julianday('now'),$user,@msg,@fcontent,$fname,$fmime)
+ }
+ } else {
+ db eval {
+ INSERT INTO chat(mtime,xfrom,xmsg)
+ VALUES(julianday('now'),$user,@msg)
+ }
+ }
+ db eval {
+ INSERT INTO ustat(uname,mtime,seen) VALUES($user,julianday('now'),0)
+ ON CONFLICT(uname) DO UPDATE set mtime=julianday('now')
+ }
+ db eval COMMIT
+ db close
+}
+
+# Request updates.
+# Delay the response until something changes (as this system works
+# using the Hanging-GET or Long-Poll style of server-push).
+# The result is javascript describing the new content.
+#
+# Call is like this: /poll/N
+# Where N is the last message received so far. The reply stalls
+# until newer messages are available.
+#
+proc wapp-page-poll {} {
+ if {[not-authorized]} return
+ wapp-mimetype text/json
+ set msglist {}
+ sqlite3 db [chat-db-name]
+ set id 0
+ scan [wapp-param PATH_TAIL] %d id
+ while {1} {
+ set datavers [db one {PRAGMA data_version}]
+ db eval {SELECT msgid, datetime(mtime) AS dx, xfrom, CAST(xmsg AS text) mx,
+ length(file) AS lx, fname, fmime
+ FROM chat
+ WHERE msgid>$id
+ ORDER BY msgid} {
+ set quname [string map {\" \\\"} $xfrom]
+ set qmsg [string map {\" \\\"} $mx]
+ if {$lx==""} {set lx 0}
+ set qfname [string map {\" \\\"} $fname]
+ lappend msglist "\173\"msgid\":$msgid,\"mtime\":\"$dx\",\
+ \"xfrom\":\"$quname\",\
+ \"xmsg\":\"$qmsg\",\"fsize\":$lx,\
+ \"fname\":\"$qfname\",\"fmime\":\"$fmime\"\175"
+ }
+ if {[llength $msglist]>0} {
+ wapp-unsafe "\173\042msgs\042:\133[join $msglist ,]\135\175"
+ db close
+ return
+ }
+ after 2000
+ while {[db one {PRAGMA data_version}]==$datavers} {after 2000}
+ }
+}
+
+# Show the text of this script.
+#
+proc wapp-page-self {} {
+ wapp-trim {
+
+ }
+ set fd [open [wapp-param SCRIPT_FILENAME] rb]
+ set script [read $fd]
+ wapp-trim {
+
%html($script)
+ }
+ wapp-trim {
+
+ }
+}
+
+# Download the file associated with a message.
+#
+# Call like this: /download/N
+# Where N is the message id.
+#
+proc wapp-page-download {} {
+ if {[not-authorized]} {
+ wapp-trim {
+