/** * 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 org.waveprotocol.box.server.rpc.render.web.template; import com.google.common.base.Joiner; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; 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.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 org.waveprotocol.box.server.rpc.render.ClientAction; import org.waveprotocol.box.server.rpc.render.web.text.ContentRenderer; import org.waveprotocol.box.server.rpc.render.web.text.Markup; import java.io.IOException; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; /** * 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 public class ThreadedWaveRenderer implements SplashWaveRenderer { private static final Set<String> HIDDEN_PARTICIPANTS = ImmutableSet.of("public"); private final boolean isReadOnly; private final int charsPerPage; private final ContentRenderer renderer; private final ProfileStore profileStore; private final Templates templates; // Ugly, but we do this to avoid polluting all the rendering methods. =( private final ThreadLocal<PageTracker> currentPage = new ThreadLocal<PageTracker>(); @Inject public ThreadedWaveRenderer(Templates templates, ContentRenderer renderer, ProfileStore profileStore) { this.templates = templates; this.profileStore = profileStore; this.isReadOnly = true; this.charsPerPage = 8000; this.renderer = renderer; } private class PageTracker { // Whether or not we're trying to deliver the first page. private final boolean firstPage; private int counter; private final List<Integer> markers = Lists.newArrayList(); // The wavelet we're trying to render in this page. private final Wavelet wavelet; public PageTracker(int page, Wavelet wavelet) { this.wavelet = wavelet; firstPage = (page == 0); } /** * Returns true if a page boundary was crossed. */ public boolean track(StringBuilder builder) { 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; } public boolean hasPages() { return !markers.isEmpty(); } public int marker(int page) { return markers.get(page); } } /** * 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 * @param builder The current HTML content StringBuilder of the wave so far */ @Override public void renderInlineReply(Element element, int index, StringBuilder builder) { PageTracker pageTracker = currentPage.get(); 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(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> "); builder.append("</span>"); // Close inline-reply } } // TODO(dhanji): This is expensive, see if we can precompute branch size // when constructing the thread tree. private static int sizeOfThreadTree(BlipThread inlineReplyThread) { List<Blip> blips = inlineReplyThread.getBlips(); int size = blips.size(); for (Blip blip : blips) { for (BlipThread thread : blip.getReplyThreads()) { size += thread.getBlipIds().size(); } } return size; } /** * * @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(Wavelet wavelet, int page) { Preconditions.checkState(null == currentPage.get(), "A page render is already in progress (this is an algorithm bug)"); StringBuilder builder = new StringBuilder(); Blip rootBlip = wavelet.getRootBlip(); // The pagetracker tracks every page worth of HTML rendered. PageTracker pageTracker = new PageTracker(page, wavelet); currentPage.set(pageTracker); try { return renderInternal(wavelet, page, builder, rootBlip, pageTracker); } finally { currentPage.remove(); } } private ClientAction renderInternal(Wavelet wavelet, int page, StringBuilder builder, Blip rootBlip, PageTracker pageTracker) { boolean stopRender = renderThreads(wavelet.getRootThread(), builder, pageTracker); String html = builder.toString(); 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. int start = pageTracker.marker(0); html = builder.substring(start); } } return new ClientAction("update-wave").version(wavelet.getLastModifiedTime()).html(html); } /** * This method renders the blip thread hierarchy using the new conversation * structure. * * @return true if we should stop rendering because a page boundary was * reached. */ boolean renderThreads(BlipThread thread, StringBuilder outer, PageTracker pageTracker) { List<Blip> blipsInThread = thread.getBlips(); StringBuilder inner = new StringBuilder(); inner.append("<div id='threadContents' class='chrome expanded'>"); for (Blip blip : blipsInThread) { if (renderBlip(blip, inner, "", pageTracker)) { return true; } // If this blip has any reply threads, they should be rendered indented. if (blip.getInlineReplyThreads().size() > 0) { int totalShift = 0; for (BlipThread childThread : blip.getInlineReplyThreads()) { StringBuilder inline = new StringBuilder(); try { inline.append(Templates.convertStreamToString(templates.openResource(Templates.ANCHOR_TEMPLATE))); } catch (IOException e) { e.printStackTrace(); } boolean isDone = renderThreads(childThread, inline, pageTracker); inline.append("</div>"); if (isDone) { inner.append("</div>"); outer.append(inner); return true; } totalShift += inline.length(); } inner.append("</div>"); outer.append(inner); } else { inner.append("</div>"); outer.append(inner); } } return false; } /** * 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(List<ParticipantProfile> profiles) { int max = Math.min(10, profiles.size()); List<ParticipantProfile> renderedParticipants = Lists.newArrayListWithExpectedSize(max); for (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); } String el = "<div>" + Joiner.on(",").join(renderedParticipants) + "</div>"; return new ClientAction("update-header").html(el); } private boolean renderBlip(Blip blip, StringBuilder builder, String title, PageTracker pageTracker) { builder.append(toHtml(blip)); // At the end of each blip, see if we've passed a page worth of content. return pageTracker.track(builder); } /** * 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(Blip blipData) { List<String> contributors = blipData.getContributors(); // TODO (Yuri Z.) Need to send also author image and name etc.. // List<ParticipantProfile> authors = loadProfiles(contributors); Map<String, Object> blip = Maps.newHashMap(); blip.put("id", blipData.getBlipId()); String authorIds = Joiner.on(",").join(contributors); blip.put("authorString", authorIds); blip.put("time", Markup.formatDateTime(blipData.getLastModifiedTime())); blip.put("content", renderContent(blipData)); return renderBlipTemplate(blip); } String renderBlipTemplate(Map<String, Object> blip) { return templates.process( Templates.BLIP_TEMPLATE, new String[] {String.valueOf("\'" + blip.get("id") + "\'"), "\'/templates/unknown.png\'", "\'unknown\'", String.valueOf(blip.get("time")), String.valueOf(blip.get("authorString")), String.valueOf(blip.get("content"))}); } List<ParticipantProfile> loadProfiles(Collection<String> participants) { ImmutableList.Builder<ParticipantProfile> result = ImmutableList.builder(); Map<String, ParticipantProfile> profiles = profileStore.getProfiles(participants); for (String address : participants) { result.add(profiles.get(address)); } return result.build(); } @Override public ClientAction renderNotFound() { // TODO: This should be an alert message once that system is // implemented. return new ClientAction("update-wave").html("NOT FOUND"); } private String renderContent(Blip blip) { return renderer.renderHtml(blip.getContent(), blip.getAnnotations(), blip.getElements(), blip.getContributors()); } }