/**
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package com.google.wave.splash.web.template;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.waveprotocol.box.server.CoreSettings;
import cc.kune.common.shared.utils.Url;
import cc.kune.common.shared.utils.UrlParam;
import cc.kune.core.shared.FileConstants;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import com.google.inject.name.Named;
import com.google.wave.api.Blip;
import com.google.wave.api.BlipThread;
import com.google.wave.api.Element;
import com.google.wave.api.ParticipantProfile;
import com.google.wave.api.Wavelet;
import com.google.wave.splash.rpc.ClientAction;
import com.google.wave.splash.text.ContentRenderer;
import com.google.wave.splash.text.Markup;
// TODO: Auto-generated Javadoc
/**
* Does the actual conversion of a wavelet/blipdata tree into html, using the
* conversation-thread model, optionally falling back to the blip-hierarchy
* model, if so configured.
*
* @author dhanji@gmail.com (Dhanji R. Prasanna)
*/
@Singleton
class ThreadedWaveRenderer implements WaveRenderer {
/**
* The Class PageTracker.
*
* @author vjrj@ourproject.org (Vicente J. Ruiz Jurado)
*/
private class PageTracker {
/** The counter. */
private int counter;
// Whether or not we're trying to deliver the first page.
/** The first page. */
private final boolean firstPage;
/** The markers. */
private final List<Integer> markers = Lists.newArrayList();
/**
* This is an alternate output string which will wrap all html content that
* is not immediately displayed. This is useful for inline replies that need
* to be moved in and out of the appropriate part of the DOM.
*/
private final StringBuilder purgatory = new StringBuilder();
// The wavelet we're trying to render in this page.
/** The wavelet. */
private final Wavelet wavelet;
/**
* Instantiates a new page tracker.
*
* @param page the page
* @param wavelet the wavelet
*/
public PageTracker(final int page, final Wavelet wavelet) {
this.wavelet = wavelet;
firstPage = (page == 0);
// Start purgatory (will be ended by #render)
purgatory.append("<div id=\"purgatory\">");
}
/**
* Checks for pages.
*
* @return true, if successful
*/
public boolean hasPages() {
return !markers.isEmpty();
}
/**
* Marker.
*
* @param page the page
* @return the int
*/
public int marker(final int page) {
return markers.get(page);
}
/**
* Purgatory element.
*
* @return the string
*/
public String purgatoryElement() {
return purgatory.append("</div>").toString();
}
/**
* Returns true if a page boundary was crossed.
*
* @param builder the builder
* @return true, if successful
*/
public boolean track(final StringBuilder builder) {
final int length = builder.length();
if (length - counter >= charsPerPage) {
markers.add(length);
counter = length;
// If this is the first page, short circuit the rendering
// process so we can deliver the page faster.
if (firstPage) {
return true;
}
}
return false;
}
}
/** The Constant HIDDEN_PARTICIPANTS. */
private static final Set<String> HIDDEN_PARTICIPANTS = ImmutableSet.of("public");
// TODO(dhanji): This is expensive, see if we can precompute branch size
// when constructing the thread tree.
/**
* Size of thread tree.
*
* @param inlineReplyThread the inline reply thread
* @return the int
*/
private static int sizeOfThreadTree(final BlipThread inlineReplyThread) {
final List<Blip> blips = inlineReplyThread.getBlips();
int size = blips.size();
for (final Blip blip : blips) {
for (final BlipThread thread : blip.getReplyThreads()) {
size += thread.getBlipIds().size();
}
}
return size;
}
/** The chars per page. */
private final int charsPerPage;
// Ugly, but we do this to avoid polluting all the rendering methods. =(
/** The current page. */
private final ThreadLocal<PageTracker> currentPage = new ThreadLocal<PageTracker>();
/** The domain. */
private final String domain;
/** The is read only. */
private final boolean isReadOnly;
/** The renderer. */
private final ContentRenderer renderer;
/** The templates. */
private final Templates templates;
/**
* Instantiates a new threaded wave renderer.
*
* @param templates the templates
* @param renderer the renderer
* @param domain the domain
*/
@Inject
public ThreadedWaveRenderer(final Templates templates, final ContentRenderer renderer,
@Named(CoreSettings.WAVE_SERVER_DOMAIN) final String domain) {
this.templates = templates;
this.domain = domain;
// vjrj: manual setted
this.isReadOnly = true;
this.charsPerPage = 100000;
this.renderer = renderer;
}
/**
* Gets the avatar url.
*
* @param address the address
* @return the avatar url
*/
private String getAvatarUrl(final String address) {
String avatar = "";
if (address.contains(domain)) {
avatar = new Url(FileConstants.LOGODOWNLOADSERVLET, new UrlParam(FileConstants.TOKEN,
address.split("@")[0]), new UrlParam(FileConstants.ONLY_USERS, true)).toString();
} else {
avatar = FileConstants.PERSON_NO_AVATAR_IMAGE;
}
return avatar;
}
/**
* Gets the profiles.
*
* @param participants the participants
* @return the profiles
*/
private Map<String, ParticipantProfile> getProfiles(final Collection<String> participants) {
final HashMap<String, ParticipantProfile> profiles = new HashMap<String, ParticipantProfile>();
for (final String address : participants) {
final ParticipantProfile profile = new ParticipantProfile(address, address.split("@")[0],
getAvatarUrl(address), "");
profiles.put(address, profile);
}
return profiles;
}
/**
* Load profiles.
*
* @param participants the participants
* @return the list
*/
List<ParticipantProfile> loadProfiles(final Collection<String> participants) {
final ImmutableList.Builder<ParticipantProfile> result = ImmutableList.builder();
final Map<String, ParticipantProfile> profiles = getProfiles(participants);
for (final String address : participants) {
result.add(profiles.get(address));
}
return result.build();
}
/**
* Render.
*
* @param wavelet A wavelet to render as a single html blob.
* @param page The page number to send back. Use this to implement paging, if you
* specify page 1, the client action will only contain the second
* page as computed during the current render.
* @return the client action.
*/
@Override
public ClientAction render(final Wavelet wavelet, final int page) {
Preconditions.checkState(null == currentPage.get(),
"A page render is already in progress (this is an algorithm bug)");
final StringBuilder builder = new StringBuilder();
final Blip rootBlip = wavelet.getRootBlip();
// The pagetracker tracks every page worth of HTML rendered.
final PageTracker pageTracker = new PageTracker(page, wavelet);
currentPage.set(pageTracker);
try {
return renderInternal(wavelet, page, builder, rootBlip, pageTracker);
} finally {
currentPage.remove();
}
}
/**
* Render blip.
*
* @param blip the blip
* @param builder the builder
* @param title the title
* @param pageTracker the page tracker
* @return true, if successful
*/
private boolean renderBlip(final Blip blip, final StringBuilder builder, final String title,
final PageTracker pageTracker) {
builder.append("<div class='blip' id='");
builder.append(Markup.toDomId(blip.getBlipId()));
builder.append("'>");
builder.append(toHtml(blip, title));
builder.append("</div>");
// At the end of each blip, see if we've passed a page worth of content.
return pageTracker.track(builder);
}
/**
* Render blip template.
*
* @param blip the blip
* @return the string
*/
String renderBlipTemplate(final Map<String, Object> blip) {
return templates.process(Templates.BLIP_TEMPLATE, blip);
}
/**
* Render content.
*
* @param blip the blip
* @return the string
*/
private String renderContent(final Blip blip) {
return renderer.renderHtml(blip.getContent(), blip.getAnnotations(), blip.getElements(),
blip.getContributors());
}
/**
* Renders the header of a wavelet, including participants.
*
* @param profiles
* A list of profiles for each participant in the wave, in correct
* order.
* @return A {@code ClientAction} that inserts the rendered participant list
* in the header portion of the DOM.
*/
@Override
public ClientAction renderHeader(final List<ParticipantProfile> profiles) {
final Map<String, Object> context = Maps.newHashMap();
final int max = Math.min(10, profiles.size());
final List<ParticipantProfile> renderedParticipants = Lists.newArrayListWithExpectedSize(max);
for (final ParticipantProfile p : profiles) {
// TODO: For some reason the address is an empty string, so
// we use name here instead.
if (HIDDEN_PARTICIPANTS.contains(p.getName())) {
continue;
}
if (renderedParticipants.size() >= max) {
break;
}
renderedParticipants.add(p);
}
context.put("participants", renderedParticipants);
return new ClientAction("update-header").html(templates.process(Templates.HEADER_TEMPLATE, context));
}
/**
* Renders an inline reply thread at the correct offset location inside a
* blip.
*
* @param element The element representing the position of the offset inline reply
* @param index the index
* @param builder The current HTML content StringBuilder of the wave so far
*/
@Override
public void renderInlineReply(final Element element, final int index, final StringBuilder builder) {
final PageTracker pageTracker = currentPage.get();
final BlipThread inlineReplyThread = pageTracker.wavelet.getThread(element.getProperty("id"));
// inlineReplyThread can be null if a sub-thread was completely deleted.
// There's still
// an entry left behind in the conversation that points to nothing.
if (inlineReplyThread != null && !inlineReplyThread.getBlipIds().isEmpty()) {
builder.append(" <span class=\"inline-reply\" ir-id=\"");
builder.append(Markup.toDomId(inlineReplyThread.getId()));
builder.append("\"><span class=\"count\" title=\"Click to expand inline replies\"><span class=\"count-inner\">");
builder.append(sizeOfThreadTree(inlineReplyThread));
builder.append("</span><span class=\"pointer\"></span></span> ");
// Render this thread into purgatory, it will be transferred to the
// appropriate
// spot by the JS code. This is needed to prevent the browser from trying
// to pre-emptively "correct" our dom structure (and thus ruin it).
pageTracker.purgatory.append("<div class=\"inline-reply-content\" id=\"ir-");
pageTracker.purgatory.append(Markup.toDomId(inlineReplyThread.getId()));
pageTracker.purgatory.append("\"><div class=\"inline-reply-content-inner\">");
renderThreads(inlineReplyThread, pageTracker.purgatory, pageTracker);
pageTracker.purgatory.append("</div></div>");
builder.append("</span>"); // Close inline-reply
}
}
/**
* Render internal.
*
* @param wavelet the wavelet
* @param page the page
* @param builder the builder
* @param rootBlip the root blip
* @param pageTracker the page tracker
* @return the client action
*/
private ClientAction renderInternal(final Wavelet wavelet, final int page,
final StringBuilder builder, final Blip rootBlip, final PageTracker pageTracker) {
final boolean stopRender = renderThreads(wavelet.getRootThread(), builder, pageTracker);
String html;
if (page != ALL_PAGES && (stopRender || pageTracker.hasPages())) {
if (page == 0) {
builder.append("<img id=\"wave-loading\" src=\"images/wave-loading.gif\">");
html = builder.toString();
} else {
// If this is a request for the rest of the wave, start from end of page
// 0 and get
// the rest.
final int start = pageTracker.marker(0);
// Append purgatory--which contains all inline reply threads
builder.append(pageTracker.purgatoryElement());
html = builder.substring(start);
}
} else {
// Append purgatory--which contains all inline reply threads
builder.append(pageTracker.purgatoryElement());
html = builder.toString();
}
return new ClientAction("update-wave").version(wavelet.getLastModifiedTime()).html(html);
}
/* (non-Javadoc)
* @see com.google.wave.splash.web.template.WaveRenderer#renderNotFound()
*/
@Override
public ClientAction renderNotFound() {
// TODO: This should be an alert message once that system is
// implemented.
return new ClientAction("update-wave").html(templates.process(Templates.WAVE_NOT_FOUND_TEMPLATE,
ImmutableMap.of()));
}
/**
* This method renders the blip thread hierarchy using the new conversation
* structure.
*
* @param thread the thread
* @param builder the builder
* @param pageTracker the page tracker
* @return true if we should stop rendering because a page boundary was
* reached.
*/
boolean renderThreads(final BlipThread thread, final StringBuilder builder,
final PageTracker pageTracker) {
builder.append("<div class=\"thread\" id=\"");
builder.append(Markup.toDomId(thread.getId()));
builder.append("\">");
final List<Blip> blipsInThread = thread.getBlips();
for (final Blip blip : blipsInThread) {
if (renderBlip(blip, builder, "", pageTracker)) {
return true;
}
// If this blip has any reply threads, they should be rendered indented.
if (blip.getReplyThreads().size() > 0) {
builder.append("<div class=\"indent\">");
for (final BlipThread childThread : blip.getReplyThreads()) {
if (renderThreads(childThread, builder, pageTracker)) {
return true;
}
}
builder.append("</div>");
}
}
builder.append("</div>");
return false;
}
/**
* Renders the content of a blip as html. If a title is specified, renders
* that specially as the root blip.
*
* @param blipData
* The blip whose content you want to render
* @param title
* The title string if this is a root blip or null
* @return Rendered HTML string with markup
*/
@Override
public String toHtml(final Blip blipData, final String title) {
final List<String> contributors = blipData.getContributors();
final List<ParticipantProfile> authors = loadProfiles(contributors);
final Map<String, Object> blip = Maps.newHashMap();
blip.put("id", Markup.toDomId(blipData.getBlipId()));
final StringBuilder authorString = new StringBuilder();
final int numberOfAuthors = authors.size();
final int len = Math.min(3, numberOfAuthors);
if (blipData.isRoot()) {
blip.put("authorCountClass", "author-count-root");
} else {
for (int i = 0; i < len; i++) {
authorString.append("<div class='authorbar ");
if (i == 0) {
authorString.append("first");
}
authorString.append("'><div class=\"avatar\"><img src=\"");
final ParticipantProfile author = authors.get(i);
authorString.append(author.getImageUrl());
authorString.append("\" alt=\"");
final String name = author.getName();
authorString.append(name);
authorString.append("\"><span class=\"name\" title=\"");
authorString.append(name);
authorString.append("\">");
authorString.append(name);
authorString.append("</span></div></div>");
}
if (numberOfAuthors > 3) {
authorString.append("<div class=\"authorbar\">");
authorString.append("<div class=\"author-more\">+");
authorString.append(numberOfAuthors);
authorString.append(" others</div>");
authorString.append("</div>");
blip.put("authorCountClass", "author-count-many");
} else {
blip.put("authorCountClass", "author-count-" + contributors.size());
}
}
blip.put("authorString", authorString.toString());
blip.put("time", Markup.formatDateTime(blipData.getLastModifiedTime()));
blip.put("title", Markup.sanitize(title));
blip.put("content", renderContent(blipData));
blip.put("readonly", isReadOnly);
return renderBlipTemplate(blip);
}
}