From f6979c811fa51c46907cd0220fc28242e68324fe Mon Sep 17 00:00:00 2001
From: jrandom <jrandom>
Date: Fri, 11 Nov 2005 03:41:16 +0000
Subject: [PATCH] 2005-11-10  jrandom     * First pass to a new threaded Syndie
 interface, which isn't enabled by       default, as its not done yet.

---
 .../java/src/net/i2p/syndie/Archive.java      |    1 +
 .../src/net/i2p/syndie/ArchiveIndexer.java    |   24 +-
 .../java/src/net/i2p/syndie/BlogManager.java  |    3 +-
 .../src/net/i2p/syndie/HeaderReceiver.java    |   43 +
 .../src/net/i2p/syndie/ThreadNodeImpl.java    |  104 ++
 .../net/i2p/syndie/WritableThreadIndex.java   |  148 +++
 .../src/net/i2p/syndie/data/ArchiveIndex.java |    4 +
 .../java/src/net/i2p/syndie/data/BlogURI.java |    4 +-
 .../i2p/syndie/data/FilteredThreadIndex.java  |   88 ++
 .../src/net/i2p/syndie/data/ThreadIndex.java  |   49 +
 .../src/net/i2p/syndie/data/ThreadNode.java   |   34 +
 .../syndie/data/TransparentArchiveIndex.java  |    1 +
 .../src/net/i2p/syndie/sml/HTMLRenderer.java  |    6 +-
 .../i2p/syndie/web/ViewThreadedServlet.java   | 1053 +++++++++++++++++
 apps/syndie/jsp/images/addToFavorites.png     |  Bin 0 -> 275 bytes
 apps/syndie/jsp/images/addToIgnored.png       |  Bin 0 -> 266 bytes
 apps/syndie/jsp/images/collapse.png           |  Bin 0 -> 917 bytes
 apps/syndie/jsp/images/expand.png             |  Bin 0 -> 922 bytes
 apps/syndie/jsp/images/favorites.png          |  Bin 0 -> 463 bytes
 apps/syndie/jsp/images/noSubthread.png        |  Bin 0 -> 129 bytes
 apps/syndie/jsp/images/threadIndent.png       |  Bin 0 -> 129 bytes
 apps/syndie/jsp/index.html                    |    3 +
 apps/syndie/jsp/switchuser.jsp                |   16 +
 apps/syndie/jsp/web.xml                       |   15 +-
 24 files changed, 1586 insertions(+), 10 deletions(-)
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/HeaderReceiver.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/ThreadNodeImpl.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/WritableThreadIndex.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/data/FilteredThreadIndex.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/data/ThreadIndex.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/data/ThreadNode.java
 create mode 100644 apps/syndie/java/src/net/i2p/syndie/web/ViewThreadedServlet.java
 create mode 100644 apps/syndie/jsp/images/addToFavorites.png
 create mode 100644 apps/syndie/jsp/images/addToIgnored.png
 create mode 100644 apps/syndie/jsp/images/collapse.png
 create mode 100644 apps/syndie/jsp/images/expand.png
 create mode 100644 apps/syndie/jsp/images/favorites.png
 create mode 100644 apps/syndie/jsp/images/noSubthread.png
 create mode 100644 apps/syndie/jsp/images/threadIndent.png
 create mode 100644 apps/syndie/jsp/index.html
 create mode 100644 apps/syndie/jsp/switchuser.jsp

diff --git a/apps/syndie/java/src/net/i2p/syndie/Archive.java b/apps/syndie/java/src/net/i2p/syndie/Archive.java
index 0487cabe1f..22232e3de0 100644
--- a/apps/syndie/java/src/net/i2p/syndie/Archive.java
+++ b/apps/syndie/java/src/net/i2p/syndie/Archive.java
@@ -244,6 +244,7 @@ public class Archive {
     }
     
     public List listEntries(BlogURI uri, String tag, SessionKey blogKey) {
+        if (uri == null) return new ArrayList();
         return listEntries(uri.getKeyHash(), uri.getEntryId(), tag, blogKey);
     }
     public List listEntries(Hash blog, long entryId, String tag, SessionKey blogKey) { 
diff --git a/apps/syndie/java/src/net/i2p/syndie/ArchiveIndexer.java b/apps/syndie/java/src/net/i2p/syndie/ArchiveIndexer.java
index a22b31436c..379a390d8e 100644
--- a/apps/syndie/java/src/net/i2p/syndie/ArchiveIndexer.java
+++ b/apps/syndie/java/src/net/i2p/syndie/ArchiveIndexer.java
@@ -19,6 +19,7 @@ class ArchiveIndexer {
     public static ArchiveIndex index(I2PAppContext ctx, Archive source) {
         Log log = ctx.logManager().getLog(ArchiveIndexer.class);
         LocalArchiveIndex rv = new LocalArchiveIndex(ctx);
+        WritableThreadIndex threads = new WritableThreadIndex();
         rv.setGeneratedOn(ctx.clock().now());
         
         File rootDir = source.getArchiveDir();
@@ -79,6 +80,7 @@ class ArchiveIndexer {
                 allEntries++;
                 totalSize += entry.getCompleteSize();
                 String entryTags[] = entry.getTags();
+                threads.addEntry(entry.getURI(), entryTags);
                 for (int t = 0; t < entryTags.length; t++) {
                     if (!tags.containsKey(entryTags[t])) {
                         tags.put(entryTags[t], new TreeMap());
@@ -98,11 +100,18 @@ class ArchiveIndexer {
                 parser.parse(entry.getEntry().getText(), rec);
                 String reply = rec.getHeader(HTMLRenderer.HEADER_IN_REPLY_TO);
                 if (reply != null) {
-                    BlogURI parent = new BlogURI(reply.trim());
-                    if ( (parent.getKeyHash() != null) && (parent.getEntryId() >= 0) ) 
-                        rv.addReply(parent, entry.getURI());
-                    else if (log.shouldLog(Log.WARN))
-                        log.warn("Parent of " + entry.getURI() + " is not valid: [" + reply.trim() + "]");
+                    String forceNewThread = rec.getHeader(HTMLRenderer.HEADER_FORCE_NEW_THREAD);
+                    if ( (forceNewThread != null) && (Boolean.valueOf(forceNewThread).booleanValue()) ) {
+                        // ignore the parent
+                    } else {
+                        BlogURI parent = new BlogURI(reply.trim());
+                        if ( (parent.getKeyHash() != null) && (parent.getEntryId() >= 0) ) {
+                            rv.addReply(parent, entry.getURI());
+                            threads.addParent(parent, entry.getURI());
+                        } else if (log.shouldLog(Log.WARN)) {
+                            log.warn("Parent of " + entry.getURI() + " is not valid: [" + reply.trim() + "]");
+                        }
+                    }
                 }
             }
             
@@ -150,6 +159,11 @@ class ArchiveIndexer {
             rv.addNewestEntry(uri);
         }
         
+        threads.organizeTree();
+        if (_log.shouldLog(Log.DEBUG))
+            _log.debug("Tree: \n" + threads.toString());
+        rv.setThreadedIndex(threads);
+        
         return rv;
     }
     
diff --git a/apps/syndie/java/src/net/i2p/syndie/BlogManager.java b/apps/syndie/java/src/net/i2p/syndie/BlogManager.java
index 861a248194..5189437c20 100644
--- a/apps/syndie/java/src/net/i2p/syndie/BlogManager.java
+++ b/apps/syndie/java/src/net/i2p/syndie/BlogManager.java
@@ -44,7 +44,8 @@ public class BlogManager {
                     if (rootDir == null)
                         rootDir = "./syndie";
                 }
-                _instance = new BlogManager(I2PAppContext.getGlobalContext(), rootDir);
+                _instance = new BlogManager(I2PAppContext.getGlobalContext(), rootDir, false);
+                _instance.getArchive().regenerateIndex();
             }
             return _instance; 
         }
diff --git a/apps/syndie/java/src/net/i2p/syndie/HeaderReceiver.java b/apps/syndie/java/src/net/i2p/syndie/HeaderReceiver.java
new file mode 100644
index 0000000000..9a9c2aa199
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/HeaderReceiver.java
@@ -0,0 +1,43 @@
+package net.i2p.syndie;
+
+import java.util.*;
+import net.i2p.syndie.sml.SMLParser;
+
+public class HeaderReceiver implements SMLParser.EventReceiver {
+    private Properties _headers;
+    public HeaderReceiver() { _headers = null; }
+    public String getHeader(String name) { return (_headers != null ? _headers.getProperty(name) : null); }
+    public void receiveHeader(String header, String value) { 
+        if (_headers == null) _headers = new Properties();
+        _headers.setProperty(header, value);
+    }
+
+    public void receiveAddress(String name, String schema, String protocol, String location, String anchorText) {}
+    public void receiveArchive(String name, String description, String locationSchema, String location, String postingKey, String anchorText) {}
+    public void receiveAttachment(int id, String anchorText) {}
+    public void receiveBegin() {}
+    public void receiveBlog(String name, String blogKeyHash, String blogPath, long blogEntryId, List blogArchiveLocations, String anchorText) {}
+    public void receiveBold(String text) {}
+    public void receiveCode(String text, String codeLocationSchema, String codeLocation) {}
+    public void receiveCut(String summaryText) {}
+    public void receiveEnd() {}
+    public void receiveGT() {}
+    public void receiveH1(String text) {}
+    public void receiveH2(String text) {}
+    public void receiveH3(String text) {}
+    public void receiveH4(String text) {}
+    public void receiveH5(String text) {}
+    public void receiveHR() {}
+    public void receiveHeaderEnd() {}
+    public void receiveImage(String alternateText, int attachmentId) {}
+    public void receiveItalic(String text) {}
+    public void receiveLT() {}
+    public void receiveLeftBracket() {}
+    public void receiveLink(String schema, String location, String text) {}
+    public void receiveNewline() {}
+    public void receivePlain(String text) {}
+    public void receivePre(String text) {}
+    public void receiveQuote(String text, String whoQuoted, String quoteLocationSchema, String quoteLocation) {}
+    public void receiveRightBracket() {}
+    public void receiveUnderline(String text) {}
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/ThreadNodeImpl.java b/apps/syndie/java/src/net/i2p/syndie/ThreadNodeImpl.java
new file mode 100644
index 0000000000..2c42f688d6
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/ThreadNodeImpl.java
@@ -0,0 +1,104 @@
+package net.i2p.syndie;
+
+import java.util.*;
+import net.i2p.data.Hash;
+import net.i2p.syndie.data.BlogURI;
+import net.i2p.syndie.data.ThreadNode;
+
+/**
+ * Simple memory intensive (but fast) node impl
+ *
+ */
+class ThreadNodeImpl implements ThreadNode {
+    /** write once, never updated once the tree is created */
+    private Collection _recursiveAuthors;
+    /** contains the BlogURI instances */
+    private Collection _recursiveEntries;
+    /** write once, never updated once the tree is created */
+    private List _children;
+    private BlogURI _entry;
+    private ThreadNode _parent;
+    private BlogURI _parentEntry;
+    private Collection _tags;
+    private Collection _recursiveTags;
+    private long _mostRecentPostDate;
+    private Hash _mostRecentPostAuthor;
+    
+    public ThreadNodeImpl() {
+        _recursiveAuthors = new HashSet(1);
+        _recursiveEntries = new HashSet(1);
+        _children = new ArrayList(1);
+        _entry = null;
+        _parent = null;
+        _parentEntry = null;
+        _tags = new HashSet();
+        _recursiveTags = new HashSet();
+        _mostRecentPostDate = -1;
+        _mostRecentPostAuthor = null;
+    }
+    
+    void setEntry(BlogURI entry) { _entry = entry; }
+    void addAuthor(Hash author) { _recursiveAuthors.add(author); }
+    void addChild(ThreadNodeImpl child) { 
+        if (!_children.contains(child))
+            _children.add(child); 
+    }
+    void setParent(ThreadNodeImpl parent) { _parent = parent; }
+    void setParentEntry(BlogURI parent) { _parentEntry = parent; }
+    void addTag(String tag) { 
+        _tags.add(tag); 
+        _recursiveTags.add(tag);
+    }
+    
+    void summarizeThread() {
+        _recursiveAuthors.add(_entry.getKeyHash());
+        _recursiveEntries.add(_entry);
+        _mostRecentPostDate = _entry.getEntryId();
+        _mostRecentPostAuthor = _entry.getKeyHash();
+        
+        // we need to go through all children (recursively), in case the 
+        // tree is out of order (which it shouldn't be, if its built carefully...)
+        for (int i = 0; i < _children.size(); i++) {
+            ThreadNodeImpl node = (ThreadNodeImpl)_children.get(i);
+            node.summarizeThread();
+            if (node.getMostRecentPostDate() > _mostRecentPostDate) {
+                _mostRecentPostDate = node.getMostRecentPostDate();
+                _mostRecentPostAuthor = node.getMostRecentPostAuthor();
+            }
+            _recursiveTags.addAll(node.getRecursiveTags());
+            _recursiveAuthors.addAll(node.getRecursiveAuthors());
+            _recursiveEntries.addAll(node.getRecursiveEntries());
+        }
+    }
+    
+    public String toString() {
+        StringBuffer buf = new StringBuffer();
+        buf.append("<node><entry>").append(getEntry().toString()).append("</entry>\n");
+        buf.append("<tags>").append(getTags()).append("</tags>\n");
+        buf.append("<recursiveTags>").append(getRecursiveTags()).append("</recursiveTags>\n");
+        buf.append("<children>\n");
+        for (int i = 0; i < _children.size(); i++)
+            buf.append(_children.get(i).toString());
+        buf.append("</children>\n");
+        buf.append("</node>\n");
+        return buf.toString();
+    }
+    
+    private Collection getRecursiveAuthors() { return _recursiveAuthors; }
+    private Collection getRecursiveEntries() { return _recursiveEntries; }
+    
+    // interface-specified methods doing what one would expect...
+    public boolean containsAuthor(Hash author) { return _recursiveAuthors.contains(author); }
+    public boolean containsEntry(BlogURI uri) { return _recursiveEntries.contains(uri); }
+    public ThreadNode getChild(int index) { return (ThreadNode)_children.get(index); }
+    public int getChildCount() { return _children.size(); }
+    public BlogURI getEntry() { return _entry; }
+    public ThreadNode getParent() { return _parent; }
+    public BlogURI getParentEntry() { return _parentEntry; }
+    public boolean containsTag(String tag) { return _tags.contains(tag); }
+    public Collection getTags() { return _tags; }
+    public Collection getRecursiveTags() { return _recursiveTags; }
+    public long getMostRecentPostDate() { return _mostRecentPostDate; }
+    public Hash getMostRecentPostAuthor() { return _mostRecentPostAuthor; }
+    public Iterator getRecursiveAuthorIterator() { return _recursiveAuthors.iterator(); }
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/WritableThreadIndex.java b/apps/syndie/java/src/net/i2p/syndie/WritableThreadIndex.java
new file mode 100644
index 0000000000..72c5f5b5ca
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/WritableThreadIndex.java
@@ -0,0 +1,148 @@
+package net.i2p.syndie;
+
+import java.util.*;
+import net.i2p.I2PAppContext;
+import net.i2p.data.DataHelper;
+import net.i2p.data.Hash;
+import net.i2p.syndie.data.*;
+import net.i2p.syndie.sml.SMLParser;
+import net.i2p.syndie.sml.HTMLRenderer;
+
+/**
+ *
+ */
+class WritableThreadIndex extends ThreadIndex {
+    /** map of child (BlogURI) to parent (BlogURI) */
+    private Map _parents;
+    /** map of entry (BlogURI) to tags (String[]) */
+    private Map _tags;
+    private static final String[] NO_TAGS = new String[0];
+    /** b0rk if the thread seems to go too deep */
+    private static final int MAX_THREAD_DEPTH = 64;
+    
+    WritableThreadIndex() {
+        super(); 
+        _parents = new HashMap();
+        _tags = new TreeMap(new NewestEntryFirstComparator());
+    }
+    
+    void addParent(BlogURI parent, BlogURI child) { _parents.put(child, parent); }
+    void addEntry(BlogURI entry, String tags[]) { 
+        if (tags == null) tags = NO_TAGS;
+        String oldTags[] = (String[])_tags.put(entry, tags);
+    }
+    
+    /** 
+     * pull the data added together into threads, and stash them in the 
+     * roots, organized chronologically
+     *
+     */
+    void organizeTree() {
+        Map nodes = new HashMap(_tags.size());
+        for (Iterator iter = _tags.keySet().iterator(); iter.hasNext(); ) {
+            BlogURI entry = (BlogURI)iter.next();
+            String tags[] = (String[])_tags.get(entry);
+            BlogURI parent = (BlogURI)_parents.get(entry);
+            ThreadNodeImpl node = new ThreadNodeImpl();
+            node.setEntry(entry);
+            if (tags != null)
+                for (int i = 0; i < tags.length; i++)
+                    node.addTag(tags[i]);
+            if (parent != null)
+                node.setParentEntry(parent);
+            addEntry(entry, node);
+            nodes.put(entry, node);
+        }
+        
+        SMLParser parser = new SMLParser(I2PAppContext.getGlobalContext());
+        HeaderReceiver rec = new HeaderReceiver();
+        Archive archive = BlogManager.instance().getArchive();
+        
+        TreeSet roots = new TreeSet(new NewestNodeFirstComparator());
+        for (Iterator iter = nodes.keySet().iterator(); iter.hasNext(); ) {
+            BlogURI entry = (BlogURI)iter.next();
+            ThreadNodeImpl node = (ThreadNodeImpl)nodes.get(entry);
+            int depth = 0;
+            // climb the tree
+            while (node.getParentEntry() != null) {
+                ThreadNodeImpl parent = (ThreadNodeImpl)nodes.get(node.getParentEntry());
+                if (parent == null) break;
+                
+                // if the parent doesn't want replies, only include replies under the tree
+                // if they're written by the same author
+                BlogURI parentURI = parent.getEntry();
+                EntryContainer parentEntry = archive.getEntry(parentURI);
+                if (parentEntry != null) {
+                    parser.parse(parentEntry.getEntry().getText(), rec);
+                    String refuse = rec.getHeader(HTMLRenderer.HEADER_REFUSE_REPLIES);
+                    if ( (refuse != null) && (Boolean.valueOf(refuse).booleanValue()) ) {
+                        if (parent.getEntry().getKeyHash().equals(entry.getKeyHash())) {
+                            // same author, allow the reply
+                        } else {
+                            // different author, refuse
+                            parent = null;
+                            break;
+                        }
+                    }
+                }
+                
+                node.setParent(parent);
+                parent.addChild(node);
+                node = parent;
+                depth++;
+                if (depth > MAX_THREAD_DEPTH)
+                    break;
+            }
+        
+            node.summarizeThread();
+            roots.add(node);
+        }
+        
+        // store them, sorted by most recently updated thread first
+        for (Iterator iter = roots.iterator(); iter.hasNext(); ) 
+            addRoot((ThreadNode)iter.next());
+        
+        _parents.clear();
+        _tags.clear();
+    }
+    
+    public String toString() {
+        StringBuffer buf = new StringBuffer();
+        buf.append("<threadIndex>");
+        for (int i = 0; i < getRootCount(); i++) {
+            ThreadNode root = getRoot(i);
+            buf.append(root.toString());
+        }
+        buf.append("</threadIndex>\n");
+        return buf.toString();
+    }
+    
+    /** sort BlogURI instances with the highest entryId first */
+    private class NewestEntryFirstComparator implements Comparator {
+        public int compare(Object lhs, Object rhs) {
+            BlogURI left = (BlogURI)lhs;
+            BlogURI right = (BlogURI)rhs;
+            if (left.getEntryId() > right.getEntryId()) {
+                return -1;
+            } else if (left.getEntryId() == right.getEntryId()) {
+                return DataHelper.compareTo(left.getKeyHash().getData(), right.getKeyHash().getData());
+            } else {
+                return 1;
+            }
+        }
+    }
+    /** sort ThreadNodeImpl instances with the highest entryId first */
+    private class NewestNodeFirstComparator implements Comparator {
+        public int compare(Object lhs, Object rhs) {
+            ThreadNodeImpl left = (ThreadNodeImpl)lhs;
+            ThreadNodeImpl right = (ThreadNodeImpl)rhs;
+            if (left.getEntry().getEntryId() > right.getEntry().getEntryId()) {
+                return -1;
+            } else if (left.getEntry().getEntryId() == right.getEntry().getEntryId()) {
+                return DataHelper.compareTo(left.getEntry().getKeyHash().getData(), right.getEntry().getKeyHash().getData());
+            } else {
+                return 1;
+            }
+        }
+    }
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/ArchiveIndex.java b/apps/syndie/java/src/net/i2p/syndie/data/ArchiveIndex.java
index 09f5f6fcdf..61126821ae 100644
--- a/apps/syndie/java/src/net/i2p/syndie/data/ArchiveIndex.java
+++ b/apps/syndie/java/src/net/i2p/syndie/data/ArchiveIndex.java
@@ -32,6 +32,7 @@ public class ArchiveIndex {
     /** parent message to a set of replies, ordered with the oldest first */
     protected Map _replies;
     protected Properties _headers;
+    private ThreadIndex _threadedIndex;
     
     public ArchiveIndex() {
         this(I2PAppContext.getGlobalContext(), false);
@@ -48,6 +49,7 @@ public class ArchiveIndex {
         _headers = new Properties();
         _replies = Collections.synchronizedMap(new HashMap());
         _generatedOn = -1;
+        _threadedIndex = null;
         if (shouldLoad)
             setIsLocal("true");
     }
@@ -61,6 +63,8 @@ public class ArchiveIndex {
     public long getTotalSize() { return _totalSize; }
     public long getNewSize() { return _newSize; }
     public long getGeneratedOn() { return _generatedOn; }
+    public ThreadIndex getThreadedIndex() { return _threadedIndex; }
+    public void setThreadedIndex(ThreadIndex index) { _threadedIndex = index; }
     
     public String getNewSizeStr() { 
         if (_newSize < 1024) return _newSize + "";
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/BlogURI.java b/apps/syndie/java/src/net/i2p/syndie/data/BlogURI.java
index 99d8b17078..5b38a55af9 100644
--- a/apps/syndie/java/src/net/i2p/syndie/data/BlogURI.java
+++ b/apps/syndie/java/src/net/i2p/syndie/data/BlogURI.java
@@ -74,7 +74,9 @@ public class BlogURI {
                DataHelper.eq(_blogHash, ((BlogURI)obj)._blogHash);
     }
     public int hashCode() {
-        int rv = (int)_entryId;
+        int rv = (int)((_entryId >>> 32) & 0x7FFFFFFF);
+        rv += (_entryId & 0x7FFFFFFF);
+        
         if (_blogHash != null)
             rv += _blogHash.hashCode();
         return rv;
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/FilteredThreadIndex.java b/apps/syndie/java/src/net/i2p/syndie/data/FilteredThreadIndex.java
new file mode 100644
index 0000000000..c5da4a485c
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/data/FilteredThreadIndex.java
@@ -0,0 +1,88 @@
+package net.i2p.syndie.data;
+
+import java.util.*;
+import net.i2p.syndie.*;
+import net.i2p.data.*;
+import net.i2p.client.naming.*;
+
+/**
+ *
+ */
+public class FilteredThreadIndex extends ThreadIndex {
+    private User _user;
+    private Archive _archive;
+    private ThreadIndex _baseIndex;
+    private Collection _filteredTags;
+    private List _roots;
+    private List _ignoredAuthors;
+
+    public static final String GROUP_FAVORITE = "Favorite";
+    public static final String GROUP_IGNORE = "Ignore";
+
+    public FilteredThreadIndex(User user, Archive archive, Collection tags) {
+        super();
+        _user = user;
+        _archive = archive;
+        _baseIndex = _archive.getIndex().getThreadedIndex();
+        _filteredTags = tags;
+        if (_filteredTags == null)
+            _filteredTags = Collections.EMPTY_SET;
+        
+        _ignoredAuthors = new ArrayList();
+        for (Iterator iter = user.getPetNameDB().iterator(); iter.hasNext(); ) {
+            PetName pn = (PetName)iter.next();
+            if (pn.isMember(GROUP_IGNORE)) {
+                try {
+                    Hash h = new Hash();
+                    h.fromBase64(pn.getLocation());
+                    _ignoredAuthors.add(h);
+                } catch (DataFormatException dfe) {
+                    // ignore
+                }
+            }
+        }
+        
+        filter();
+    }
+    
+    private void filter() {
+        _roots = new ArrayList(_baseIndex.getRootCount());
+        for (int i = 0; i < _baseIndex.getRootCount(); i++) {
+            ThreadNode node = _baseIndex.getRoot(i);
+            if (!isIgnored(node, _ignoredAuthors, _filteredTags))
+                _roots.add(node);
+        }
+    }
+    
+    
+    private boolean isIgnored(ThreadNode node, List ignoredAuthors, Collection requestedTags) {
+        boolean allAuthorsIgnored = true;
+        for (Iterator iter = node.getRecursiveAuthorIterator(); iter.hasNext(); ) {
+            Hash author = (Hash)iter.next();
+            if (!ignoredAuthors.contains(author)) {
+                allAuthorsIgnored = false;
+                break;
+            }
+        }
+        
+        if ( (allAuthorsIgnored) && (ignoredAuthors.size() > 0) )
+            return true;
+        if (requestedTags.size() > 0) {
+            for (Iterator iter = requestedTags.iterator(); iter.hasNext(); ) 
+                if (node.getRecursiveTags().contains(iter.next()))
+                    return false;
+            // authors we aren't ignoring have posted in the thread, but the user is filtering
+            // posts by tags, and this thread doesn't include any of those tags
+            return true;
+        } else {
+            // we aren't filtering by tags, and we haven't been refused by the author
+            // filtering
+            return false;
+        }
+    }
+    
+    public int getRootCount() { return _roots.size(); }
+    public ThreadNode getRoot(int index) { return (ThreadNode)_roots.get(index); }
+    public ThreadNode getNode(BlogURI uri) { return _baseIndex.getNode(uri); }
+    public Collection getFilteredTags() { return _filteredTags; }
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/ThreadIndex.java b/apps/syndie/java/src/net/i2p/syndie/data/ThreadIndex.java
new file mode 100644
index 0000000000..3ba3b2833b
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/data/ThreadIndex.java
@@ -0,0 +1,49 @@
+package net.i2p.syndie.data;
+
+import java.util.*;
+
+/**
+ * List of threads, ordered with the most recently updated thread first.
+ * Each node in the tree summarizes everything underneath it as well.
+ *
+ */
+public class ThreadIndex {
+    /** ordered list of threads, with most recent first */
+    private List _roots;
+    /** map of BlogURI to ThreadNode */
+    private Map _nodes;
+    
+    protected ThreadIndex() {
+        // no need to synchronize, since the thread index doesn't change after
+        // its first built
+        _roots = new ArrayList();
+        _nodes = new HashMap(64);
+    }
+    
+    public int getRootCount() { return _roots.size(); }
+    public ThreadNode getRoot(int index) { return (ThreadNode)_roots.get(index); }
+    public ThreadNode getNode(BlogURI uri) { return (ThreadNode)_nodes.get(uri); }
+    /** 
+     * get the root of the thread that the given uri is located in, or -1.
+     * The implementation depends only on getRoot/getNode/getRootCount and not on the
+     * data structures, so should be safe for subclasses who adjust those methods
+     *
+     */
+    public int getRoot(BlogURI uri) {
+        ThreadNode node = getNode(uri);
+        if (node == null) return -1;
+        while (node.getParent() != null)
+            node = node.getParent();
+        for (int i = 0; i < getRootCount(); i++) {
+            ThreadNode cur = getRoot(i);
+            if (cur.equals(node))
+                return i;
+        }
+        return -1;
+    }
+  
+    /** call this in the right order - most recently updated thread first */
+    protected void addRoot(ThreadNode node) { _roots.add(node); }
+    /** invocation order here doesn't matter */
+    protected void addEntry(BlogURI uri, ThreadNode node) { _nodes.put(uri, node); }
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/ThreadNode.java b/apps/syndie/java/src/net/i2p/syndie/data/ThreadNode.java
new file mode 100644
index 0000000000..7ab3f8aa63
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/data/ThreadNode.java
@@ -0,0 +1,34 @@
+package net.i2p.syndie.data;
+
+import java.util.*;
+import net.i2p.data.Hash;
+
+/**
+ *
+ */
+public interface ThreadNode {
+    /** current post */
+    public BlogURI getEntry();
+    /** how many direct replies there are to the current entry */
+    public int getChildCount();
+    /** the given direct reply */
+    public ThreadNode getChild(int index);
+    /** parent this is actually a reply to */
+    public BlogURI getParentEntry();
+    /** parent in the tree, maybe not a direct parent, but the closest one */
+    public ThreadNode getParent();
+    /** true if this entry, or any child, is written by the given author */
+    public boolean containsAuthor(Hash author);
+    /** true if this node, or any child, includes the given URI */
+    public boolean containsEntry(BlogURI uri);
+    /** list of tags (String) of this node only */
+    public Collection getTags();
+    /** list of tags (String) of this node or any children in the tree */
+    public Collection getRecursiveTags();
+    /** date of the most recent post, recursive */
+    public long getMostRecentPostDate();
+    /** author of the most recent post, recurisve */
+    public Hash getMostRecentPostAuthor();
+    /** walk across the authors of the entire thread */
+    public Iterator getRecursiveAuthorIterator();
+}
diff --git a/apps/syndie/java/src/net/i2p/syndie/data/TransparentArchiveIndex.java b/apps/syndie/java/src/net/i2p/syndie/data/TransparentArchiveIndex.java
index c2c39faee2..49d9c45e7c 100644
--- a/apps/syndie/java/src/net/i2p/syndie/data/TransparentArchiveIndex.java
+++ b/apps/syndie/java/src/net/i2p/syndie/data/TransparentArchiveIndex.java
@@ -25,6 +25,7 @@ public class TransparentArchiveIndex extends ArchiveIndex {
     public long getTotalSize() { return index().getTotalSize(); }
     public long getNewSize() { return index().getNewSize(); }
     public long getGeneratedOn() { return index().getGeneratedOn(); }
+    public ThreadIndex getThreadedIndex() { return index().getThreadedIndex(); }
     
     public String getNewSizeStr() { return index().getNewSizeStr(); }
     public String getTotalSizeStr() { return index().getTotalSizeStr(); }
diff --git a/apps/syndie/java/src/net/i2p/syndie/sml/HTMLRenderer.java b/apps/syndie/java/src/net/i2p/syndie/sml/HTMLRenderer.java
index 931ceacbe8..d67231b079 100644
--- a/apps/syndie/java/src/net/i2p/syndie/sml/HTMLRenderer.java
+++ b/apps/syndie/java/src/net/i2p/syndie/sml/HTMLRenderer.java
@@ -755,6 +755,10 @@ public class HTMLRenderer extends EventReceiverImpl {
     public static final String HEADER_STYLE = "Style";
     public static final String HEADER_PETNAME = "PetName";
     public static final String HEADER_TAGS = "Tags";
+    /** if set to true, don't display the message in the same thread, though keep a parent reference */
+    public static final String HEADER_FORCE_NEW_THREAD = "ForceNewThread";
+    /** if set to true, don't let anyone else reply in the same thread (but let the original author reply) */
+    public static final String HEADER_REFUSE_REPLIES = "RefuseReplies";
     
     private void renderSubjectCell() {
         _preBodyBuffer.append("<form action=\"index.jsp\">");
@@ -880,7 +884,7 @@ public class HTMLRenderer extends EventReceiverImpl {
     }
     
     private final SimpleDateFormat _dateFormat = new SimpleDateFormat("yyyy/MM/dd", Locale.UK);
-    private final String getEntryDate(long when) {
+    public final String getEntryDate(long when) {
         synchronized (_dateFormat) {
             try {
                 String str = _dateFormat.format(new Date(when));
diff --git a/apps/syndie/java/src/net/i2p/syndie/web/ViewThreadedServlet.java b/apps/syndie/java/src/net/i2p/syndie/web/ViewThreadedServlet.java
new file mode 100644
index 0000000000..fcd5ebaa51
--- /dev/null
+++ b/apps/syndie/java/src/net/i2p/syndie/web/ViewThreadedServlet.java
@@ -0,0 +1,1053 @@
+package net.i2p.syndie.web;
+
+import java.io.*;
+import java.util.*;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.ServletException;
+
+import net.i2p.I2PAppContext;
+import net.i2p.client.naming.*;
+import net.i2p.data.*;
+import net.i2p.syndie.*;
+import net.i2p.syndie.data.*;
+import net.i2p.syndie.sml.*;
+
+/**
+ *
+ */
+public class ViewThreadedServlet extends HttpServlet {
+    /** what, if any, post should be rendered */
+    public static final String PARAM_VIEW_POST = "post";
+    /** what, if any, thread should be rendered in its entirety */
+    public static final String PARAM_VIEW_THREAD = "thread";
+    /** what post should be visible in the nav tree */
+    public static final String PARAM_VISIBLE = "visible";
+    public static final String PARAM_ADD_TO_GROUP_LOCATION = "addLocation";
+    public static final String PARAM_ADD_TO_GROUP_NAME = "addGroup";
+    /** index into the nav tree to start displaying */
+    public static final String PARAM_OFFSET = "offset";
+    public static final String PARAM_TAGS = "tags";
+    
+    private static final boolean ALLOW_FILTER_BY_TAG = true;
+    
+    public void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+        req.setCharacterEncoding("UTF-8");
+        resp.setCharacterEncoding("UTF-8");
+        resp.setContentType("text/html");
+        
+        User user = (User)req.getSession().getAttribute("user");
+        String login = req.getParameter("login");
+        String pass = req.getParameter("password");
+        String action = req.getParameter("action");
+        boolean forceNewIndex = false;
+        
+        if (user == null) {
+            if ("Login".equals(action)) {
+                user = new User();
+                BlogManager.instance().login(user, login, pass); // ignore failures - user will just be unauthorized
+                if (!user.getAuthenticated())
+                    user.invalidate();
+            } else {
+                user = new User();
+                BlogManager.instance().login(user, login, pass); // ignore failures - user will just be unauthorized
+            }
+            forceNewIndex = true;
+        } else if ("Login".equals(action)) {
+            user = new User();
+            BlogManager.instance().login(user, login, pass); // ignore failures - user will just be unauthorized
+            if (!user.getAuthenticated())
+                user.invalidate();
+            forceNewIndex = true;
+        }
+        
+        req.getSession().setAttribute("user", user);
+        
+        if (user.getAuthenticated()) {
+            String loc = req.getParameter(PARAM_ADD_TO_GROUP_LOCATION);
+            String group = req.getParameter(PARAM_ADD_TO_GROUP_NAME);
+            if ( (loc != null) && (group != null) && (group.trim().length() > 0) ) {
+                try {
+                    Hash key = new Hash();
+                    key.fromBase64(loc);
+                    PetNameDB db = user.getPetNameDB();
+                    PetName pn = db.getByLocation(loc);
+                    boolean isNew = false;
+                    if (pn == null) {
+                        isNew = true;
+                        BlogInfo info = BlogManager.instance().getArchive().getBlogInfo(key);
+                        String name = null;
+                        if (info != null)
+                            name = info.getProperty(BlogInfo.NAME);
+                        else
+                            name = loc.substring(0,6);
+
+                        if (db.containsName(name)) {
+                            int i = 0;
+                            while (db.containsName(name + i))
+                                i++;
+                            name = name + i;
+                        }
+                        
+                        pn = new PetName(name, "syndie", "syndieblog", loc);
+                    }
+                    pn.addGroup(group);
+                    if (isNew)
+                        db.add(pn);
+                    BlogManager.instance().saveUser(user);
+                    // if we are ignoring someone, we need to recalculate the filters
+                    if (FilteredThreadIndex.GROUP_IGNORE.equals(group))
+                        forceNewIndex = true;
+                } catch (DataFormatException dfe) {
+                    // bad loc, ignore
+                }
+            }
+        }
+        
+        FilteredThreadIndex index = (FilteredThreadIndex)req.getSession().getAttribute("threadIndex");
+        
+        Collection tags = getFilteredTags(req);
+        if (forceNewIndex || (index == null) || (!index.getFilteredTags().equals(tags)) ) {
+            index = new FilteredThreadIndex(user, BlogManager.instance().getArchive(), getFilteredTags(req));
+            req.getSession().setAttribute("threadIndex", index);
+        }
+        
+        render(user, req, resp.getWriter(), index);
+    }
+    
+    private void render(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws ServletException, IOException {
+        Archive archive = BlogManager.instance().getArchive();
+        int numThreads = 10;
+        int threadOffset = getOffset(req);
+        if (threadOffset == -1) {
+            threadOffset = index.getRootCount() - numThreads;
+        } 
+        if (threadOffset < 0) {
+            threadOffset = 0;
+        }
+
+        BlogURI visibleEntry = getVisible(req);
+        
+        int offset = 0;
+        if ( empty(req, PARAM_OFFSET) && (visibleEntry != null) ) {
+            // we're on a permalink, so jump the tree to the given thread
+            threadOffset = index.getRoot(visibleEntry);
+            if (threadOffset < 0)
+                threadOffset = 0;
+        }
+        
+        renderBegin(user, req, out, index);
+        renderNavBar(user, req, out, index);
+        renderControlBar(user, req, out, index);
+        renderBody(user, req, out, index);
+        renderThreadNav(user, req, out, threadOffset, index);
+        renderThreadTree(user, req, out, threadOffset, visibleEntry, archive, index);
+        renderThreadNav(user, req, out, threadOffset, index);
+        renderEnd(user, req, out, index);
+    }
+    
+    private void renderBegin(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws IOException {
+        out.write(BEGIN_HTML);
+    }
+    private void renderNavBar(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws IOException {
+        //out.write("<tr class=\"topNav\"><td class=\"topNav_user\" colspan=\"2\" nowrap=\"true\">\n");
+        out.write("<tr class=\"topNav\"><td colspan=\"3\" nowrap=\"true\"><span class=\"topNav_user\">\n");
+        out.write("<!-- nav bar begin -->\n");
+        if (user.getAuthenticated()) {
+            out.write("Logged in as <a href=\"" + getProfileLink(req, user.getBlog()) + "\" title=\"Edit your profile\">");
+            out.write(user.getUsername());
+            out.write("</a>\n");
+            out.write("(<a href=\"switchuser.jsp\" title=\"Log in as another user\">switch</a>)\n");
+            out.write("<a href=\"post.jsp\" title=\"Post a new thread\">Post a new thread</a>\n");
+        } else {
+            out.write("<form action=\"" + req.getRequestURI() + "\" method=\"GET\">\n");
+            out.write("Login: <input type=\"text\" name=\"login\" />\n");
+            out.write("Password: <input type=\"password\" name=\"password\" />\n");
+            out.write("<input type=\"submit\" name=\"action\" value=\"Login\" /></form>\n");
+        }
+        //out.write("</td><td class=\"topNav_admin\">\n");
+        out.write("</span><span class=\"topNav_admin\">\n");
+        if (user.getAuthenticated() && user.getAllowAccessRemote()) {
+            out.write("<a href=\"syndicate.jsp\" title=\"Syndicate data between other Syndie nodes\">Syndicate</a>\n");
+            out.write("<a href=\"importfeed.jsp\" title=\"Import RSS/Atom data\">Import RSS/Atom</a>\n");
+            out.write("<a href=\"admin.jsp\" title=\"Configure this Syndie node\">Admin</a>\n");
+        }
+        out.write("</span><!-- nav bar end -->\n</td></tr>\n");
+    }
+    
+    private static final ArrayList SKIP_TAGS = new ArrayList();
+    static {
+        SKIP_TAGS.add("action");
+        SKIP_TAGS.add("filter");
+        // post and visible are skipped since we aren't good at filtering by tag when the offset will
+        // skip around randomly.  at least, not yet.
+        SKIP_TAGS.add("visible");
+        //SKIP_TAGS.add("post");
+        //SKIP_TAGS.add("thread");
+        SKIP_TAGS.add("offset"); // if we are adjusting the filter, ignore the previous offset
+        SKIP_TAGS.add("login");
+        SKIP_TAGS.add("password");
+    }
+    
+    private void renderControlBar(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws IOException {
+        if (ALLOW_FILTER_BY_TAG) {
+            out.write("<form action=\"");
+            out.write(req.getRequestURI());
+            out.write("\" method=\"GET\">\n");
+            String tags = "";
+            Enumeration params = req.getParameterNames();
+            while (params.hasMoreElements()) {
+                String param = (String)params.nextElement();
+                String val = req.getParameter(param);
+                if (PARAM_TAGS.equals(param)) {
+                    tags = val;
+                } else if (SKIP_TAGS.contains(param)) {
+                    // skip
+                } else if (param.length() <= 0) {
+                    // skip
+                } else {
+                    out.write("<input type=\"hidden\" name=\"" + param + "\" value=\"" + val + "\" />\n");
+                }
+            }
+            out.write("<tr class=\"controlBar\"><td colspan=\"2\">\n");
+            out.write("<!-- control bar begin -->\n");
+            out.write("Filter: <select name=\"filter\" disabled=\"true\" >\n");
+            out.write(" <option value=\"all\">All posts in all threads</option>\n");
+            out.write(" <option value=\"self\">Threads you have posted in</option>\n");
+            out.write(" <option value=\"favorites\">Threads your friends have posted in</option>\n");
+            out.write(" </select>\n");
+            out.write("Tags: <input type=\"text\" name=\"" + PARAM_TAGS + "\" size=\"30\" value=\"" + tags + "\" />\n");
+            out.write("<input type=\"submit\" name=\"action\" value=\"Go\" />\n");
+            out.write("</td><td class=\"controlBarRight\"><a href=\"#threads\" title=\"Jump to the thread navigation\">Threads</a></td>\n");
+            out.write("<!-- control bar end -->\n");
+            out.write("</tr>\n");
+            out.write("</form>\n");
+        } else {
+            out.write(CONTROL_BAR_WITHOUT_TAGS);
+        }
+    }
+    private void renderBody(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws IOException, ServletException  {
+        Archive archive = BlogManager.instance().getArchive();
+        List posts = getPosts(archive, req, index);
+        for (int i = 0; i < posts.size(); i++) {
+            BlogURI post = (BlogURI)posts.get(i);
+            renderBody(user, req, out, archive, post, posts.size() == 1, index);
+        }
+    }
+    
+    private List getPosts(Archive archive, HttpServletRequest req, ThreadIndex index) {
+        List rv = new ArrayList(1);
+        String post = req.getParameter(PARAM_VIEW_POST);
+        BlogURI uri = getAsBlogURI(post);
+        if ( (uri != null) && (uri.getEntryId() > 0) ) {
+            rv.add(uri);
+        } else {
+            String thread = req.getParameter(PARAM_VIEW_THREAD);
+            uri = getAsBlogURI(thread);
+            if ( (uri != null) && (uri.getEntryId() > 0) ) {
+                ThreadNode node = index.getNode(uri);
+                if (node != null) {
+                    while (node.getParent() != null)
+                        node = node.getParent(); // hope the structure is loopless...
+                    // depth first traversal
+                    walkTree(rv, node);
+                } else {
+                    rv.add(uri);
+                }
+            }
+        }
+        return rv;
+    }
+    
+    private void walkTree(List uris, ThreadNode node) {
+        if (node == null)
+            return;
+        if (uris.contains(node))
+            return;
+        uris.add(node.getEntry());
+        for (int i = 0; i < node.getChildCount(); i++)
+            walkTree(uris, node.getChild(i));
+    }
+    
+    private void renderBody(User user, HttpServletRequest req, PrintWriter out, Archive archive, BlogURI post, boolean inlineReply, ThreadIndex index) throws IOException, ServletException {
+        EntryContainer entry = archive.getEntry(post);
+        if (entry == null) return;
+        
+        out.write("<!-- body begin -->\n");
+        out.write("<!-- body meta begin -->\n");
+        out.write("<tr class=\"postMeta\" id=\"" + post.toString() + "\">\n");
+        
+        HeaderReceiver rec = new HeaderReceiver();
+        SMLParser parser = new SMLParser(I2PAppContext.getGlobalContext());
+        HTMLRenderer rend = new HTMLRenderer(I2PAppContext.getGlobalContext());
+        parser.parse(entry.getEntry().getText(), rec);
+        String subject = rec.getHeader(HTMLRenderer.HEADER_SUBJECT);
+        if (subject == null)
+            subject = "";
+        out.write(" <td colspan=\"3\" class=\"postMetaSubject\">");
+        out.write(subject);
+        out.write("</td></tr>\n");
+        out.write("<tr class=\"postMeta\"><td colspan=\"3\" class=\"postMetaLink\">\n");
+        out.write("<a href=\"");
+        out.write(HTMLRenderer.getMetadataURL(post.getKeyHash()));
+        out.write("\" title=\"View the author's profile\">");
+        
+        String author = null;
+        PetName pn = user.getPetNameDB().getByLocation(post.getKeyHash().toBase64());
+        if (pn == null) {
+            BlogInfo info = archive.getBlogInfo(post.getKeyHash());
+            if (info != null)
+                author = info.getProperty(BlogInfo.NAME);
+        } else {
+            author = pn.getName();
+        }
+        if ( (author == null) || (author.trim().length() <= 0) )
+            author = post.getKeyHash().toBase64().substring(0,6);
+        
+        ThreadNode node = index.getNode(post);
+        
+        out.write(author);
+        out.write("</a> @ ");
+        out.write(rend.getEntryDate(post.getEntryId()));
+        
+        Collection tags = node.getTags();
+        if ( (tags != null) && (tags.size() > 0) ) {
+            out.write("\nTags: \n");
+            for (Iterator tagIter = tags.iterator(); tagIter.hasNext(); ) {
+                String tag = (String)tagIter.next();
+                if (ALLOW_FILTER_BY_TAG) {
+                    out.write("<a href=\"");
+                    out.write(getFilterByTagLink(req, node, user, tag));
+                    out.write("\" title=\"Filter threads to only include posts tagged as '");
+                    out.write(tag);
+                    out.write("'\">");
+                }
+                out.write(" " + tag);
+                if (ALLOW_FILTER_BY_TAG)
+                    out.write("</a>\n");
+            }
+        }
+        
+        out.write("\n<a href=\"");
+        out.write(getViewPostLink(req, node, user, true));
+        out.write("\" title=\"Select a shareable link directly to this post\">permalink</a>\n");
+        
+        out.write("</td>\n</tr>\n");
+        out.write("<!-- body meta end -->\n");
+        out.write("<!-- body post begin -->\n");
+        out.write("<tr class=\"postData\">\n");
+        out.write("<td colspan=\"3\">\n");
+        rend.render(user, archive, entry, out, false, true);
+        out.write("</td>\n</tr>\n");
+        out.write("<!-- body post end -->\n");
+        out.write("<!-- body details begin -->\n");
+/*
+"<tr class=\"postDetails\">\n" +
+" <form action=\"viewattachment.jsp\" method=\"GET\">\n" +
+" <td colspan=\"3\">\n" +
+" External links:\n" +
+"  <a href=\"external.jsp?foo\" title=\"View foo.i2p\">http://foo.i2p/</a>\n" +
+"  <a href=\"external.jsp?bar\" title=\"View bar.i2p\">http://bar.i2p/</a>\n" +
+" <br />\n" +
+" Attachments: <select name=\"attachment\">\n" +
+"  <option value=\"0\">sampleRawSML.sml: Sample SML file with headers (4KB, type text/plain)</option>\n" +
+" </select> <input type=\"submit\" name=\"action\" value=\"Download\" />\n" +
+" <br /><a href=\"\" title=\"Expand the entire thread\">Full thread</a>\n" +
+" <a href=\"\" title=\"Previous post in the thread\">Prev in thread</a> \n" +
+" <a href=\"\" title=\"Next post in the thread\">Next in thread</a> \n" +
+" </td>\n" +
+" </form>\n" +
+"</tr>\n" +
+ */
+        out.write("<!-- body details end -->\n");
+        if (inlineReply && user.getAuthenticated() ) {
+            String refuseReplies = rec.getHeader(HTMLRenderer.HEADER_REFUSE_REPLIES);
+            // show the reply form if we are the author or replies have not been explicitly rejected
+            if ( (user.getBlog().equals(post.getKeyHash())) ||
+                 (refuseReplies == null) || (!Boolean.valueOf(refuseReplies).booleanValue()) ) {
+                out.write("<!-- body reply begin -->\n");
+                out.write("<form action=\"post.jsp\" method=\"POST\" enctype=\"multipart/form-data\">\n");
+                out.write("<input type=\"hidden\" name=\"inReplyTo\" value=\"");
+                out.write(Base64.encode(post.toString()));
+                out.write("\" />");
+                out.write("<input type=\"hidden\" name=\"entrysubject\" value=\"re: ");
+                out.write(HTMLRenderer.sanitizeTagParam(subject));
+                out.write("\" />");
+                out.write("<tr class=\"postReply\">\n");
+                out.write("<td colspan=\"3\">Reply: (<a href=\"smlref.jsp\" title=\"SML cheatsheet\">SML reference</a>)</td>\n</tr>\n");
+                out.write("<tr class=\"postReplyText\">\n");
+                out.write("<td colspan=\"3\"><textarea name=\"entrytext\" rows=\"2\" cols=\"100\"></textarea></td>\n");
+                out.write("</tr>\n");
+                out.write("<tr class=\"postReplyOptions\">\n");
+                out.write(" <td colspan=\"3\">\n");
+                out.write(" <input type=\"submit\" value=\"Preview...\" name=\"Post\" />\n");
+                out.write(" Tags: <input type=\"text\" size=\"10\" name=\"entrytags\" />\n");
+                out.write(" in a new thread? <input type=\"checkbox\" name=\"replyInNewThread\" />\n");
+                out.write(" allow replies? <input type=\"checkbox\" name=\"allowReplies\" checked=\"true\" />\n");
+                out.write(" attachment: <input type=\"file\" name=\"entryfile0\" />\n");
+                out.write(" </td>\n</tr>\n</form>\n");
+                out.write("<!-- body reply end -->\n");
+            }
+        }
+        out.write("<!-- body end -->\n");
+    }
+    private void renderThreadNav(User user, HttpServletRequest req, PrintWriter out, int threadOffset, ThreadIndex index) throws IOException {
+        out.write("<tr class=\"threadNav\" id=\"threads\"><td colspan=\"2\" nowrap=\"true\">\n");
+        out.write("<!-- thread nav begin -->\n");
+        out.write("<a href=\"");
+        out.write(getNavLink(req, 0));
+        out.write("\">&lt;&lt; First Page</a> ");
+        if (threadOffset > 0) {
+            out.write("<a href=\"");
+            int nxt = threadOffset - 10;
+            if (nxt < 0)
+                nxt = 0;
+            out.write(getNavLink(req, nxt));
+            out.write("\">&lt; Prev Page</a>\n");
+        } else {
+            out.write("&lt; Prev Page\n");
+        }
+        out.write("</td><td class=\"threadNavRight\" nowrap=\"true\">\n");
+        
+        int max = index.getRootCount();
+        if (threadOffset + 10 > max) {
+            out.write("Next Page&gt; Last Page&gt;&gt;\n");
+        } else {
+            out.write("<a href=\"");
+            out.write(getNavLink(req, threadOffset + 10));
+            out.write("\">Next Page&gt;</a> <a href=\"");
+            out.write(getNavLink(req, -1));
+            out.write("\">Last Page&gt;&gt;</a>\n");
+        }
+        out.write("<!-- thread nav end -->\n");
+        out.write("</td></tr>\n");
+    }
+    
+    private void renderThreadTree(User user, HttpServletRequest req, PrintWriter out, int threadOffset, BlogURI visibleEntry, Archive archive, ThreadIndex index) throws IOException {
+        int numThreads = 10;
+        renderThreadTree(user, out, index, archive, req, threadOffset, numThreads, visibleEntry);
+    }
+    
+    private static final int getOffset(HttpServletRequest req) {
+        String off = req.getParameter(PARAM_OFFSET);
+        try {
+            return Integer.parseInt(off);
+        } catch (NumberFormatException nfe) {
+            return 0;
+        }
+    }
+    private static final BlogURI getVisible(HttpServletRequest req) {
+        return getAsBlogURI(req.getParameter(PARAM_VISIBLE));
+    }
+    private static final BlogURI getAsBlogURI(String uri) {
+        if (uri != null) {
+            int split = uri.indexOf('/');
+            if ( (split <= 0) || (split + 1 >= uri.length()) )
+                return null;
+            String blog = uri.substring(0, split);
+            String id = uri.substring(split+1);
+            try {
+                Hash hash = new Hash();
+                hash.fromBase64(blog);
+                long msgId = Long.parseLong(id);
+                if (msgId > 0)
+                    return new BlogURI(hash, msgId);
+            } catch (DataFormatException dfe) {
+                return null;
+            } catch (NumberFormatException nfe) {
+                return null;
+            }
+        }
+        return null;
+    }
+    
+    private Collection getFilteredTags(HttpServletRequest req) {
+        String tags = req.getParameter(PARAM_TAGS);
+        if (tags != null) {
+            StringTokenizer tok = new StringTokenizer(tags, "\n\t ");
+            ArrayList rv = new ArrayList();
+            while (tok.hasMoreTokens()) {
+                String tag = tok.nextToken().trim();
+                if (tag.length() > 0)
+                    rv.add(tag);
+            }
+            return rv;
+        } else {
+            return Collections.EMPTY_LIST;
+        }
+    }
+
+    private void renderThreadTree(User user, PrintWriter out, ThreadIndex index, Archive archive, HttpServletRequest req,
+                                  int threadOffset, int numThreads, BlogURI visibleEntry) {
+        
+        if ( (visibleEntry != null) && (empty(req, PARAM_OFFSET)) ) {
+            // we want to jump to a specific thread in the nav
+            threadOffset = index.getRoot(visibleEntry);
+        }
+        
+        out.write("<!-- threads begin -->\n");
+        if (threadOffset + numThreads > index.getRootCount())
+            numThreads = index.getRootCount() - threadOffset;
+        TreeRenderState state = new TreeRenderState(new ArrayList());
+        
+        for (int curRoot = threadOffset; curRoot < numThreads + threadOffset; curRoot++) {
+            ThreadNode node = index.getRoot(curRoot);
+            out.write("<!-- thread begin node=" + node + " curRoot=" + curRoot + " threadOffset=" + threadOffset + " -->\n");
+            renderThread(user, out, index, archive, req, node, 0, visibleEntry, state);
+            out.write("<!-- thread end -->\n");
+        }
+        out.write("<!-- threads begin -->\n");
+    }
+    
+    /*
+    private void renderThreadTree(User user, PrintWriter out, ThreadIndex index, Archive archive, HttpServletRequest req,
+                                  int threadOffset, int numThreads, BlogURI visibleEntry) {
+
+        List ignored = new ArrayList();
+        for (Iterator iter = user.getPetNameDB().iterator(); iter.hasNext(); ) {
+            PetName pn = (PetName)iter.next();
+            if (pn.isMember(GROUP_IGNORE)) {
+                ignored.add(new Hash(Base64.decode(pn.getLocation())));
+            }
+        }
+        
+        out.write("<!-- threads begin -->\n");
+        if (threadOffset + numThreads > index.getRootCount())
+            numThreads = index.getRootCount() - threadOffset;
+        TreeRenderState state = new TreeRenderState(ignored);
+        
+        Collection requestedTags = getFilteredTags(req);
+        out.write("<!-- requested tags: " + requestedTags + " -->\n");
+        
+        int writtenThreads = 0;
+        int skipped = 0;
+        for (int curRoot = 0; (curRoot < index.getRootCount()) && (writtenThreads < numThreads); curRoot++) {
+            ThreadNode node = index.getRoot(curRoot);
+            boolean isIgnored = isIgnored(node, ignored, requestedTags);
+            out.write("<!-- thread begin (" + curRoot + ", " + writtenThreads + ", " + skipped + ", " + state.getRowsWritten() + ", " + isIgnored + ", " + threadOffset + ") -->\n");
+            if (!isIgnored) {
+                if ( (writtenThreads + skipped >= threadOffset) || ( (visibleEntry != null) && (empty(req, PARAM_OFFSET)) ) ) {
+                    renderThread(user, out, index, archive, req, node, 0, visibleEntry, state, requestedTags);
+                    writtenThreads++;
+                } else {
+                    skipped++;
+                }
+            }
+            out.write("<!-- thread end -->\n");
+        }
+        out.write("<!-- threads begin -->\n");
+    }
+    */
+    /**
+     * @return true if some post in the thread has been written
+     */
+    private boolean renderThread(User user, PrintWriter out, ThreadIndex index, Archive archive, HttpServletRequest req,
+                                 ThreadNode node, int depth, BlogURI visibleEntry, TreeRenderState state) {
+        boolean isFavorite = false;
+        
+        HTMLRenderer rend = new HTMLRenderer(I2PAppContext.getGlobalContext());
+        SMLParser parser = new SMLParser(I2PAppContext.getGlobalContext());
+        
+        PetName pn = user.getPetNameDB().getByLocation(node.getEntry().getKeyHash().toBase64());
+        if (pn != null) {
+            if (pn.isMember(FilteredThreadIndex.GROUP_FAVORITE)) {
+                isFavorite = true;
+            }
+        }
+        
+        state.incrementRowsWritten();
+        if (state.getRowsWritten() % 2 == 0)
+            out.write("<tr class=\"threadEven\">\n");
+        else
+            out.write("<tr class=\"threadOdd\">\n");
+
+        out.write("<td class=\"threadFlag\">");
+        out.write(getFlagHTML(user, node));
+        out.write("</td>\n<td class=\"threadLeft\">\n");
+        for (int i = 0; i < depth; i++)
+            out.write("<img src=\"images/threadIndent.png\" alt=\"\" border=\"0\" />");
+        
+        boolean showChildren = false;
+        
+        int childCount = node.getChildCount();
+        /*
+        for (int i = 0; i < node.getChildCount(); i++) {
+            ThreadNode child = node.getChild(i);
+            // we don't actually filter with the tags here, since something in this thread has already
+            // picked it out for rendering, and we don't want to limit it to just the subthreads that are
+            // tagged
+            if (isIgnored(child, state.getIgnoredAuthors(), Collections.EMPTY_LIST))
+                childCount--;
+        }
+        */
+        
+        if (childCount > 0) {
+            boolean allowCollapse = false;
+
+            if (visibleEntry != null) {
+                if (node.getEntry().equals(visibleEntry)) {
+                    // noop
+                } else if (node.containsEntry(visibleEntry)) {
+                    showChildren = true;
+                    allowCollapse = true;
+                }
+            } else {
+                // noop
+            }
+        
+            if (allowCollapse) {
+                out.write("<a href=\"");
+                out.write(getCollapseLink(req, node));
+                out.write("\" title=\"collapse thread\"><img border=\"0\" src=\"images/collapse.png\" alt=\"-\" /></a>\n");
+            } else {
+                out.write("<a href=\"");
+                out.write(getExpandLink(req, node));
+                out.write("\" title=\"expand thread\"><img border=\"0\" src=\"images/expand.png\" alt=\"+\" /></a>\n");
+            }
+        } else {
+            out.write("<img src=\"images/noSubthread.png\" alt=\"\" border=\"0\" />\n");
+        }
+        
+        out.write("<a href=\"");
+        out.write(getProfileLink(req, node.getEntry().getKeyHash()));
+        out.write("\" title=\"View the user's profile\">");
+
+        if (pn == null) {
+            BlogInfo info = archive.getBlogInfo(node.getEntry().getKeyHash());
+            String name = null;
+            if (info != null)
+                name = info.getProperty(BlogInfo.NAME);
+            if ( (name == null) || (name.trim().length() <= 0) )
+                name = node.getEntry().getKeyHash().toBase64().substring(0,6);
+            out.write(name);
+        } else {
+            out.write(pn.getName());
+        }
+        out.write("</a>\n");
+
+        if (isFavorite) {
+            out.write("<img src=\"images/favorites.png\" alt=\"favorites\" border=\"0\" />\n");
+        } else {
+            if (user.getAuthenticated()) {
+                // give them a link to bookmark or ignore the peer
+                out.write("(<a href=\"");
+                out.write(getAddToGroupLink(req, node.getEntry().getKeyHash(), user, FilteredThreadIndex.GROUP_FAVORITE));
+                out.write("\" title=\"Add as a friend\"><img src=\"images/addToFavorites.png\" alt=\"friend\" border=\"0\" /></a>\n");
+                out.write("/<a href=\"");
+                out.write(getAddToGroupLink(req, node.getEntry().getKeyHash(), user, FilteredThreadIndex.GROUP_IGNORE));
+                out.write("\" title=\"Add to killfile\"><img src=\"images/addToIgnored.png\" alt=\"ignore\" border=\"0\" /></a>)\n");
+            }
+        }
+
+        out.write(" @ ");
+        out.write("<a href=\"");
+        out.write(getViewPostLink(req, node, user, false));
+        out.write("\" title=\"View post\">");
+        out.write(rend.getEntryDate(node.getEntry().getEntryId()));
+        out.write(": ");
+        EntryContainer entry = archive.getEntry(node.getEntry());
+
+        HeaderReceiver rec = new HeaderReceiver();
+        parser.parse(entry.getEntry().getText(), rec);
+        String subject = rec.getHeader(HTMLRenderer.HEADER_SUBJECT);
+        if (subject == null)
+            subject = "";
+        out.write(subject);
+        out.write("</a>\n</td><td class=\"threadRight\">\n");
+        out.write("<a href=\"");
+        out.write(getViewThreadLink(req, node, user));
+        out.write("\" title=\"View all posts in the thread\">view thread</a>\n");
+        out.write("</td></tr>\n");
+        
+        boolean rendered = true;
+        
+        if (showChildren) {
+            for (int i = 0; i < node.getChildCount(); i++) {
+                ThreadNode child = node.getChild(i);
+                boolean childRendered = renderThread(user, out, index, archive, req, child, depth+1, visibleEntry, state);
+                rendered = rendered || childRendered;
+            }
+        }
+        
+        return rendered;
+    }
+    
+    
+    private String getFlagHTML(User user, ThreadNode node) {
+        // grab all of the peers in the user's favorites group and check to see if 
+        // they posted something in the given thread, flagging it if they have
+        boolean favoriteFound = false;
+        for (Iterator iter = user.getPetNameDB().getNames().iterator(); iter.hasNext(); ) {
+            PetName pn = user.getPetNameDB().getByName((String)iter.next());
+            if (pn.isMember(FilteredThreadIndex.GROUP_FAVORITE)) {
+                Hash cur = new Hash();
+                try {
+                    cur.fromBase64(pn.getLocation());
+                    if (node.containsAuthor(cur)) {
+                        favoriteFound = true;
+                        break;
+                    }
+                } catch (Exception e) {}
+            }
+        }
+        if (favoriteFound) 
+            return "<img src=\"images/favorites.png\" border=\"0\" alt=\"flagged author posted in the thread\" />";
+        else
+            return "&nbsp;"; 
+    }
+    
+    private static final boolean empty(HttpServletRequest req, String param) {
+        String val = req.getParameter(param);
+        return (val == null) || (val.trim().length() <= 0);
+    }
+    
+    private String getExpandLink(HttpServletRequest req, ThreadNode node) {
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        buf.append('?');
+        // expand node == let one of node's children be visible
+        if (node.getChildCount() > 0) {
+            ThreadNode child = node.getChild(0);
+            buf.append(PARAM_VISIBLE).append('=');
+            buf.append(child.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(child.getEntry().getEntryId()).append('&');
+        }
+        
+        if (!empty(req, PARAM_VIEW_POST))
+            buf.append(PARAM_VIEW_POST).append('=').append(req.getParameter(PARAM_VIEW_POST)).append('&');
+        else if (!empty(req, PARAM_VIEW_THREAD))
+            buf.append(PARAM_VIEW_THREAD).append('=').append(req.getParameter(PARAM_VIEW_THREAD)).append('&');
+        
+        if (!empty(req, PARAM_OFFSET))
+            buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+        
+        if (!empty(req, PARAM_TAGS)) 
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        return buf.toString();
+    }
+    private String getCollapseLink(HttpServletRequest req, ThreadNode node) { 
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        // collapse node == let the node be visible
+        buf.append('?').append(PARAM_VISIBLE).append('=');
+        buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+        buf.append(node.getEntry().getEntryId()).append('&');
+
+        if (!empty(req, PARAM_VIEW_POST))
+            buf.append(PARAM_VIEW_POST).append('=').append(req.getParameter(PARAM_VIEW_POST)).append('&');
+        else if (!empty(req, PARAM_VIEW_THREAD))
+            buf.append(PARAM_VIEW_THREAD).append('=').append(req.getParameter(PARAM_VIEW_THREAD)).append('&');
+        
+        if (!empty(req, PARAM_OFFSET))
+            buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+        
+        if (!empty(req, PARAM_TAGS))
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        return buf.toString();
+    }
+    private String getProfileLink(HttpServletRequest req, Hash author) {
+        return HTMLRenderer.getMetadataURL(author);
+    }
+    private String getAddToGroupLink(HttpServletRequest req, Hash author, User user, String group) {
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        buf.append('?');
+        String visible = req.getParameter(PARAM_VISIBLE);
+        if (visible != null) 
+            buf.append(PARAM_VISIBLE).append('=').append(visible).append('&');
+        buf.append(PARAM_ADD_TO_GROUP_LOCATION).append('=').append(author.toBase64()).append('&');
+        buf.append(PARAM_ADD_TO_GROUP_NAME).append('=').append(group).append('&');
+
+        if (!empty(req, PARAM_VIEW_POST))
+            buf.append(PARAM_VIEW_POST).append('=').append(req.getParameter(PARAM_VIEW_POST)).append('&');
+        else if (!empty(req, PARAM_VIEW_THREAD))
+            buf.append(PARAM_VIEW_THREAD).append('=').append(req.getParameter(PARAM_VIEW_THREAD)).append('&');
+        
+        if (!empty(req, PARAM_OFFSET))
+            buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+
+        if (!empty(req, PARAM_TAGS))
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        return buf.toString();
+    }
+    private String getViewPostLink(HttpServletRequest req, ThreadNode node, User user, boolean isPermalink) {
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        if (node.getChildCount() > 0) {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            ThreadNode child = node.getChild(0);
+            buf.append(child.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(child.getEntry().getEntryId()).append('&');
+        } else {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(node.getEntry().getEntryId()).append('&');
+        }
+        buf.append(PARAM_VIEW_POST).append('=');
+        buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+        buf.append(node.getEntry().getEntryId()).append('&');
+        
+        if ( (!isPermalink) && (!empty(req, PARAM_OFFSET)) )
+            buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+        
+        if ( (!isPermalink) && (!empty(req, PARAM_TAGS)) )
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        return buf.toString();
+    }
+    private String getViewThreadLink(HttpServletRequest req, ThreadNode node, User user) {
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        if (node.getChildCount() > 0) {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            ThreadNode child = node.getChild(0);
+            buf.append(child.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(child.getEntry().getEntryId()).append('&');
+        } else {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(node.getEntry().getEntryId()).append('&');
+        }
+        buf.append(PARAM_VIEW_THREAD).append('=');
+        buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+        buf.append(node.getEntry().getEntryId()).append('&');
+        
+        if (!empty(req, PARAM_OFFSET))
+            buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+        
+        if (!empty(req, PARAM_TAGS))
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        buf.append("#").append(node.getEntry().toString());
+        return buf.toString();
+    }
+    private String getFilterByTagLink(HttpServletRequest req, ThreadNode node, User user, String tag) { 
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI()).append('?');
+        /*
+        if (node.getChildCount() > 0) {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            ThreadNode child = node.getChild(0);
+            buf.append(child.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(child.getEntry().getEntryId()).append('&');
+        } else {
+            buf.append('?').append(PARAM_VISIBLE).append('=');
+            buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(node.getEntry().getEntryId()).append('&');
+        }
+         */
+        if (node != null) {
+            buf.append(PARAM_VIEW_POST).append('=');
+            buf.append(node.getEntry().getKeyHash().toBase64()).append('/');
+            buf.append(node.getEntry().getEntryId()).append('&');
+        }
+        
+        //if (!empty(req, PARAM_OFFSET))
+        //    buf.append(PARAM_OFFSET).append('=').append(req.getParameter(PARAM_OFFSET)).append('&');
+        
+        if ( (tag != null) && (tag.trim().length() > 0) )
+            buf.append(PARAM_TAGS).append('=').append(tag);
+        return buf.toString();
+    }
+    private String getNavLink(HttpServletRequest req, int offset) {
+        StringBuffer buf = new StringBuffer(64);
+        buf.append(req.getRequestURI());
+        buf.append('?');
+        if (!empty(req, PARAM_VIEW_POST))
+            buf.append(PARAM_VIEW_POST).append('=').append(req.getParameter(PARAM_VIEW_POST)).append('&');
+        else if (!empty(req, PARAM_VIEW_THREAD))
+            buf.append(PARAM_VIEW_THREAD).append('=').append(req.getParameter(PARAM_VIEW_THREAD)).append('&');
+        
+        if (!empty(req, PARAM_TAGS))
+            buf.append(PARAM_TAGS).append('=').append(req.getParameter(PARAM_TAGS)).append('&');
+        
+        buf.append(PARAM_OFFSET).append('=').append(offset).append('&');
+        
+        return buf.toString();
+    }
+    
+    
+    private void renderEnd(User user, HttpServletRequest req, PrintWriter out, ThreadIndex index) throws IOException {
+        out.write(END_HTML);
+    }
+
+    private static final String BEGIN_HTML = "<html>\n" +
+"<head>\n" +
+"<title>Syndie</title>\n" +
+"<style>\n" +
+".overallTable {\n" +
+"	border-spacing: 0px;\n" +
+"	border-width: 0px;\n" +
+"	border: 0px;\n" +
+"	margin: 0px;\n" +
+"	padding: 0px;\n" +
+"}\n" +
+".topNav {\n" +
+"	background-color: #BBBBBB;\n" +
+"}\n" +
+".topNav_user {\n" +
+"	text-align: left;\n" +
+"	float: left;\n" +
+"	align: left;\n" +
+"	display: inline;\n" +
+"}\n" +
+".topNav_admin {\n" +
+"	text-align: right;\n" +
+"	float: right;\n" +
+"	align: right;\n" +
+"	display: inline;\n" +
+"}\n" +
+".controlBar {\n" +
+"	background-color: #BBBBBB;\n" +
+"}\n" +
+".controlBarRight {\n" +
+"	text-align: right;\n" +
+"}\n" +
+".threadEven {\n" +
+"	background-color: #FFFFFF;\n" +
+"	white-space: nowrap;\n" +
+"}\n" +
+".threadOdd {\n" +
+"	background-color: #EEEEEE;\n" +
+"	white-space: nowrap;\n" +
+"}\n" +
+".threadLeft {\n" +
+"	text-align: left;\n" +
+"	align: left;\n" +
+"}\n" +
+".threadRight {\n" +
+"	text-align: right;\n" +
+"}\n" +
+".threadNav {\n" +
+"	background-color: #BBBBBB;\n" +
+"}\n" +
+".threadNavRight {\n" +
+"	text-align: right;\n" +
+"}\n" +
+".postMeta {\n" +
+"	background-color: #BBBBFF;\n" +
+"}\n" +
+".postMetaSubject {\n" +
+"	text-align: left;\n" +
+"}\n" +
+".postMetaLink {\n" +
+"	text-align: right;\n" +
+"}\n" +
+".postDetails {\n" +
+"	background-color: #DDDDFF;\n" +
+"}\n" +
+".postReply {\n" +
+"	background-color: #BBBBFF;\n" +
+"}\n" +
+".postReplyText {\n" +
+"	background-color: #BBBBFF;\n" +
+"}\n" +
+".postReplyOptions {\n" +
+"	background-color: #BBBBFF;\n" +
+"}\n" +
+"</style>\n" +
+"</head>\n" +
+"<body>\n" +
+"<table border=\"0\" width=\"100%\" class=\"overallTable\">\n";
+
+   private static final String CONTROL_BAR_WITHOUT_TAGS = "<form>\n" +
+"<tr class=\"controlBar\"><td colspan=\"2\">\n" +
+"<!-- control bar begin -->\n" +
+"Filter: <select disabled=\"true\" name=\"filter\">\n" +
+" <option value=\"all\">All posts in all threads</option>\n" +
+" <option value=\"self\">Threads you have posted in</option>\n" +
+" <option value=\"favorites\">Threads your friends have posted in</option>\n" +
+" </select>\n" +
+"<input type=\"submit\" name=\"action\" value=\"Go\" />\n" +
+"</td><td class=\"controlBarRight\"><a href=\"#threads\" title=\"Jump to the thread navigation\">Threads</a></td>\n" +
+"<!-- control bar end -->\n" +
+"</tr>\n" +
+"</form>\n";
+
+   private static final String BODY = "<!-- body begin -->\n" +
+"<!-- body meta begin -->\n" +
+"<tr class=\"postMeta\">\n" +
+" <td colspan=\"2\" class=\"postMetaSubject\">This is my subject</td>\n" +
+" <td class=\"postMetaLink\">\n" +
+" <a href=\"profile.jsp?blog=ovp\" title=\"View the author's profile\">petname</a> @ 2005/11/08\n" +
+" <a href=\"?permalink\" title=\"Select a sharable link directly to this post\">permalink</a>\n" +
+" </td>\n" +
+"</tr>\n" +
+"<!-- body meta end -->\n" +
+"<!-- body post begin -->\n" +
+"<tr class=\"postData\">\n" +
+" <td colspan=\"3\">\n" +
+" Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Vestibulum iaculis ante ac nisi. \n" +
+" Ut ut justo sed sem venenatis elementum. Donec in erat. Duis felis erat, adipiscing eget, mattis\n" + 
+" sed, volutpat nec, lorem. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur \n" +
+" ridiculus mus. Phasellus porta lacus ac metus. Suspendisse mi. Nulla facilisi. Phasellus metus. \n" +
+" Nam varius elit ut magna. Suspendisse lectus massa, tempus vel, malesuada et, dictum quis, arcu. \n" +
+" Ut auctor enim vel tellus.\n" +
+" </td>\n" +
+"</tr>\n" +
+"<!-- body post end -->\n" +
+"<!-- body details begin -->\n" +
+"<tr class=\"postDetails\">\n" +
+" <form action=\"viewattachment.jsp\" method=\"GET\">\n" +
+" <td colspan=\"3\">\n" +
+" External links:\n" +
+"  <a href=\"external.jsp?foo\" title=\"View foo.i2p\">http://foo.i2p/</a>\n" +
+"  <a href=\"external.jsp?bar\" title=\"View bar.i2p\">http://bar.i2p/</a>\n" +
+" <br />\n" +
+" Attachments: <select name=\"attachment\">\n" +
+"  <option value=\"0\">sampleRawSML.sml: Sample SML file with headers (4KB, type text/plain)</option>\n" +
+" </select> <input type=\"submit\" name=\"action\" value=\"Download\" />\n" +
+" <br /><a href=\"\" title=\"Expand the entire thread\">Full thread</a>\n" +
+" <a href=\"\" title=\"Previous post in the thread\">Prev in thread</a> \n" +
+" <a href=\"\" title=\"Next post in the thread\">Next in thread</a> \n" +
+" </td>\n" +
+" </form>\n" +
+"</tr>\n" +
+"<!-- body details end -->\n" +
+"<!-- body reply begin -->\n" +
+"<form action=\"post.jsp\" method=\"POST\">\n" +
+"<tr class=\"postReply\">\n" +
+" <td colspan=\"3\">Reply: (<a href=\"smlref.jsp\" title=\"SML cheatsheet\">SML reference</a>)</td>\n" +
+"</tr>\n" +
+"<tr class=\"postReplyText\">\n" +
+" <td colspan=\"3\"><textarea name=\"smltext\" rows=\"2\" cols=\"100\"></textarea></td>\n" +
+"</tr>\n" +
+"<tr class=\"postReplyOptions\">\n" +
+" <td colspan=\"3\">\n" +
+" <input type=\"submit\" value=\"Preview...\" name=\"action\" />\n" +
+" Tags: <input type=\"text\" size=\"10\" name=\"tags\" />\n" +
+" in a new thread? <input type=\"checkbox\" name=\"replyInNewThread\" />\n" +
+" allow replies? <input type=\"checkbox\" name=\"allowReplies\" checked=\"true\" />\n" +
+" attachment: <input type=\"file\" name=\"entryfile0\" />\n" +
+" </td>\n" +
+"</tr>\n" +
+"</form>\n" +
+"<!-- body reply end -->\n" +
+"<!-- body end -->\n";
+
+   
+   private static final String END_HTML = "</table>\n" +
+"</body>\n";
+   
+    private static class TreeRenderState {
+        private int _rowsWritten;
+        private int _rowsSkipped;
+        private List _ignored;
+        public TreeRenderState(List ignored) { 
+            _rowsWritten = 0; 
+            _rowsSkipped = 0;
+            _ignored = ignored;
+        }
+        public int getRowsWritten() { return _rowsWritten; }
+        public void incrementRowsWritten() { _rowsWritten++; }
+        public int getRowsSkipped() { return _rowsSkipped; }
+        public void incrementRowsSkipped() { _rowsSkipped++; }
+        public List getIgnoredAuthors() { return _ignored; }
+    }
+   
+}
diff --git a/apps/syndie/jsp/images/addToFavorites.png b/apps/syndie/jsp/images/addToFavorites.png
new file mode 100644
index 0000000000000000000000000000000000000000..95ded8d979a2c86a3a261636862795ab52fc2f3c
GIT binary patch
literal 275
zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u_bxCyD<C*!3BGlPX>x`7I;J!
zGca%qgD@k*tT_@u!Ofm7jv*44dnf4fH3tZ^>_4tKE9Clj4i1rJD<4YCX*3oP;ar!n
z)5-rsP;`KXfCW#UR@}n<nY&J=rJGN_c}C)$>QXx%!<fEzo7N{zmJ*&{IYBA@mgl3j
z<&%`^Lx1^IsV(34{NC0G?%TQ%+>c)r#4>L=AfVhj>4L=~oATm$vhQ}enqKkxb9bT5
zbG|MnzvWluYxx~F<ovEOHTHc`;NaP`>eR#6UlX4x&)eh@**5E$sodxM2j)8B2f|Zh
SzK8&w&*16m=d#Wzp$PyOlxc_n

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/addToIgnored.png b/apps/syndie/jsp/images/addToIgnored.png
new file mode 100644
index 0000000000000000000000000000000000000000..5b87bf45bc46333e5fffc775cf475e17620a9834
GIT binary patch
literal 266
zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u_bxCyD<C*!3BGlPX>x`7I;J!
zGca%qgD@k*tT_@u!Bw6vjv*44dnXujH9Ls7%%7)pL@!~!@|v}=QvDATb@sNn->^M6
z>E5YxhnPFgF8T4QO~A>H=l^D&&1X*EaDOFpM%PCETao;OOR1649?On-E>*D$@yfb0
z^UK{YdGBKK?)M(yaAh<+wCRQ|4}-G7c8{$pTXa^u&t1Rz?&Xpt*Q|Q?hiY#xGgEX3
zo$2%{i>1%#p7QgcuHqBbDtA*}l-b-{{_J+z?cc9E)qjis;5J%&P~6XEV++vL44$rj
JF6*2UngBB|Yc>D?

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/collapse.png b/apps/syndie/jsp/images/collapse.png
new file mode 100644
index 0000000000000000000000000000000000000000..2ce31b36d88ff5b3a23caaeb92cd9b3a5557fcce
GIT binary patch
literal 917
zcmc(eziO0G5XH|LWaBO=f{h9RdmCXMKqI?m7j+3Sja^WfS}=uatrQDwNAe75MN3<!
z6@3CLTPu<AH}@sHuz%*x%$ak}eBYbH&B4y>#!S-AdbK#>xBt(!xA^}2>&`pr(%-{}
z$4kk%kwQwTWRjW8Wg$yhb>2<x@{p&zx;9MVicqAYx;0Jd%21}VKvJ2?RiR210oXu;
zMKjgabNaYhn59|uSR!}xFi-Pp79_(h!XhoINl}|_8J1~TaHQ4Bt->m;m<nsaAPq}s
zkz+8}K!ZhdwV;kU;hyf*`WQ<T;gKHIa$!L-!!tb#^^l_0E4<PRdH4ZMy0HWOkU$}<
z0fRIwDTN1v!3G+vYUesl%*lvqBhklLqRhy`SLDKiWK>4sBTi!gyeP+L)M6!mK$C9l
zfD?jI2y4I~O((SA!C<g~u3k}BlP2aA8^cKSF_tK+dVyp8&0=h{_c@l-q=xUk-eF~P
z9PaeChv23>H1zTZtyVB50nOvUmM{-ZV-z?4;oSdyan7?pIX&7u*4>{!`c;@beQ>bQ
z>&ts9f-BEgPhLp#tNoei!}&EXr>E;j%jviIboctL&C8E(x!PSX_K&x}KRdZOx&39b
Yb;jHERr9@1@Bfh0dbwF#+&g*o4`Ex`jQ{`u

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/expand.png b/apps/syndie/jsp/images/expand.png
new file mode 100644
index 0000000000000000000000000000000000000000..95c0c4c81a2db8c4d58842c15e1a0d08148fab9b
GIT binary patch
literal 922
zcmc(ey=s(U5QWbwvT;KSu}KycTMJQ`3-E_svWvQem_}?Cm1%4Q7wiK8#llXQ;5B#w
zUc*>QY;CM81UsAYneQfiVSnbGnKS2{`M!4ttNoqHbRubIxtJaD+y5t9=lTBf>&^%1
z+~0$TM{~)gkWwm{WF~W2$Wm6Fbd$S0<SDPN4O6%x6sf3gO;frul&LI`RHkxOs8U4$
zHqc<vOm+2~K5iCfX;wX!$lW~5)4ZAm$#9FXNQ-Jx)TUd8Wm*;-X|-~zuu3bY!WuA0
z!xCEL7z{ShV9{JHsAEpJr+c+N#u7z%q(`+}Sdh%{OwU3+q^R`@uk=D5en69M>_9&x
zPzY<lAPq}O;lW_Afd;GExlR*vGNRf@^f8twGqUg%xv(G^l~MSJ(-;6R$}t+XScxCd
zq#HZngkTiH8Zb!H2`zXq7;K=cSJc&{i8;l_FcN)?CCaK^;IRH?F>JK=IV`D34Zin!
z2P>Q7;7)IQ5Zsgp4ZZw<Rx21L0nOvUmM{-Z!zgb4!#UZEzw$DSHixUny8H7x&y7aU
z9_-Kb_QKT#!NpgLC$FXL%l#SY^wTbv<IVEXeEe;Dd}(^){nL{dT<tAq_l~yCp09QM
k@@#jlTYsikuG{kVNN;{$+dKVn^CLxC&R4U~_t(e&0NsJy6aWAK

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/favorites.png b/apps/syndie/jsp/images/favorites.png
new file mode 100644
index 0000000000000000000000000000000000000000..5fa5a83bb3356281a22a77ba14d2eb1f3fc5fe66
GIT binary patch
literal 463
zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u_bxCyD<C*!3BGlPX>x`7I;J!
zGca%qgD@k*tT_@uLG}_)Usv|4+?>q3(mRU+;u#niV?13PLnJPT_MO*mE@U`n|2{*n
z`efWT^UZsud5=!BD0ue7{ST{N*33CEcVsOD#dogTQ5f)#;b>&Evf;+^YV+!|72Q#X
z?yWy8G_UH_=PSiOJa!!64B#k_E-G0d5olW_-Vn)mZ@GT0`1bO(`3}olyqB;qzIo--
zrFU~KXMQl3m#^DaCiXq(<pl+=1+#3d`nsJis%wcpjpufqZ(-HPts3BK)pX0dT_^oQ
zX}8A_rP*6oTzyqjmZf-F5+uC%Y;Ki`r@)n*u<%?b#o5!QS@HAU_L#H)C@fW9Bf^@r
zF@App&%OH#BUa@f-~3dUSw(2W{{PiFQxlRlojX5m`<)r5clVz6KWl7U?N@W~nHsZg
zqJ>4nSzdKfZ{_Lb(!5vKZ~ZyxX062AdKp>ie5dQ7pIE1^%oI((%<)=U`}Xwkxpth>
zD;V@l{%1SKub2F`O`5UcxO)A+Uv7KPN}e@U_{3ZDYnr6s<tAXrGcb6%`njxgN@xNA
D9^SlV

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/noSubthread.png b/apps/syndie/jsp/images/noSubthread.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ea13d3a131c793fc3cabe91a9861d69767a2164
GIT binary patch
literal 129
zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u_bxCyD<C*!3BGlPX>x`7I;J!
zGca%qgD@k*tT_@uLG}_)Usv|4-0Xsq5+7Gwn+p`;^K@|xk+__kAi>%URLeMvfsuRB
RTzQ}jgQu&X%Q~loCIIJ19ZvuN

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/images/threadIndent.png b/apps/syndie/jsp/images/threadIndent.png
new file mode 100644
index 0000000000000000000000000000000000000000..3ea13d3a131c793fc3cabe91a9861d69767a2164
GIT binary patch
literal 129
zcmeAS@N?(olHy`uVBq!ia0vp^AT}2V8<6ZZI=>f4u_bxCyD<C*!3BGlPX>x`7I;J!
zGca%qgD@k*tT_@uLG}_)Usv|4-0Xsq5+7Gwn+p`;^K@|xk+__kAi>%URLeMvfsuRB
RTzQ}jgQu&X%Q~loCIIJ19ZvuN

literal 0
HcmV?d00001

diff --git a/apps/syndie/jsp/index.html b/apps/syndie/jsp/index.html
new file mode 100644
index 0000000000..90c07f0777
--- /dev/null
+++ b/apps/syndie/jsp/index.html
@@ -0,0 +1,3 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"><html
+><head><title>Syndie</title></head
+><body><meta http-equiv="refresh" content="0;url=index.jsp" /><a href="index.jsp">Enter</a></body></html>
diff --git a/apps/syndie/jsp/switchuser.jsp b/apps/syndie/jsp/switchuser.jsp
new file mode 100644
index 0000000000..64a31219f8
--- /dev/null
+++ b/apps/syndie/jsp/switchuser.jsp
@@ -0,0 +1,16 @@
+<%@page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="net.i2p.syndie.web.*" %><% 
+request.setCharacterEncoding("UTF-8"); 
+%><!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 TRANSITIONAL//EN" "http://www.w3c.org/TR/1999/REC-html401-19991224/loose.dtd">
+<html>
+<head>
+<title>Syndie</title>
+<link href="style.jsp" rel="stylesheet" type="text/css" >
+</head>
+<body>
+<form action="threads.jsp" method="GET">
+Syndie login: <input type="text" name="login" /><br />
+Password: <input type="password" name="password" /><br />
+<input type="submit" name="action" value="Login" />
+<input type="submit" name="action" value="Cancel" />
+</form>
+</body>
\ No newline at end of file
diff --git a/apps/syndie/jsp/web.xml b/apps/syndie/jsp/web.xml
index 1f5798c835..acdb2e131b 100644
--- a/apps/syndie/jsp/web.xml
+++ b/apps/syndie/jsp/web.xml
@@ -14,18 +14,25 @@
      <servlet-class>net.i2p.syndie.web.RSSServlet</servlet-class>
     </servlet>
      
+    <servlet>
+     <servlet-name>net.i2p.syndie.web.ViewThreadedServlet</servlet-name>
+     <servlet-class>net.i2p.syndie.web.ViewThreadedServlet</servlet-class>
+    </servlet>
+     
     <servlet>
 	 <servlet-name>net.i2p.syndie.UpdaterServlet</servlet-name>
 	 <servlet-class>net.i2p.syndie.UpdaterServlet</servlet-class>
-     <load-on-startup>1</load-on-startup>
-	</servlet>
+         <load-on-startup>1</load-on-startup>
+    </servlet>
      
     <!-- precompiled servlets -->
     
+    <!--
     <servlet-mapping> 
       <servlet-name>net.i2p.syndie.jsp.index_jsp</servlet-name>
       <url-pattern>/</url-pattern>
     </servlet-mapping>
+    -->
     <servlet-mapping> 
       <servlet-name>net.i2p.syndie.web.ArchiveServlet</servlet-name>
       <url-pattern>/archive/*</url-pattern>
@@ -34,6 +41,10 @@
       <servlet-name>net.i2p.syndie.web.RSSServlet</servlet-name>
       <url-pattern>/rss.jsp</url-pattern>
     </servlet-mapping>
+    <servlet-mapping> 
+      <servlet-name>net.i2p.syndie.web.ViewThreadedServlet</servlet-name>
+      <url-pattern>/threads.jsp</url-pattern>
+    </servlet-mapping>
     
     <session-config>
         <session-timeout>
-- 
GitLab