/** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you 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.webclient.search; import com.google.gwt.core.client.GWT; import org.waveprotocol.box.webclient.search.Search.State; import org.waveprotocol.box.webclient.search.i18n.SearchPresenterMessages; import org.waveprotocol.wave.client.account.Profile; import org.waveprotocol.wave.client.account.ProfileListener; import org.waveprotocol.wave.client.scheduler.Scheduler.IncrementalTask; import org.waveprotocol.wave.client.scheduler.Scheduler.Task; import org.waveprotocol.wave.client.scheduler.SchedulerInstance; import org.waveprotocol.wave.client.scheduler.TimerService; import org.waveprotocol.wave.client.widget.toolbar.GroupingToolbar; import org.waveprotocol.wave.client.widget.toolbar.ToolbarButtonViewBuilder; import org.waveprotocol.wave.client.widget.toolbar.ToolbarView; import org.waveprotocol.wave.client.widget.toolbar.buttons.ToolbarClickButton; import org.waveprotocol.wave.model.id.WaveId; import org.waveprotocol.wave.model.util.CollectionUtils; import org.waveprotocol.wave.model.util.IdentityMap; import org.waveprotocol.wave.model.wave.SourcesEvents; /** * Presents a search model into a search view. * <p> * This class invokes rendering, and controls the lifecycle of digest views. It * also handles all UI gesture events sourced from views in the search panel. * * @author hearnden@google.com (David Hearnden) */ public final class SearchPresenter implements Search.Listener, SearchPanelView.Listener, SearchView.Listener, ProfileListener { /** * Handles wave actions. */ public interface WaveActionHandler { /** Handles the wave creation action. */ void onCreateWave(); /** Handles a wave selection action. */ void onWaveSelected(WaveId id); } private static final SearchPresenterMessages messages = GWT.create(SearchPresenterMessages.class); /** How often to repeat the search query. */ private final static int POLLING_INTERVAL_MS = 15000; // 15s private final static String DEFAULT_SEARCH = "in:inbox"; private final static int DEFAULT_PAGE_SIZE = 20; // External references private final TimerService scheduler; private final Search search; private final SearchPanelView searchUi; private final WaveActionHandler actionHandler; // Internal state private final IdentityMap<DigestView, Digest> digestUis = CollectionUtils.createIdentityMap(); private final IncrementalTask searchUpdater = new IncrementalTask() { @Override public boolean execute() { doSearch(); return true; } }; private final Task renderer = new Task() { @Override public void execute() { if (search.getState() == State.READY) { render(); } else { // Try again later. scheduler.schedule(this); } } }; /** Current search query. */ private String queryText = DEFAULT_SEARCH; /** Number of results to query for. */ private int querySize = DEFAULT_PAGE_SIZE; /** Current selected digest. */ private DigestView selected; /** The dispatcher of profiles events. */ SourcesEvents<ProfileListener> profiles; private boolean isRenderingInProgress = false; SearchPresenter(TimerService scheduler, Search search, SearchPanelView searchUi, WaveActionHandler actionHandler, SourcesEvents<ProfileListener> profiles) { this.search = search; this.searchUi = searchUi; this.scheduler = scheduler; this.actionHandler = actionHandler; this.profiles = profiles; } /** * Creates a search presenter. * * @param model model to present * @param view view to render into * @param actionHandler handler for actions * @param profileEventsDispatcher the dispatcher of profile events. */ public static SearchPresenter create( Search model, SearchPanelView view, WaveActionHandler actionHandler, SourcesEvents<ProfileListener> profileEventsDispatcher) { SearchPresenter presenter = new SearchPresenter( SchedulerInstance.getHighPriorityTimer(), model, view, actionHandler, profileEventsDispatcher); presenter.init(); return presenter; } /** * Performs initial presentation, and attaches listeners to live objects. */ private void init() { initToolbarMenu(); initSearchBox(); render(); search.addListener(this); profiles.addListener(this); searchUi.init(this); searchUi.getSearch().init(this); // Fire a polling search. scheduler.scheduleRepeating(searchUpdater, 0, POLLING_INTERVAL_MS); } /** * Releases resources and detaches listeners. */ public void destroy() { scheduler.cancel(searchUpdater); scheduler.cancel(renderer); searchUi.getSearch().reset(); searchUi.reset(); search.removeListener(this); profiles.removeListener(this); } /** * Adds custom buttons to the toolbar. */ private void initToolbarMenu() { GroupingToolbar.View toolbarUi = searchUi.getToolbar(); ToolbarView group = toolbarUi.addGroup(); new ToolbarButtonViewBuilder().setText(messages.newWave()).applyTo( group.addClickButton(), new ToolbarClickButton.Listener() { @Override public void onClicked() { actionHandler.onCreateWave(); // HACK(hearnden): To mimic live search, fire a search poll // reasonably soon (500ms) after creating a wave. This will be unnecessary // with a real live search implementation. The delay is to give // enough time for the wave state to propagate to the server. int delay = 500; scheduler.scheduleRepeating(searchUpdater, delay, POLLING_INTERVAL_MS); } }); // Fake group with empty button - to force the separator be displayed. group = toolbarUi.addGroup(); new ToolbarButtonViewBuilder().setText("").applyTo(group.addClickButton(), null); } /** * Initializes the search box. */ private void initSearchBox() { searchUi.getSearch().setQuery(queryText); } /** * Executes the current search. */ private void doSearch() { search.find(queryText, querySize); } /** * Renders the current state of the search result into the panel. */ private void render() { renderTitle(); renderDigests(); renderShowMore(); } /** * Renders the paging information into the title bar. */ private void renderTitle() { int resultEnd = querySize; String totalStr; if (search.getTotal() != Search.UNKNOWN_SIZE) { resultEnd = Math.min(resultEnd, search.getTotal()); totalStr = messages.of(search.getTotal()); } else { totalStr = messages.ofUnknown(); } searchUi.setTitleText(queryText + " (0-" + resultEnd + " of " + totalStr + ")"); } private void renderDigests() { isRenderingInProgress = true; // Preserve selection on re-rendering. WaveId toSelect = selected != null ? digestUis.get(selected).getWaveId() : null; searchUi.clearDigests(); digestUis.clear(); setSelected(null); for (int i = 0, size = search.getMinimumTotal(); i < size; i++) { Digest digest = search.getDigest(i); if (digest == null) { continue; } DigestView digestUi = searchUi.insertBefore(null, digest); digestUis.put(digestUi, digest); if (digest.getWaveId().equals(toSelect)) { setSelected(digestUi); } } isRenderingInProgress = false; } private void renderShowMore() { searchUi.setShowMoreVisible( search.getTotal() == Search.UNKNOWN_SIZE || querySize < search.getTotal()); } // // UI gesture events. // private void setSelected(DigestView digestUi) { if (selected != null) { selected.deselect(); } selected = digestUi; if (selected != null) { selected.select(); } } /** * Invokes the wave-select action on the currently selected digest. */ private void openSelected() { actionHandler.onWaveSelected(digestUis.get(selected).getWaveId()); } @Override public void onClicked(DigestView digestUi) { setSelected(digestUi); openSelected(); } @Override public void onQueryEntered() { queryText = searchUi.getSearch().getQuery(); querySize = DEFAULT_PAGE_SIZE; searchUi.setTitleText(messages.searching()); doSearch(); } @Override public void onShowMoreClicked() { querySize += DEFAULT_PAGE_SIZE; doSearch(); } // // Search events. For now, dumbly re-render the whole list. // @Override public void onStateChanged() { // // If the state switches to searching, then do nothing. A manual title-bar // update is performed in onQueryEntered(), and the title-bar should not be // updated when a polling search fires. // // If the state switches to ready, then just update the title. Do not // necessarily re-render, since that is only necessary if a change occurred, // which would have fired one of the other methods below. // if (search.getState() == State.READY) { renderTitle(); } } @Override public void onDigestAdded(int index, Digest digest) { renderLater(); } @Override public void onDigestRemoved(int index, Digest digest) { renderLater(); } /** * Find the DigestView that contains a certain digest * * @param digest the digest the DigestView should contain. * @return the DigestView containing the digest. {@null} if the digest is * not found. */ private DigestView findDigestView(Digest digest) { DigestView digestUi = searchUi.getFirst(); while(digestUi != null) { if (digestUis.get(digestUi).equals(digest)) { return digestUi; } digestUi = searchUi.getNext(digestUi); } return null; } /** * Insert a digest before amongst the currently shown digests * * @param insertRef the DigestView to insert the new digest before. The new digest * is inserted last if insertRef is {@null}. * @param digest the digest to insert. * @return the newly inserted DigestView. */ private DigestView insertDigest(DigestView insertRef, Digest digest) { DigestView newDigestUi = null; if (insertRef != null) { newDigestUi = searchUi.insertBefore(insertRef, digest); digestUis.put(newDigestUi, digest); } else { insertRef = searchUi.getLast(); newDigestUi = searchUi.insertAfter(insertRef, digest); digestUis.put(newDigestUi, digest); } return newDigestUi; } @Override public void onDigestReady(int index, Digest digest) { if (isRenderingInProgress) { return; } setSelected(null); DigestView digestToRemove = findDigestView(digest); if (digestToRemove == null) { return; } DigestView insertRef = searchUi.getNext(digestToRemove); digestToRemove.remove(); DigestView newDigestUi = insertDigest(insertRef, digest); setSelected(newDigestUi); } @Override public void onTotalChanged(int total) { renderLater(); } private void renderLater() { if (!scheduler.isScheduled(renderer)) { scheduler.schedule(renderer); } } @Override public void onProfileUpdated(Profile profile) { // NOTE: Search panel will be re-rendered once for every profile that comes // back to the client. If this causes an efficiency problem then have the // SearchPanelRenderer to be the profile listener, rather than // SearchPresenter, and make it stateful. Have it remember which digests // have used which profiles in their renderings. renderLater(); } }