/** * Copyright 2009 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.wave.model.conversation; import com.google.common.annotations.VisibleForTesting; import org.waveprotocol.wave.model.conversation.WaveletBasedConversation.ComponentHelper; import org.waveprotocol.wave.model.document.Document; import org.waveprotocol.wave.model.document.MutableDocument; import org.waveprotocol.wave.model.document.MutableDocument.Action; import org.waveprotocol.wave.model.document.util.DocIterate; import org.waveprotocol.wave.model.document.util.Point; import org.waveprotocol.wave.model.util.CollectionUtils; import org.waveprotocol.wave.model.util.CopyOnWriteSet; import org.waveprotocol.wave.model.util.Preconditions; import org.waveprotocol.wave.model.util.StringMap; import org.waveprotocol.wave.model.wave.Blip; import org.waveprotocol.wave.model.wave.ObservableWavelet; import org.waveprotocol.wave.model.wave.ParticipantId; import org.waveprotocol.wave.model.wave.SourcesEvents; import org.waveprotocol.wave.model.wave.WaveletListener; import org.waveprotocol.wave.model.wave.opbased.WaveletListenerImpl; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; /** * A conversation blip backed by a region of a wavelet's manifest document. * * NOTE(anorth): at present this in in fact backed by a {@link Blip} but we are * migrating all blip meta-data into documents. * * @author anorth@google.com (Alex North) */ final class WaveletBasedConversationBlip implements ObservableConversationBlip, SourcesEvents<WaveletBasedConversationBlip.Listener>, ObservableManifestBlip.Listener { /** * Receives events on a conversation blip. */ interface Listener { /** * Notifies this listener that a reply thread has been added to this blip. * * @param reply the new thread */ void onReplyAdded(WaveletBasedConversationThread reply); /** * Notifies this listener that a reply thread has been added to this blip. * * @param reply the new thread * @param location the location at which the thread is anchored */ void onInlineReplyAdded(WaveletBasedConversationThread reply, int location); /** * Notifies this listener that the blip was removed from the conversation. * No further methods may be called on the blip. */ void onDeleted(); /** * Notifies this listener that a contributor was added to the blip. */ void onContributorAdded(ParticipantId contributor); /** * Notifies this listener that a contributor was removed from the blip. */ void onContributorRemoved(ParticipantId contributor); /** * Notifies the listener that the blip was submitted. */ void onSumbitted(); /** * Notifies the listener that the blip timestamp changed. */ void onTimestampChanged(long oldTimestamp, long newTimestamp); } /** * A located reply thread which specializes the thread type to * WaveletBasedConversationThread. */ final class LocatedReplyThread extends ConversationBlip.LocatedReplyThread<WaveletBasedConversationThread> implements Comparable<LocatedReplyThread> { LocatedReplyThread(WaveletBasedConversationThread thread, int location) { super(thread, location); } @Override public int compareTo(WaveletBasedConversationBlip.LocatedReplyThread o) { if (getLocation() == o.getLocation()) { return 0; } else if (getLocation() == Blips.INVALID_INLINE_LOCATION) { return 1; } else if (o.getLocation() == Blips.INVALID_INLINE_LOCATION) { return -1; } return getLocation() - o.getLocation(); } } /** * Redirects old-style blip events to the conversation listeners. */ private final WaveletListener waveletListener = new WaveletListenerImpl() { @Override public void onBlipContributorAdded(ObservableWavelet wavelet, Blip eventBlip, ParticipantId contributor) { if (eventBlip == blip) { triggerOnContributorAdded(contributor); } } @Override public void onBlipContributorRemoved(ObservableWavelet wavelet, Blip eventBlip, ParticipantId contributor) { if (eventBlip == blip) { triggerOnContributorRemoved(contributor); } } @Override public void onBlipSubmitted(ObservableWavelet wavelet, Blip eventBlip) { if (eventBlip == blip) { triggerOnSubmitted(); } } @Override public void onBlipTimestampModified(ObservableWavelet wavelet, Blip eventBlip, long oldTime, long newTime) { if (eventBlip == blip) { triggerOnTimestampModified(oldTime, newTime); } } }; /** Manifest entry for this blip. */ private final ObservableManifestBlip manifestBlip; /** Blip object containing content and metadata. */ private final Blip blip; /** Thread containing this blip. */ private final WaveletBasedConversationThread parentThread; /** Helper for wavelet access. */ private final ComponentHelper helper; /** Replies keyed by id. */ private final StringMap<WaveletBasedConversationThread> replies = CollectionUtils.createStringMap(); /** Whether this blip is safe to access. Set false when deleted. */ private boolean isUsable = true; private final CopyOnWriteSet<Listener> listeners = CopyOnWriteSet.create(); static WaveletBasedConversationBlip create(ObservableManifestBlip manifestBlip, Blip backingBlip, WaveletBasedConversationThread thread, ComponentHelper helper) { WaveletBasedConversationBlip blip = new WaveletBasedConversationBlip(manifestBlip, backingBlip, thread, helper); for (ObservableManifestThread reply : manifestBlip.getReplies()) { blip.adaptThread(reply); } manifestBlip.addListener(blip); helper.getWaveletEventSource().addListener(blip.waveletListener); return blip; } private WaveletBasedConversationBlip(ObservableManifestBlip manifestBlip, Blip blip, WaveletBasedConversationThread thread, ComponentHelper helper) { Preconditions.checkNotNull(manifestBlip, "WaveletBasedConversationBlip received null manifest blip"); if (blip == null) { Preconditions.nullPointer("WaveletBasedConversationBlip " + manifestBlip.getId() + " received null blip"); } this.manifestBlip = manifestBlip; this.blip = blip; this.helper = helper; this.parentThread = thread; } @Override public WaveletBasedConversation getConversation() { return helper.getConversation(); } @Override public WaveletBasedConversationThread getThread() { return parentThread; } @Override public Iterable<LocatedReplyThread> locateReplyThreads() { // NOTE(anorth): We must recalculate the anchor locations on each // call as the document does not provide stable elements. However, we // calculate the list of anchor locations on demand. Map<String, Integer> replyLocations = null; List<LocatedReplyThread> inlineReplyThreads = CollectionUtils.newArrayList(); for (WaveletBasedConversationThread reply : getReplyThreads()) { if (replyLocations == null) { replyLocations = findAnchors(); } Integer location = replyLocations.get(reply.getId()); inlineReplyThreads.add(new LocatedReplyThread(reply, (location != null) ? location : Blips.INVALID_INLINE_LOCATION)); } Collections.sort(inlineReplyThreads); return Collections.unmodifiableList(inlineReplyThreads); } /** * {@inheritDoc} * * The 'history of appends' corresponds to the manifest order of replies. */ @Override public Iterable<WaveletBasedConversationThread> getReplyThreads() { final Iterable<? extends ObservableManifestThread> manifestBlips = manifestBlip.getReplies(); return new Iterable<WaveletBasedConversationThread>() { @Override public Iterator<WaveletBasedConversationThread> iterator() { return WrapperIterator.create(manifestBlips.iterator(), replies); } }; } @Override public WaveletBasedConversationThread addReplyThread() { checkIsUsable(); String id = helper.createThreadId(); manifestBlip.appendReply(id, false); return replies.get(id); } @Override public WaveletBasedConversationThread addReplyThread(final int location) { checkIsUsable(); final String threadId = helper.createThreadId(); createInlineReplyAnchor(threadId, location); manifestBlip.appendReply(threadId, true); return replies.get(threadId); } @Override public Document getContent() { return blip.getContent(); } // TODO(anorth): migrate blip metadata to data stored in the blip document. @Override public ParticipantId getAuthorId() { return blip.getAuthorId(); } @Override public Set<ParticipantId> getContributorIds() { return blip.getContributorIds(); } @Override public long getLastModifiedTime() { return blip.getLastModifiedTime(); } @Override public long getLastModifiedVersion() { return blip.getLastModifiedVersion(); } @Override public void delete() { checkIsUsable(); Collection<WaveletBasedConversationThread> allReplies = CollectionUtils.createQueue(); CollectionUtils.copyValuesToJavaCollection(replies, allReplies); // Delete reply threads. // TODO(anorth): Move this loop to WBCT, where it can delete all the // inline reply anchors in one pass. for (WaveletBasedConversationThread replyThread : allReplies) { deleteThread(replyThread); } // All replies have been deleted, so remove this empty blip. parentThread.deleteBlip(this, true); } @Override public String getId() { return manifestBlip.getId(); } @Override public boolean isRoot() { return parentThread == getConversation().getRootThread() && this == parentThread.getFirstBlip(); } @Override public void addListener(Listener listener) { listeners.add(listener); } @Override public void removeListener(Listener listener) { listeners.remove(listener); } // // ObservableManifestBlip.Listener // These methods update local data structures in response to changes in // the underlying data, either synchronously in local methods or from // remote changes. They don't make further changes to the data. // @Override public void onReplyAdded(ObservableManifestThread thread) { WaveletBasedConversationThread convThread = adaptThread(thread); triggerOnReplyAdded(convThread); } @Override public void onReplyRemoved(final ObservableManifestThread thread) { forgetThread(replies.get(thread.getId())); } @Override public String toString() { return "WaveletBasedConversationBlip(id = " + manifestBlip.getId() + ")"; } public Blip getBlip() { return blip; } // TODO(anorth): remove this after porting client to conversation model. @Override public Blip hackGetRaw() { return getBlip(); } /** * Deletes the content of this blip's document. */ void clearContent() { if (blip.getContent().size() != 0) { blip.getContent().emptyElement(blip.getContent().getDocumentElement()); } } @Override public ObservableConversationThread getReplyThread(String id) { return replies.get(id); } ManifestBlip getManifestBlip() { return manifestBlip; } // Package-private methods for WaveletBasedConversationThread. /** * Deletes a thread from this blip, deleting that thread's blips. */ void deleteThread(WaveletBasedConversationThread threadToDelete) { threadToDelete.deleteBlips(); manifestBlip.removeReply(threadToDelete.getManifestThread()); clearInlineReplyAnchor(threadToDelete.getId()); } /** * Deletes all threads from this blip. * * @see WaveletBasedConversationBlip#deleteThread(WaveletBasedConversationThread) */ void deleteThreads() { // deleteThread() equivalent is inline here so we can do only one // document traversal to remove inline reply anchors. List<WaveletBasedConversationThread> threads = CollectionUtils.newArrayList(getReplyThreads()); for (WaveletBasedConversationThread threadToDelete : threads) { threadToDelete.deleteBlips(); manifestBlip.removeReply(threadToDelete.getManifestThread()); } clearAllInlineReplyAnchors(); } /** * Invalidates this blip. It may no longer be accessed. */ void invalidate() { checkIsUsable(); manifestBlip.removeListener(this); isUsable = false; } /** * Recursively invalidates this blip and its replies. */ void destroy() { for (WaveletBasedConversationThread thread : CollectionUtils.valueList(replies)) { thread.destroy(); } invalidate(); listeners.clear(); } /** * Checks that this blip is safe to access. */ @VisibleForTesting void checkIsUsable() { if (!isUsable) { Preconditions.illegalState("Deleted blip is not usable: " + this); } } /** * Creates a conversation thread backed by a manifest thread and inserts it in * {@code replies}. */ private WaveletBasedConversationThread adaptThread(ObservableManifestThread manifestThread) { WaveletBasedConversationThread thread = WaveletBasedConversationThread.create(manifestThread, this, helper); String id = thread.getId(); replies.put(id, thread); return thread; } /** * Removes a thread from the internal list and triggers its deletion event. */ private void forgetThread(WaveletBasedConversationThread threadToRemove) { String id = threadToRemove.getId(); assert replies.containsKey(id); replies.remove(id); threadToRemove.triggerOnDeleted(); } /** * Finds all thread anchor elements in the blip document. * * @return thread ids and their anchor locations */ private Map<String, Integer> findAnchors() { final Map<String, Integer> anchors = CollectionUtils.newHashMap(); blip.getContent().with(new Action() { @Override public <N, E extends N, T extends N> void exec(MutableDocument<N, E, T> doc) { for (E el : DocIterate.deepElements(doc, doc.getDocumentElement(), null)) { if (Blips.THREAD_INLINE_ANCHOR_TAGNAME.equals(doc.getTagName(el))) { String threadId = doc.getAttribute(el, Blips.THREAD_INLINE_ANCHOR_ID_ATTR); if ((threadId != null) && !anchors.containsKey(threadId)) { anchors.put(threadId, doc.getLocation(el)); } } } } }); return anchors; } /** * Inserts an inline reply anchor element in the blip document. * * @param threadId id of the reply thread * @param location location at which to insert anchor */ private void createInlineReplyAnchor(final String threadId, final int location) { blip.getContent().with(new Action() { @Override public <N, E extends N, T extends N> void exec(MutableDocument<N, E, T> doc) { Point<N> point = doc.locate(location); doc.createElement(point, Blips.THREAD_INLINE_ANCHOR_TAGNAME, Collections.singletonMap(Blips.THREAD_INLINE_ANCHOR_ID_ATTR, threadId)); } }); } /** * Deletes all inline reply anchor elements for a thread from the blip * document. * * @param threadId id of the anchor(s) to delete */ private void clearInlineReplyAnchor(final String threadId) { clearInlineReplyAnchors(CollectionUtils.immutableSet(threadId)); } /** * Deletes all inline reply anchor elements for a set of threads from the blip * document. * * @param threadIds ids of the anchors to delete */ private void clearInlineReplyAnchors(final Set<String> threadIds) { blip.getContent().with(new Action() { @Override public <N, E extends N, T extends N> void exec(MutableDocument<N, E, T> doc) { List<E> elementsToDelete = CollectionUtils.newArrayList(); for (E el : DocIterate.deepElements(doc, doc.getDocumentElement(), null)) { if (Blips.THREAD_INLINE_ANCHOR_TAGNAME.equals(doc.getTagName(el))) { String elId = doc.getAttribute(el, Blips.THREAD_INLINE_ANCHOR_ID_ATTR); if (threadIds.contains(elId)) { elementsToDelete.add(el); } } } // Reverse elements to delete so we always delete bottom up if one // contains another (which would be really weird anyway). Collections.reverse(elementsToDelete); for (E el : elementsToDelete) { doc.deleteNode(el); } } }); } /** * Deletes all inline reply anchor elements from the blip document. */ private void clearAllInlineReplyAnchors() { blip.getContent().with(new Action() { @Override public <N, E extends N, T extends N> void exec(MutableDocument<N, E, T> doc) { List<E> elementsToDelete = CollectionUtils.newArrayList(); for (E el : DocIterate.deepElements(doc, doc.getDocumentElement(), null)) { if (Blips.THREAD_INLINE_ANCHOR_TAGNAME.equals(doc.getTagName(el))) { elementsToDelete.add(el); } } // Reverse elements to delete so we always delete bottom up if one // contains another (which would be really weird anyway). Collections.reverse(elementsToDelete); for (E el : elementsToDelete) { doc.deleteNode(el); } } }); } private void triggerOnReplyAdded(WaveletBasedConversationThread reply) { for (Listener l : listeners) { l.onReplyAdded(reply); } } // Package-private for access from WaveletBasedConversationThread. void triggerOnDeleted() { helper.getWaveletEventSource().removeListener(waveletListener); invalidate(); for (Listener l : listeners) { l.onDeleted(); } } private void triggerOnContributorAdded(ParticipantId contributor) { for (Listener l : listeners) { l.onContributorAdded(contributor); } } private void triggerOnContributorRemoved(ParticipantId contributor) { for (Listener l : listeners) { l.onContributorRemoved(contributor); } } private void triggerOnSubmitted() { for (Listener l : listeners) { l.onSumbitted(); } } private void triggerOnTimestampModified(long oldTimestamp, long newTimestamp) { for (Listener l : listeners) { l.onTimestampChanged(oldTimestamp, newTimestamp); } } }