Live Comments Source Code

This is the source code for the "Live Comments" AJAX application as implemented on gmail dot net. This implementation uses a database to persist user comments and email addresses. The server-side scripting language is PHP and the database is MySQL.

You can use a different backend in combination with the AJAX javascript client-side application listed below. Also included is the CSS that is used for layout and styling Live Comments look-and-feel.

The software is released under the MIT license (i.e., basically, you can do what you want with it!).

Instructions:

First, create the Comments DB and associated tables; ActiveUsers, Comments, and Email.

Next, copy the Comments.php file to a directory accessible by your web server. Let's assume that directory is called webroot/www.

At the same level as www, create another directory for the include files called inc; e.g. webroot/inc. Copy DBLayer.php and Constants.php into this directory.

Now create Css and Js directories under www and copy Comments.css and comments.js into their respective directories.

Next, create CG (common graphics) under www and copy bl.gif and br.gif into the directory.

Your directory should look like:

                
        webroot/www
            Comments.php
            index.html   (see Example.html)
        webroot/www/Js
            comments.js
        webroot/www/Css
            Comments.css
            WebSite.css  (see Example.html)
        webroot/www/CG
            bl.gif
            br.gif
        webroot/inc
            DBLayer.php
            Constants.php
                
                

Now, create an HTML file and place in the www directory. Within the HTML file, where Live Comments is to be added, add the following to the <head> section (see Example at bottom of page):

  <link rel="stylesheet" href="Css/Comments.css"></link>
  <script type="text/javascript" src="Js/comments.js"></script>


In the body of your HTML, add the following div (note: the id must be "comments"):

  <div id="comments" class="invisible">&nbsp;</div>

Finally, add an onload to the body tag as follows:

  <body onload="initComments()">

That should be it! The only difficult part is implementing your persistence layer. Once implemented you should be good to go.

Database:

Create database and associated tables.

                
#
# Script to create Live Comments DB and associated tables.
#

DROP DATABASE IF EXISTS `CommentsDB`;

DROP TABLE IF EXISTS `CommentsDB`.`ActiveUsers`;
DROP TABLE IF EXISTS `CommentsDB`.`Email`;
DROP TABLE IF EXISTS `CommentsDB`.`Comments`;

CREATE DATABASE `CommentsDB`;
USE `CommentsDB`;

CREATE TABLE `ActiveUsers` (
  `IP` varchar(45) NOT NULL default '',
  `TimeStamp` int(10) unsigned NOT NULL default '0',
  PRIMARY KEY  (`IP`),
  KEY `TimeStamp` (`TimeStamp`)
) TYPE=INNODB COMMENT='Used to determine number of active users';

CREATE TABLE `Comments` (
  `ID` int(11) NOT NULL auto_increment,
  `Name` varchar(32) NOT NULL default '',
  `Comment` varchar(255) NOT NULL default '',
  `TimeStamp` timestamp(14) NOT NULL,
  `IP` varchar(24) NOT NULL default '',
  `State` char(1) NOT NULL default 'A',
  PRIMARY KEY  (`ID`),
  KEY `TimeStamp` (`TimeStamp`)
) TYPE=INNODB COMMENT='Gmail Comments';

CREATE TABLE `Email` (
  `ID` int(11) NOT NULL auto_increment,
  `CID` int(11) NOT NULL default '0',
  `Email` varchar(96) NOT NULL default '',
  PRIMARY KEY  (`ID`),
  KEY `CID` (`CID`),
  FOREIGN KEY (`CID`) REFERENCES Comments(`ID`) ON DELETE CASCADE
) TYPE=INNODB COMMENT='Poster''s email address (optional)';
                
            

PHP server-side script:

This PHP script uses DBLayer.php (see below) as the DB abstraction layer. The DB related code gets included via the include_once statements at the top of the file. You can implement a different abstraction layer. Just change the function query($sql) to implement your persistance model. This file's name is: Comments.php.

                
<?php
/*===================================================================*

  MIT License
  ===========

  Copyright (c) 2005 Jerry Anders.  All rights reserved.

  Permission is hereby granted, free of charge, to any person
  obtaining a copy of this software and associated documentation files
  (the "Software"), to deal in the Software without restriction,
  including without limitation the rights to use, copy, modify, merge,
  publish, distribute, sublicense, and/or sell copies of the Software,
  and to permit persons to whom the Software is furnished to do so,
  subject to the following conditions:

  The above copyright notice and this permission notice shall be
  included in all copies or substantial portions of the Software.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  SOFTWARE.

*===================================================================*/

/**
 * This is the Live Comments server-side script. It handles posting of
 * new comments and also getting comments to display in the web
 * browser. It always returns an xml string of the form:
 * 
 *    "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
 *      "<results post=\"False\" version=\"2.0.1\" ts=\"1150347196\" numUsers=\"7\">
 *        "<comment>"
 *          "<auth>joe</auth>"
 *          "<email>joe@gmail.net</email>"
 *          "<text>Four score and seven years ago...</text>"
 *          "<ts>1137376197</ts>"
 *        "</comment>"
 *        "<comment>"
 *          "<auth>betty</auth>"
 *          "<email>betty@gmail.net</email>"
 *          "<text>Fe Fi Fo Fum...</text>"
 *          "<ts>1137375705</ts>"
 *        "</comment>"
 *        ...
 *      "</results>";
 *
 * Send comments to: janders (@) gmail (.) net
 *
 */

// Find base file path on the server
@define('DIR', dirname(__FILE__) . '/../inc');

// get DB related code
include_once DIR . '/Constants.php';
include_once DIR . '/DBLayer.php';

// process AJAX post/request (xmlhttprequest)
$method = $_POST['method'];
$auth = ($_POST['auth']) ? trim($_POST['auth']) : "Anonymous";
$email = ($_POST['email']) ? strtolower(substr(trim($_POST['email']), 0, 96)) : "";
$comment = ($_POST['comment']) ? trim($_POST['comment']) : "";
$ts = ($_POST['ts']) ? $_POST['ts'] : (($_GET['ts']) ? $_GET['ts'] : 0);
$ip = substr($_SERVER['REMOTE_ADDR'],0, 24);

// changed code to only open one connection, pass $DB as global var.
$DB = new DB;
$DB->connect();

switch ($method) {

 case "post":
    $post=1;
     if (!empty($comment) && validatePoster()) {
         postComment();
         $xml = getComments();
     } else {
         $numUsers = calcActiveUsers();
         $error = True;
         $xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?><results post=\"True\" version=\"" . VERSION_NO . "\" ts=\"$ts\" numUsers=\"$numUsers\" error=\"$error\"/>";
     }
     break;

 case "get":
 default:
    $post=0;
    $xml = getComments();
    break;
}

$DB->close();

// http header sent back to browser
header("Pragma: public");
header("Expires: 0");
header("Cache-Control: must-revalidate, post-check=0, pre-check=0"); 
header("Content-Type: text/xml"); // very important for xmlhttp so that we can use responseXML in JavaScript

// send xml string back to browser
print($xml);
exit;

// script functions

// really simple submit validation...
// if same user has posted 10 comments in 
// a row, reject them until someone else posts a comment
function validatePoster()
{
    global $DB, $ip;

    $sql = "SELECT IP FROM Comments WHERE 1 ORDER BY TimeStamp DESC LIMIT 10";
    $DB->query($sql);

    if ($DB->rs === false) return false;
    $result = $DB->rsArray;

    if (!empty($result) && count($result) == LIMIT_REPEAT_POSTS) {
      foreach ($result as $row) {
        if ($ip != $row["IP"]) return true;
      }
      return false;
    }
    // must be an empty db
    return true;
}

// use ip to approximate number of active users -- using cookies would
// be more accurate, since ip can be masked by proxy servers
function calcActiveUsers()
{
    global $DB, $ip;

    $now = time(); // now

    $sql = "SELECT * FROM ActiveUsers WHERE IP = '$ip'";
    $DB->query($sql);

    if ($DB->rs === false || empty($DB->rsArray)) {
        // this must be a new user, add to ActiveUsers 
        // to track number of users viewing the web page
        $sql = "INSERT INTO ActiveUsers (IP, TimeStamp) VALUES('$ip', '$now')";
    } else {
        // update users timestamp
        $sql = "UPDATE ActiveUsers SET TimeStamp = '$now' WHERE IP = '$ip' ";
    }
    $DB->query($sql);

    // flush old users... if user hasn't hit the server 
    // in the last minute, consider that they are no 
    // longer looking at Live Comments
    $et = time() - 60; // elapsed time: now - 1 minute
    $sql = "DELETE FROM ActiveUsers WHERE TimeStamp < $et";
    $DB->query($sql);

    $sql = "SELECT * FROM ActiveUsers WHERE 1";
    $DB->query($sql);

    if ($DB->rs === false) {
        return 0;
    } else {
        return count($DB->rsArray);
    }
}

// retrieve last 100 comments from CommentsDB
function getComments()
{
    global $DB, $ts, $ip, $post;

    $numUsers = calcActiveUsers();

    // as the DB grows over time, keep things running fast on
    // initial page load by grabbing only one week's worth of data
    // note: 'YmdHis' is the MySQL 4            timestamp format -- e.g., 20051206152942
    // note: 'Y-m-d H:i:s' is the MySQL 4.1, 5+ timestamp format -- e.g., 2005-12-06 15:29:42
    // so use UNIX_TIMESTAMP to do comparison
    //$tt = date('YmdHis',strtotime('-1 weeks'));
    $tt = strtotime('-1 weeks'); // looks like: 1144919156

    // Test timestamp before doing full query to take load off of DB
    /* see EXPERIMENTAL cache code based on file mod timestamp instead of using this DB call
    $sql = "SELECT C.ID, UNIX_TIMESTAMP(C.TimeStamp) AS TS 
            FROM Comments AS C
            ORDER BY C.ID DESC LIMIT 1";
    $DB->query($sql);
    $result = $DB->rsArray;

    $cts = 0;
    if ($DB->rs !== false || !empty($result)) $cts = $result[0]['TS'];
    */

    /* EXPERIMENTAL -- requires cts.cache file in www dir, also see postComment func */
    // much simpler cache technique not involving the DB; see postComment() function below
    $cts = 0;
    if (file_exists("cts.cache")) $cts = filemtime("cts.cache");

    if ($cts > $ts) {

      $sql = "SELECT C.ID, C.Name, E.Email, C.Comment, UNIX_TIMESTAMP(C.TimeStamp) AS TS
              FROM Comments AS C
              LEFT JOIN Email AS E ON E.CID = C.ID
              WHERE C.State = 'A' AND UNIX_TIMESTAMP(C.TimeStamp) > $tt AND UNIX_TIMESTAMP(C.TimeStamp) > $ts
              ORDER BY C.TimeStamp DESC LIMIT 100";
      $DB->query($sql);
      $result = $DB->rsArray;

      // timestamp of latest post
      $ts = $result[0]["TS"];

      $xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>" .
             "<results post=\"$post\" version=\"" . VERSION_NO . "\" ts=\"$ts\" numUsers=\"$numUsers\">";

      foreach ($result as $row) {
        $xml .= "<comment>" .
                "<auth>" . $row["Name"] . "</auth>" .
                "<email>" . $row["Email"] . "</email>" .
                "<text>" . $row["Comment"] . "</text>" .
                "<ts>" . $row["TS"] . "</ts>" .
                "</comment>";
        }

      $xml .= "</results>";

    } else {

      $xml = "<?xml version=\"1.0\" encoding=\"utf-8\"?><results post=\"$post\" version=\"" . VERSION_NO . "\" ts=\"$ts\" numUsers=\"$numUsers\"/>";

    }

    return $xml;
}

// post user comments to DB and store email if submitted
// post comments to Comments table and valid email to Email table
function postComment()
{
    global $DB, $auth, $email, $comment, $ip;

    // make sure field lengths don't exceed max length of DB tables
    // length is already handled by client side JavaScript
    // and there was an issue with PHP adding slashes to single and double quotes
    //$auth = strtolower(substr($auth, 0, 32));
    $auth = strtolower($auth);
    //$comment = substr($comment, 0, 255);

    $sql = "INSERT INTO Comments (Name, Comment, IP) VALUES('$auth', '$comment', '$ip')";
    $DB->query($sql);

    if ($DB->rs === false) {
        return false;
    } else if (validateEmail()) {
        $cid = $DB->getInsertId();
        $sql = "INSERT INTO Email (CID, Email) VALUES('$cid', '$email')";
        $DB->query($sql);
    }

    /* EXPERIMENTAL */
    // used to offload the DB, see getComments func above
    if (!isset($cid)) $cid = $DB->getInsertId();
    $fp = fopen("cts.cache", "w");
    fwrite($fp, $cid);
    fclose($fp);

    return true;
}

// Make sure email is of valid form before saving
// simple email syntax validation
function validateEmail()
{
    global $email;

    // Create the syntactical validation regular expression
    $regexp = "^([_a-z0-9-]+)(\.[_a-z0-9-]+)*@([a-z0-9-]+)(\.[a-z0-9-]+)*(\.[a-z]{2,4})$";

    // Validate the syntax
    if (eregi($regexp, $email)) {
        return true;
    }
   return false;
}
?>
                
            

PHP include file (Contants.php):

Constants.php holds constants and sensitive data for the application. It gets included via the include_once statement at the top of Comments.php. This file's name is: Constants.php.

                
<?php
/*===================================================================*

  MIT License
  ===========

  Copyright (c) 2005 Jerry Anders.  All rights reserved.

  Permission is hereby granted, free of charge, to any person
  obtaining a copy of this software and associated documentation files
  (the "Software"), to deal in the Software without restriction,
  including without limitation the rights to use, copy, modify, merge,
  publish, distribute, sublicense, and/or sell copies of the Software,
  and to permit persons to whom the Software is furnished to do so,
  subject to the following conditions:

  The above copyright notice and this permission notice shall be
  included in all copies or substantial portions of the Software.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  SOFTWARE.

*===================================================================*/

/**
 * This is a place holder for common constants that are used within
 * the application.
 *
 */


// Version ----------------------------------------------------------------

/** @const VERSION_NO The version number as displayed on the UI.   */
// The version mnumber can be manually set by changing N.N.N to
// someting like 1.4.3, or it can be set during build process using
// sed or other editor that can edit this file on the fly
define('VERSION_NO', '3.0.0'); // changing this version number on the
                               // server will cause Live Comments
                               // client to refresh the page and
                               // thereby reload Js and Css files 
                               // -- this is cool.


// Directories ------------------------------------------------------------

list($baseDir, $dir) = split("inc", dirname(__FILE__));
/** @const DIR The base directory. */
define('DIR', $baseDir);
/** @const DIR_WWW The base directory to the Web files. */
define('DIR_WWW', $baseDir . 'www');
/** @const DIR_INC The base directory to the Include files. */
define('DIR_INC', $baseDir . 'inc');


// Databases  -------------------------------------------------------------

// Never keep passwords and other sensitive data in a directory accessible 
// by the web server; e.g. /webroot/www.  Make sure that they are in the
// include (inc) directory or other directory below www.
define('DB_PORT', '3306');
define('DB_URL', 'localhost');
define('DB_SOCKET', '/var/lib/mysql/mysql.sock');
define('DB_USER', 'CommentsDB');
define('DB_USER_PWD', 'password');
define('DB_NAME', 'CommentsDB');

// Other  -----------------------------------------------------------------

define('LIMIT_REPEAT_POSTS', '10');
                
            

PHP include file (DBLayer.php):

The DB class found in DBLayer.php also gets included into Comments.php using an include_once statement. This provides and abstract layer to the mysql database. Your implementation may vary. Just implement your own persistance model. This file's name is: DBLayer.php.

                
<?php
/*===================================================================*

  MIT License
  ===========

  Copyright (c) 2005 Jerry Anders.  All rights reserved.

  Permission is hereby granted, free of charge, to any person
  obtaining a copy of this software and associated documentation files
  (the "Software"), to deal in the Software without restriction,
  including without limitation the rights to use, copy, modify, merge,
  publish, distribute, sublicense, and/or sell copies of the Software,
  and to permit persons to whom the Software is furnished to do so,
  subject to the following conditions:

  The above copyright notice and this permission notice shall be
  included in all copies or substantial portions of the Software.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  SOFTWARE.

*===================================================================*/

/**
 *  Database abstraction layer
 */

class DB {

    // public vars
    
    // default setting (see Constants.php)
    var $url = DB_URL;
    var $port = DB_PORT;
    var $socket = DB_SOCKET;
    var $userName = DB_USER;
    var $userPwd = DB_USER_PWD;
    var $dbName = DB_NAME;

    // holds query results (record set)
    var $rs = false;

    // rs as an array
    var $rsArray = Array();


    // private vars

    // DB link ID
    var $_id;
    

    // Constructor
    function DB () {}
    
    function connect()
    {
        // Connect to MySQL
        $hostURL = $this->url . ":" . $this->port . ":" . $this->socket;
        $this->_id = mysql_pconnect($hostURL, $this->userName, $this->userPwd);
        if (!$this->_id) {
            die("Could not connect: " . mysql_error());
        }
        
        // Select assigned DB
        if (!mysql_select_db($this->dbName, $this->_id)) {
            die("Could not connect to DB: " . mysql_error());
        }
    }
    
    // Close connection
    function disconnect ()
    {
        if (!mysql_close($this->_id)) {
            die("Could not close DB");
        }
    }
    
    // pseudonym for disconnect
    function close()
    {
        $this->disconnect();
    }

    // Query db
    function query ($sql)
    {
        $this->rs = mysql_query($sql);
        
        $this->rsArray = ''; // clear old values
        while ($row = @mysql_fetch_assoc($this->rs)) {
            $this->rsArray[] = $row;
        }
        
        return $this->rs;
    }
    
    // get last inserted id
    function getInsertId()
    {
        return mysql_insert_id($this->_id);
    }
    
    // get number of rows for results
    function getNumRows()
    {
        mysql_num_rows($this->result);
    }
}

?>
                
            

JavaScript:

This is the client-side script that implements the Comments application using AJAX. This file's name is: comments.js.

                
/*===================================================================*

  MIT License
  ===========

  Copyright (c) 2005 Jerry Anders.  All rights reserved.

  Permission is hereby granted, free of charge, to any person
  obtaining a copy of this software and associated documentation files
  (the "Software"), to deal in the Software without restriction,
  including without limitation the rights to use, copy, modify, merge,
  publish, distribute, sublicense, and/or sell copies of the Software,
  and to permit persons to whom the Software is furnished to do so,
  subject to the following conditions:

  The above copyright notice and this permission notice shall be
  included in all copies or substantial portions of the Software.

  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
  NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
  BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
  ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  SOFTWARE.

*===================================================================*/

/**
 * xhr namespace
 */
if (typeof(xhr) == "undefined") xhr = {};


/**
 * This class is used to manage page comments using AJAX (asynchronous
 * javascript and xml). There are also a set of helper functions that
 * manage instantiation and page related html to create the dynamic
 * look-and-feel.
 *
 * Send comments to: janders (@) gmail (.) net
 * 
 */
xhr.Comments = function()
{

    // public vars

    this.interval = 30000;      // get interval (30 seconds)
    this.mininterval = 10000;   // 10 seconds to conserve bandwidth and to make 
                                // sure works on highly latent network connections
                                // -- i.e., smaller intervals may not work
    this.maxinterval = 30000;   // 30000 = 30 seconds
    this.timeout;               // var to hold timeout, so timeout can be cleared
    this.url = "Comments.php";  // pointer to server page to process AJAX request
    this.divComments;           // holds entire comments html string
    this.comment;

    // private global vars used by class methods

    var xmlhttp;           // holds xmlhttprequest object
    var url;               // holds this.url
    var ts = 0;            // timestamp sent back from server
    var version;           // LC version sent back from server
    var div;               // to get the callback to work need this for mozilla, et al.
    var divUsers;          // div to display number of active users
    var nUsers = 0;        // number of active users
    var auth;              // temp placeholder for inputAuth value
    var email;             // temp placeholder for inputEmail value
    var comment;           // temp placeholder for textarea value
    var inputAuth;         // author html input field 
    var inputEmail;        // email html input field
    var textareaComment;   // comment textarea
    var inputCounter;      // counter html input field

    // set the xmlhttprequest object
    if (window.XMLHttpRequest) {
        try {
            xmlhttp = new XMLHttpRequest();
        } catch (e) {
            xmlhttp = false;
        }
    } else if (window.ActiveXObject) {
        try {
            xmlhttp = new ActiveXObject("Msxml2.XMLHTTP");
        } catch (e) {
            try {
                xmlhttp = new ActiveXObject("Microsoft.XMLHTTP");
            } catch (e) {
                xmlhttp = false;
            }
        }
    }


    // privileged methods


    // is this browser capable of xmlHttpRequest?
    this.isBrowserXmlHttpCapable = function()
    {
        return (xmlhttp) ? true : false;
    }


    // set div where comments are to be displayed
    this.setCommentsDiv = function(d)
    {

        if (typeof(d) == "undefined") {
            d = "comments";
        }

        this.divComments = document.getElementById(d);

        if (!xmlhttp) {
            return false;
        } else if (this.divComments) {
            this.divComments.innerHTML =  this.innerHTML;     // set div contents
            this.divComments.className = "visible";           // make comments UI visible if divComments exists

            // set vars used by these functions
            inputAuth = document.getElementById("name");
            inputEmail = document.getElementById("email");
            textareaComment = document.getElementById("ctext");
            inputCounter = document.getElementById("count");

            // div where comments are to be displayed
            div = document.getElementById("entries");
            // div where number of active users are to be displayed
            divUsers = document.getElementById("users");
        } else {
            alert('Comments class requires that \n\n   <div id="comments" class="invisible"></div>\n\n be defined in the HTML file.');
        }
    }


    // Get comments from the server
    this.getComments = function()
    {
        url = this.url;
        _sendRequest("get");
    }


    // Post comments to server
    this.postComment = function()
    {
        if (!xmlhttp) return;

        // set params for posting comments -- read that escape is depricated
        auth = escape(_validChars(inputAuth.value, 32));
        email = escape(_validChars(inputEmail.value, 96));
        if (this.comment) {
            comment = escape(_validChars(this.comment, 255));
            this.comment = "";
        } else {
            comment = escape(_validChars(textareaComment.value, 255));
        }
        // leave name and email intact, clear text, reset counter and refocus on textarea for next comment
        textareaComment.value = "";
        inputCounter.value = 255;
        textareaComment.focus(); // $todo: doesn't work in Safari if user tabbed from email field into the textarea field. 

        // if comment contains text then post it, otherwise do nothing
        url = this.url;
        if (comment) _sendRequest("post");
    }



    // private methods 


    var _sendRequest = function(m)
    {
        xmlhttp.abort(); // this seems to fix uncaught exception in firefox
        xmlhttp.open("POST", url, true);
        xmlhttp.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
        xmlhttp.setRequestHeader("Accept","text/xml");
        xmlhttp.onreadystatechange = _commentCallBack;

        switch (m) {
            case "post":
                xmlhttp.send("method=post&ts="+ts+"&auth="+auth+"&email="+email+"&comment="+comment);
                break;
            case "get":
            default:
                xmlhttp.send("method=get&ts="+ts);
                break;
        }
    }

    // Server results are processed here.
    var _commentCallBack = function()
    {
        if (xmlhttp.readyState == 4 && xmlhttp.status == 200) {
            var comment = "";
            var xml = xmlhttp.responseXML;

            if (xml != null && xml.getElementsByTagName("results").item(0) != null) {

                var a = xml.getElementsByTagName("results").item(0);


                // display version, if new version cause page to
                // reload which, as a side effect, will reload the
                // latest Js and Css files on to the client -- see
                // Constants.php for version number

                if (typeof(version) == "undefined") {
                    version = a.getAttribute("version");
                    document.getElementById('version').innerHTML = a.getAttribute("version");
                // do not reload if version is same or if user has typed content in to textarea
                } else if (version != a.getAttribute("version") && !textareaComment.value) {
                    location.reload(true);
                }

                // update timestamp
                ts = a.getAttribute("ts");

                // set number of active users viewing this page -- for visual effect add delay
                if (nUsers != a.getAttribute("numUsers")) {
                    nUsers = a.getAttribute("numUsers");
                    _updateActiveUsers(nUsers);
                }

                var comments = xml.getElementsByTagName("comment");
                var html = _updateCommentList(comments);
                if (html) {
                    div.innerHTML = html + div.innerHTML;
                }

                // Alert user to max posts 
                if (a.getAttribute("error")) {
                    alert("You can only post 10 comments in a row.\n\nYou will be able to post again, once another\nuser posts a comment.");
                }
            }
        }
    }


    // Update the display of active users
    var _updateActiveUsers = function(n)
    {
        divUsers.innerHTML = "&nbsp;";
        if (n <= 1) {
           str = "Currently you are the only viewer...";
        } else {
           str = "Currently&nbsp;" + n + "&nbsp;people are viewing this page...";
        }
        setTimeout(function(){divUsers.innerHTML = str},1000);
    }


    // creates html containing comments
    var _updateCommentList = function(comments)
    {
        var comment = "";
        for (i=0; i<comments.length; i++) {
            var auth = "anonymous"                               // $todo: need to figure out how auth can get set to a null string on post, in the meantime this fixes the problem
            var email = "";
            var n = comments.item(i).childNodes;
            if (n.item(0).childNodes.item(0)) {
                auth = n.item(0).childNodes.item(0).nodeValue;   // $todo: need to figure out how auth can get set to a null string on post, in the meantime this fixes the problem
            }
            if (n.item(1).childNodes.item(0)) {
                email = n.item(1).childNodes.item(0).nodeValue;
            }
            var text = n.item(2).childNodes.item(0).nodeValue;
            var timestamp = n.item(3).childNodes.item(0).nodeValue;
            var d = new Date(timestamp*1000); //* 1000 to convert to milliseconds
            var date = d.getMonth()+1 + "/" + d.getDate() + "/" + d.getFullYear();
            var time = d.toLocaleTimeString();

            comment += 
                "<div class='by'>" +
                " <span class='auth small'>" + auth + "</span>" +
                " <span class='date small'>" + date + "</span>" +
                " <span class='date small'>" + time + "</span>" +
                "</div>";

            if (email) {
                comment += "<div class='eml small'>" + _obfuscateEmail(email) + "</div>";
            }

            comment += "<div><div class='para'>" + text + "</div>";

            comment += "</div>";
        }
        return comment;
    }


    // returns string of only valid characters
    var _validChars = function(str, l)
    {
        // default legal character set is alphanumeric + symbols (note first char is a space)
        var validChars = " abcdefghijklmnopqrstuvwxyz0123456789~`!@#$%^&*()_-+=[]{}\\|;:'\",.<>/?";

        //remove leading and trailing whitespace
        str = str.replace(/^\s+/g, '').replace(/\s+$/g, '');

        //convert carriage returns and newlines entered into textarea to spaces
        str = str.replace(/\r\n|\n/g, ' ');

        // replace <, >, and &
        str = str.replace(/</g, "&lt;");
        str = str.replace(/>/g, "&gt;");
        str = str.replace(/&/g, "&amp;");

       var valid = "";
        for (var i=0; i < str.length; i++) {
            var c = str.charAt(i);
            var s = c.toLowerCase();
            if (validChars.indexOf(s) == -1) continue;
            valid += c;
        }

        // Escapes single quote, double quotes and backslash characters in a string with backslashes
        //valid = (valid + '').replace(/[\\"']/g, '\\$&').replace(/\u0000/g, '\\0');

        if (typeof(l) != "undefined") {
            valid = valid.substr(0, l);

            // there is the possibility that once we trim the string we
            // may have chopped off part of &amp; to create something
            // like &am

            // e.g. a name that was entered as <b>abc</b> and is limited
            // to 32 characters would break things because it would get
            // stored as: 
            //    &amp;lt;b&amp;gt;abc&amp;lt;/b&a
            // i.e., the &a on the end breaks things.

            // so let's check for and fix such situations

            valid = valid.replace(/(&$|&a$|&am$|&amp$)/, '');
        }

        return valid;
    }


    // obfuscate user's email address for display
    // note: validation is handled by server-side php code on submit
    var _obfuscateEmail = function(e)
    {
        var m = e.match(/@/g);
        if (m == null || m.length != 1) return false;

        var r1 = Math.round(Math.random()*3); // random number between 0 and 3
        var r2 = Math.round(Math.random()*1); // random number between 0 and 1
        var r3 = Math.round(Math.random()*1); // random number between 0 and 1

        switch (r1) {
        case 0:
          e = (r2) ? e.replace(/@/, " (@) ") : e.replace(/@/, " (at) ");
          e = (r3) ? e.replace(/\./g, " (.) ") : e.replace(/\./g, " (dot) ");
          break;
 
        case 1:
          e = (r2) ? e.replace(/@/, " {@} ") : e.replace(/@/, " {at} ");
          e = (r3) ? e.replace(/\./g, " {.} ") : e.replace(/\./g, " {dot} ");
          break;

        case 2:
          e = (r2) ? e.replace(/@/, " [@] ") : e.replace(/@/, " [at] ");
          e = (r3) ? e.replace(/\./g, " [.] ") : e.replace(/\./g, " [dot] ");
          break;

        case 3:
          e = (r2) ? e.replace(/@/, " @ ") : e.replace(/@/, " at ");
          e = (r3) ? e.replace(/\./g, " . ") : e.replace(/\./g, " dot ");
          break;
        }

        return e;
    }
}

xhr.Comments.prototype =
{

    // public vars and methods


    innerHTML: 
        "<div id='lc'>" +
        " <div id='titleCap'><span id='title'>Live Comments</span><span id='version'></span></div>" +
        " <div class='cap'>" +
        "  <span>Name&nbsp;<span class='xx-small'>(optional)</span>:</span>&nbsp;<input tabindex='1' id='name' class='name' maxlength='16' onmouseover=\"this.className='namef'\" onfocus=\"this.className='namef';this.infocus=true\" onmouseout=\"if(!this.infocus)this.className='name'\" onblur=\"this.className='name';this.infocus=false\">&nbsp;" +
        " </div>" +
        " <div class='cap'>" +
        "  <span>Email&nbsp;&nbsp;<span class='xx-small'>(optional)</span>:</span>&nbsp;<input tabindex='2' id='email' class='email' maxlength='96' onmouseover=\"this.className='emailf'\" onfocus=\"this.className='emailf';this.infocus=true\" onmouseout=\"if(!this.infocus)this.className='email'\" onblur=\"this.className='email';this.infocus=false\">" +
        " </div>" +
        " <div class='cap'>" +
        "  <span>Comment&nbsp;(plain text only)<span id='counter'><input id='count' disabled='disabled' value='255' maxlength='3'/><span class='xx-small'>&nbsp;characters left</span></span>:</span>" +
        " </div>" +
        " <div>" +
        "  <textarea tabindex='3' id='ctext' class='ctext' onKeyDown=\"xhr.Comments.prototype.limitText(this, document.getElementById('count'), 255)\" onKeyUp=\"xhr.Comments.prototype.limitText(this, document.getElementById('count'), 255)\"  onmouseover=\"this.className='ctextf'\" onfocus=\"this.className='ctextf';this.infocus=true\" onmouseout=\"if(!this.infocus)className='ctext'\" onblur=\"className='ctext';this.infocus=false\"></textarea>" +
        " </div>" +
        " <div id='submit'>" +
        "  <div style='float:right'>" +
        "   <input tabindex='4' type='submit' value='submit' class='x-small' title='Post your comment' onclick='submitComment()'/>" +
        "  </div>" +
        " </div>" +
        " <div id='users'>&nbsp;</div>" +
        " <div id='entries'></div>" +
        " <span id='so'>&nbsp;</span>" +
        "</div>",


    // every object has a toString, so what is this class?
    toString:function()
    {
        return "Class to manage page comments using AJAX.";
    },


    // limit number of characters that can be typed in comment textarea
    limitText:function(textField, limitField, limit)
    {
        if (textField.value.length > limit) {
            textField.value = textField.value.substring(0, limit);
        } else {
            limitField.value = limit - textField.value.length;
        }
    },


    // special UI expand routine (more/less)
    more:function(o)
    {
        o.blur();
        o.firstChild.src = "CG/bl.gif";
        o.title = "less";
        var self = this;
        o.onclick = function() {self.less(this)};
        var p = o.parentNode;
        p.childNodes[0].className = "invisible";
        p.childNodes[1].className = "visible";
    },


    // reset more
    less:function(o)
    {
        o.blur();
        o.firstChild.src = "CG/br.gif";
        o.title = "more";
        var self = this;
        o.onclick = function() {self.more(this)};
        var p = o.parentNode;
        p.childNodes[0].className = "visible";
        p.childNodes[1].className = "invisible";
    }

}


/**
 * General helper functions that use Comments class.
 *
 * @author janders
 * 
 */

// initalizes Comments class and sets instance and global vars
function initComments()
{
    _c_ = new xhr.Comments;
    _c_.setCommentsDiv();
    if (_c_.divComments && _c_.isBrowserXmlHttpCapable()) {
        _c_.interval = _c_.mininterval; // override default 60 second interval and set to 10 seconds
        getComments();        // start recursive loop watching for new comments
    } else if (_c_.divComments) {
        _c_.divComments.className = "visible";
    }
}


// loop forever getting any new comments on server
function getComments()
{
    _c_.getComments(); // use AJAX to get comments on server
    // add growth function so that polling slows down over time
    if (_c_.interval < _c_.maxinterval) _c_.interval = _c_.interval * Math.pow(3, (0.00001 * _c_.interval));
    if (_c_.interval > _c_.maxinterval) _c_.interval = _c_.maxinterval;
    _c_.timeout = setTimeout(getComments,_c_.interval); // recursive loop
}


// submit comment using AJAX
function submitComment()
{
    _c_.postComment(); // use AJAX to post comment to server
    // reset interval
    _c_.interval = _c_.mininterval;
    // clear current timeout for getComments
    clearTimeout(_c_.timeout);
    // restart getComments
    setTimeout(getComments,_c_.interval); // add delay otherwise this will cancel postComment
}



/**
 * General helper functions that manage UI.
 *
 * @author janders
 * 
 */
// simple, fun rating system
function rateit(o)
{
    o.blur();
    var rating = null;
    var p = o.parentNode;
    var r = p.getElementsByTagName("input");
    for (var i=0; i<r.length; i++) {
        if (r.item(i).checked) {
            rating = r.item(i).parentNode.title;
            r.item(i).checked = false;
            break;
        }
    }
    if (rating == null) return; // nothing selected
    _c_.comment = rating;
    _c_.postComment();
    document.getElementById("rating").style.display = "none";
}
                
            

CSS:

This is the CSS used to format Comments layout and style. This file's name is: Comments.css.

                
/* CSS for Live Comments */

/* container for live comments */
#lc {
  margin:0;
  padding:0px 12px 2px 12px;
  width:400px; /* change this width to change app's width */
  font-family: 'Lucidia Grande', Verdana, Arial, Sans-Serif;
  color:#001650;
}

/* general settings for form fields */ 
#lc input, #lc textarea {
  margin:0;
  padding:0;
  cursor:pointer;
  color:#001650;
}

/* Live Comments title and version*/
#lc #titleCap {
  color:#5066a0;
}

#lc #titleCap #title {
  padding:0 0 8px 0;
  font-size:large;
  letter-spacing:6px;
  font-weight:bold;
}

/* Live Comments version */
#lc #titleCap #version {
  padding:0 0 0 8px;
  vertical-align:top;
  font-size:xx-small;
}

/* form field captions */
#lc .cap {
  padding:8px 0 0 0;
  font-size:x-small;
}

/* name form input field out of focus (i.e. onblur) */
#lc .name {
  padding:1px;
  width:143px;
  height:12px;
  font-size:x-small;
  border-width:0 0 1px 0;
  border-style:solid;
  border-color:#5066a0;
  background-color:transparent;
}

/* name form input field in focus (i.e. onfocus) */
#lc .namef {
  padding:1px;
  width:143px;
  height:12px;
  font-size:x-small;
  border-width:0 0 1px 0;
  border-style:solid;
  border-color:#d28a2c;
  background-color:#ffffff;
}

/* email form input field out of focus */
#lc .email {
  padding:1px;
  width:225px;
  height:12px;
  font-size:x-small;
  border-width:0 0 1px 0;
  border-style:solid;
  border-color:#5066a0;
  background-color:transparent;
}

/* email form input field in focus */
#lc .emailf {
  padding:1px;
  width:225px;
  height:12px;
  font-size:x-small;
  border-width:0 0 1px 0;
  border-style:solid;
  border-color:#d28a2c;
  background-color:#ffffff;
}

/* comment form textarea out of focus */
#lc .ctext {
  margin:4px 0 4px 0;
  padding:1px;
  width:100%;
  height:65px;
  font-size:x-small;
  border-width:1px;
  border-style:solid;
  border-color:#5066a0;
  background-color:#c5ee62;
  background-color:transparent;
}

/* comment form textarea in focus */
#lc .ctextf {
  margin:4px 0 4px 0;
  padding:1px;
  width:100%;
  height:65px;
  font-size:x-small;
  border-width:1px;
  border-style:solid;
  border-color:#d28a2c;
  background-color:#ffffff;
}

/* counter text below comment textarea */
#lc #counter {
  margin:0 0 0 0;
  font-size:x-small;
}

#lc #count {
  padding:0 0 2px 0;
  width:30px;
  font-size:x-small;
  text-align:right;
  border-width:0;
  background-color:transparent;
}

/* submit link below comment textarea */
#lc #submit {
  margin:2px 2px 40px 2px;
  text-align:right;
  font-size:x-small;
  cursor:pointer;
}

/* active users */
#lc #users {
  margin:0 0 2px 0;
  width:100%;
  font-size:x-small;
  letter-spacing:2px;
}

/* entries -- where user comments are displayed */
#lc #entries {
  padding:12px 2px 2px 2px;
  width:100%;
  font-size:medium;
  overflow:hidden;
  color:#5066a0;
  border-width:3px 0 0 0;
  border-style:solid;
  border-color:#5066a0;
}

/* more and less icons */
#lc #entries img {
  width:14px;
  height:9px;
  border:0px;
}

/* comment */
#lc #entries .para {
  margin:0 3px 0 0;
  padding:0 0 36px 0;
  border-width:1px 0 0 0;
  border-style:dashed;
  border-color:#5066a0;
}

/* the by line, including auth and date */
#lc #entries .by {
  margin:0 3px 0 0;
  font-family: arial;
}

/* who wrote the comment */
#lc #entries .auth {
  margin:0 4px 0 0;
  color:#d28a2c;
}

/* comment date listed with author */
#lc #entries .date {
}

/* email line if present */
#lc #entries .eml {
  margin:0 3px 2px 0;
}


/* general css for comments */

#lc a {
  cursor:pointer;
  color:#5066a0;
  border-width:0;
}

#lc a:hover {
  cursor:pointer;
  color:#d28a2c;
}

#lc a:visited {
  cursor:pointer;
}

#lc .pointer { 
  cursor:pointer;
}

#lc .small { 
  font-size:small;
}

#lc .x-small { 
  font-size:x-small;
}

#lc .xx-small { 
  font-size:xx-small;
}

#lc .medium { 
  font-size:medium;
}

#lc .large { 
  font-size:large;
}

#lc .x-large { 
  font-size:x-large;
}

#lc .xx-large { 
  font-size:xx-large;
}

#lc .graycol {
  color:#bebebe;
}

#lc .darkgraycol {
  color:#666666;
}

#lc .hlc {
  color:#d28a2c;
}

#lc .invisible {
  display:none;
  visibility:hidden;
}

#lc .visible {
  display:inline;
  visibility:visible;
}
                
            

Images:

These are the images used as the more / less icons as defined in comments.js. To download images, click right mouse button and select to save image file.

moreright

lessleft

Example:

Now let's pull it all together. Following is a template html file that you can use as an example. This file's name is: Example.html.

                
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
    <head>
        <title>Gmail dot Net: AJAX Example</title>
        <!-- Changed by: Jerry Anders, 11-Feb-2006 -->
        <meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
        <meta name="description" content="Live Comments: An example AJAX application by Jerry Anders">
        <meta name="keywords" content="Jerry Anders, live comments, comments, css, javascript, xhtml, xml, ajax, ajax application, web-based">
        <link rel="stylesheet" href="Css/Example.css"></link>
        <link rel="stylesheet" href="Css/Comments.css"></link>
        <script type="text/javascript" src="Js/comments.js"></script>
    </head>
    <script>
        // Disable comments so that they do not work on this page.
        // To do so we need to override getComments and submitComments
        // which are helper functions defined in comments.js.

        // To use this file as a template file for your own 
        // application, remove this <script ... /script> 
        // section from this page.

        function getComments()
        {
            document.getElementById("entries").innerHTML = "Comments are disabled on this page.";
        }
        function submitComment()
        {
            // do nothing
        }
    </script>

    <body onload="initComments()">
        <!-- Since this is using the same css as gmail.net, I 
             need to override page width as set in WebSite.css
             to get 3 columns to fit in the page.
        -->
        <div id="page" style="width:812px"> 

            <div class="b hlcol x-large ls5">EXAMPLE</div>

            <!-- left column -->
            <div id="left">

                <div class="sb">
                    <div class="b hlcol small ls3">FAQ:</div>
                    <br>
                    <b>Q:</b> Can I use this as a template for my site?
                    <br>
                    <b>A:</b> Yes.
                    <br><br><br>

                    <b>Q:</b> Does this application work for all browsers?
                    <br>
                    <b>A:</b> It does for all modern browsers.
                    <br><br><br>

                    <b>Q:</b> Where do I get the software?
                    <br>
                    <b>A:</b> <a href="http://www.gmail.net/GetSource.php">Live Comments</a>.
                    <br><br><br>

                    <b>Q:</b> Do I need anything else?
                    <br>
                    <b>A:</b> No, but if you want to use this example
                    file you will need 
                    <a href="http://www.gmail.net/Css/Example.css">Example.css</a>.
                    This Css file is not published on Live Comments
                    Source page but is required for Example.html's
                    layout.

                </div>

                <div class="sb">
                    <div class="b hlcol small ls3">LEFT 2:</div>
                    <br>
                    Add your text here to create another column entry.
                </div>

            </div>

            <!-- center column -->
            <div id="center">
                <div class="sb">
                    <!-- comments application populates this div -->
                    <div id="comments" class="invisible">
                        <!-- this gets replaced by live comments for browsers that support xmlhttprequest -->
                        <div>If you see this, your Browser is old! Upgrade to a new browser that supports XMLHttpRequest.</div>
                    </div>
                </div>
            </div>

            <!-- right column -->
            <div id="right">

                <div class="sb">
                    <div class="b hlcol small ls3">RIGHT 1:</div>
                    <br>
                    Add your text here to create another column entry.
                    <br>
                    Add your text here to create another column entry.
                    <br>
                    Add your text here to create another column entry.
                </div>

                <div class="sb">
                    <div class="b hlcol small ls3">RIGHT 2:</div>
                    <br>
                    Add your text here to create another column entry.
                </div>

                <div class="sb">
                    <div class="b hlcol small ls3">RIGHT 3:</div>
                    <br>
                    Add your text here to create another column entry.
                </div>

                <div class="sb">
                    <div class="b hlcol small ls3">RIGHT 4:</div>
                    <br>
                    Add your text here to create another column entry.
                </div>

            </div>

            <!-- bottom -->
            <div style="clear:both"></div>
            <div id="footer">
                <span class="darkgraycol">Copyright &copy; 2002-2006 Your Name Here. All rights reserved.</span>
            </div>

        </div>
    </body>
</html>
                
            
Valid CSS! Valid HTML 4.01 Transitional

Last modified: Mon Mar 7 11:13:00 GMT 2011