/* * Copyright (c) 2010-2011 Lockheed Martin Corporation * * 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.eurekastreams.web.client.ui.common.notification.dialog; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; import org.eurekastreams.server.domain.InAppNotificationDTO; import org.eurekastreams.web.client.events.DialogLinkClickedEvent; import org.eurekastreams.web.client.events.EventBus; import org.eurekastreams.web.client.events.NotificationClickedEvent; import org.eurekastreams.web.client.events.NotificationDeleteRequestEvent; import org.eurekastreams.web.client.events.Observer; import org.eurekastreams.web.client.events.UpdateRawHistoryEvent; import org.eurekastreams.web.client.events.data.GotNotificationListResponseEvent; import org.eurekastreams.web.client.model.NotificationListModel; import org.eurekastreams.web.client.ui.Session; import org.eurekastreams.web.client.ui.common.dialog.BaseDialogContent; import org.eurekastreams.web.client.ui.common.notification.NotificationSettingsWidget; import org.eurekastreams.web.client.ui.pages.master.CoreCss; import org.eurekastreams.web.client.ui.pages.master.MasterComposite; import org.eurekastreams.web.client.ui.pages.master.StaticResourceBundle; import com.google.gwt.core.client.GWT; import com.google.gwt.dom.client.DivElement; import com.google.gwt.dom.client.Style.Display; import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.event.dom.client.ClickHandler; import com.google.gwt.resources.client.CssResource; import com.google.gwt.uibinder.client.UiBinder; import com.google.gwt.uibinder.client.UiField; import com.google.gwt.uibinder.client.UiHandler; import com.google.gwt.user.client.Command; import com.google.gwt.user.client.Element; import com.google.gwt.user.client.ui.FlowPanel; import com.google.gwt.user.client.ui.HasWidgets; import com.google.gwt.user.client.ui.Label; import com.google.gwt.user.client.ui.ScrollPanel; import com.google.gwt.user.client.ui.SimplePanel; import com.google.gwt.user.client.ui.Widget; /** * Dialog content (i.e. main panel) for showing notifications. */ public class NotificationsDialogContent extends BaseDialogContent { /** Main content widget. */ private final Widget main; /** To unwire the observer when done with dialog. */ private Observer<DialogLinkClickedEvent> linkClickedObserver; /** Binder for building UI. */ private static LocalUiBinder binder = GWT.create(LocalUiBinder.class); /** Local styles. */ @UiField LocalStyle style; /** Global CSS. */ @UiField(provided = true) CoreCss coreCss; /** The list of sources. */ @UiField FlowPanel sourceFiltersPanel; /** Panel making a shadow on the source filter widgets. */ @UiField SimplePanel shadowPanel; /** Scroll panel holding the notification list. */ @UiField ScrollPanel notificationListScrollPanel; /** The displayed list of notifications. */ @UiField FlowPanel notificationListPanel; /** Element to indicate no notifications. */ @UiField DivElement noNotificationsUi; /** Selector for all (read+unread) notifications. */ @UiField Label allFilterUi; /** Selector for unread only notifications. */ @UiField Label unreadFilterUi; /** Button to mark all notifications as read. */ @UiField Label markAllReadButton; /** Button to switch show settings view. */ @UiField Label settingsButton; /** Current all/unread selector. */ private Widget currentReadFilterWidget; /** Notifications. */ private List<InAppNotificationDTO> allNotifications; /** All notification widgets (cached to prevent re-creating when changing filters). */ private final Map<Long, NotificationWidget> notifWidgetIndex = new HashMap<Long, NotificationWidget>(); /** The notifications currently being displayed. */ private final Collection<InAppNotificationDTO> notifsShowing = new ArrayList<InAppNotificationDTO>(); /** Source representing all notifications. */ private Source rootSource; /** Index of actual sources. */ private Map<String, Source> sourceIndex; /** Currently-selected source. */ private Source currentSource; /** Currently selected show read option. */ private boolean currentShowRead = false; /** See explanation where this is used. */ private final boolean manuallyHandleInternalLinks; /** Observer (allow unlinking). */ private final Observer<NotificationClickedEvent> notificationClickedObserver = // \n new Observer<NotificationClickedEvent>() { public void update(final NotificationClickedEvent ev) { handleNotificationClicked(ev.getNotification(), ev.getWidget()); } }; /** Observer (allow unlinking). */ private final Observer<NotificationDeleteRequestEvent> notificationDeleteRequestObserver = // \n new Observer<NotificationDeleteRequestEvent>() { public void update(final NotificationDeleteRequestEvent ev) { handleNotificationDeleteRequest(ev.getResponse()); } }; /** * Constructor. */ public NotificationsDialogContent() { // -- determine if IE workaround is needed (see explanation where used) -- manuallyHandleInternalLinks = MasterComposite.getUserAgent().contains("msie"); // -- build UI -- coreCss = StaticResourceBundle.INSTANCE.coreCss(); main = binder.createAndBindUi(this); currentReadFilterWidget = unreadFilterUi; // -- setup events -- final EventBus eventBus = Session.getInstance().getEventBus(); eventBus.addObserver(GotNotificationListResponseEvent.class, new Observer<GotNotificationListResponseEvent>() { public void update(final GotNotificationListResponseEvent ev) { eventBus.removeObserver(ev, this); storeReceivedNotifications(ev.getResponse()); selectSource(currentSource); } }); eventBus.addObserver(NotificationClickedEvent.class, notificationClickedObserver); eventBus.addObserver(NotificationDeleteRequestEvent.class, notificationDeleteRequestObserver); // -- request data -- NotificationListModel.getInstance().fetch(null, false); } /** * Invoked on closing before the dialog is removed from screen. */ @Override public void beforeHide() { if (linkClickedObserver != null) { Session.getInstance().getEventBus().removeObserver(DialogLinkClickedEvent.class, linkClickedObserver); linkClickedObserver = null; } EventBus.getInstance().removeObserver(NotificationClickedEvent.class, notificationClickedObserver); EventBus.getInstance().removeObserver(NotificationDeleteRequestEvent.class, notificationDeleteRequestObserver); } /** * {@inheritDoc} */ public Widget getBody() { return main; } /** * {@inheritDoc} */ public String getTitle() { return "Notifications"; } /** * {@inheritDoc} */ @Override public String getCssName() { return style.modal(); } /** * Responds appropriately to a notification being clicked. * * @param item * The notification. * @param widget * The notification's widget. */ private void handleNotificationClicked(final InAppNotificationDTO item, final Widget widget) { // tell server notification is read if (!item.isRead()) { NotificationListModel.getInstance().update(item.getId()); } // dismiss the dialog when clicking on a notification with an internal URL. (Dialog will be discarded, so no // UI or local data updates are required.) final String url = item.getUrl(); boolean hasInternalUrl = url != null && !url.isEmpty() && url.charAt(0) == '#'; if (hasInternalUrl) { close(); // For some reason, in IE (7 & 8), if the URL fragment is completely empty, then clicking one of the // notification links will update the URL in the address bar, but the HistoryHandler will never be notified // of it. So we need to force the app to go to the desired URL. // Also, IE seems to lose the history stack when clicking on a plain link, so we manually handle internal // links for all cases (not just the empty history token). if (manuallyHandleInternalLinks) { EventBus.getInstance().notifyObservers(new UpdateRawHistoryEvent(url.substring(1))); } } // not closing dialog, so update UI and local data if item was just read else if (!item.isRead()) { item.setRead(true); // work up the source tree, reducing the unread count and hiding sources as applicable. (Note that the // starting source may not be the current source. This happens when the user is viewing "All" or // "Streams"/"Apps".) Source source = getSource(item); while (source != null) { source.decrementUnreadCount(); updateDisplayString(source); if (source.getUnreadCount() == 0 && source != rootSource) { source.getWidget().addStyleName(style.sourceFilterAllRead()); } source = source.getParent(); } // in unread view, insure no read items or empty sources are showing if (!currentShowRead) { if (currentSource.getUnreadCount() == 0) { // Note: if already showing the root source, then this will redraw it with the "none" message. selectSource(rootSource); } else { widget.removeFromParent(); } setShadowHeight(); } } } /** * Deletes a notification on request. * * @param item * The notification. */ private void handleNotificationDeleteRequest(final InAppNotificationDTO item) { if (notifsShowing.remove(item)) { allNotifications.remove(item); notifWidgetIndex.remove(item.getId()); // work up the source tree, updating/hiding/removing sources as applicable. (Note that the starting source // may not be the current source. This happens when the user is viewing "All" or "Streams"/"Apps".) Source source = getSource(item); while (source != null) { source.decrementTotalCount(); if (source.getTotalCount() == 0 && source != rootSource) { source.getWidget().removeFromParent(); } else if (!item.isRead()) { source.decrementUnreadCount(); updateDisplayString(source); if (source.getUnreadCount() == 0 && source != rootSource) { source.getWidget().addStyleName(style.sourceFilterAllRead()); } } source = source.getParent(); } NotificationListModel.getInstance().delete(item.getId()); // switch source if no notifications left to show. (If already showing the root source, this will just // redraw it with the "none" message.) if (currentSource.getTotalCount() == 0 || (!currentShowRead && currentSource.getUnreadCount() == 0)) { selectSource(rootSource); } setShadowHeight(); } } /** * Gets the source for a given notification. * * @param item * Notification. * @return Source. */ private Source getSource(final InAppNotificationDTO item) { Source source = sourceIndex.get(SourceListBuilder.buildSourceKey(item)); return source == null ? rootSource : source; } /** * Handles the received list of notifications. * * @param list * List of notifications. */ private void storeReceivedNotifications(final List<InAppNotificationDTO> list) { allNotifications = list; SourceListBuilder builder = new SourceListBuilder(list, Session.getInstance().getCurrentPerson() .getAccountId()); rootSource = builder.getRootSource(); sourceIndex = builder.getSourceIndex(); shadowPanel.setVisible(false); for (Source source : builder.getSourceList()) { addSourceFilter(source, !source.isCategorySource()); } currentSource = rootSource; // set up shadow setShadowHeight(); shadowPanel.setVisible(true); } /** * Adjusts the height of the shadow to the displayed sources. */ private void setShadowHeight() { shadowPanel.setHeight("0px"); shadowPanel.setHeight(sourceFiltersPanel.getOffsetHeight() + "px"); } /** * Creates and adds the widget for a source filter. * * @param source * Source data. * @param indent * If the label should be indented. */ private void addSourceFilter(final Source source, final boolean indent) { int count = source.getUnreadCount(); String text = count > 0 ? source.getDisplayName() + " (" + count + ")" : source.getDisplayName(); final Label label = new Label(text); label.addStyleName(style.sourceFilter()); label.addStyleName(StaticResourceBundle.INSTANCE.coreCss().buttonLabel()); label.addStyleName(StaticResourceBundle.INSTANCE.coreCss().ellipsis()); if (count == 0 && source != rootSource) { label.addStyleName(style.sourceFilterAllRead()); } if (indent) { label.addStyleName(style.sourceFilterIndented()); } label.addClickHandler(new ClickHandler() { public void onClick(final ClickEvent inEvent) { selectSource(source); } }); sourceFiltersPanel.add(label); source.setWidget(label); } /** * Updates the display to show a new source. * * @param newSource * New source. */ private void selectSource(final Source newSource) { currentSource.getWidget().removeStyleName(style.filterSelected()); currentSource = newSource; currentSource.getWidget().addStyleName(style.filterSelected()); displayNotifications(currentSource.getFilter(), currentShowRead); } /** * Displays the appropriate subset of notifications. * * @param filter * Filter for notifications. * @param showRead * If read notifications should be displayed (unread are always displayed). */ private void displayNotifications(final Source.Filter filter, final boolean showRead) { noNotificationsUi.getStyle().setDisplay(Display.NONE); notificationListScrollPanel.setVisible(false); notificationListPanel.clear(); notifsShowing.clear(); for (InAppNotificationDTO item : allNotifications) { if (filter.shouldDisplay(item) && (showRead || !item.isRead())) { notifsShowing.add(item); notificationListPanel.add(getNotificationWidget(item)); } } if (notifsShowing.isEmpty()) { noNotificationsUi.getStyle().clearDisplay(); } else { notificationListScrollPanel.scrollToTop(); notificationListScrollPanel.setVisible(true); } } /** * Gets/creates a notification widget for the given notification. * * @param item * Notification. * @return Widget. */ private NotificationWidget getNotificationWidget(final InAppNotificationDTO item) { NotificationWidget widget = notifWidgetIndex.get(item.getId()); if (widget == null) { widget = new NotificationWidget(item, manuallyHandleInternalLinks); notifWidgetIndex.put(item.getId(), widget); } return widget; } /** * Shows all (unread+read) or just unread notifications. * * @param ev * Event. */ @UiHandler({ "allFilterUi", "unreadFilterUi" }) void onFilterClick(final ClickEvent ev) { Widget selector = (Widget) ev.getSource(); if (selector != currentReadFilterWidget) { currentReadFilterWidget.removeStyleName(style.filterSelected()); currentReadFilterWidget = selector; currentReadFilterWidget.addStyleName(style.filterSelected()); currentShowRead = !currentShowRead; if (currentShowRead) { sourceFiltersPanel.removeStyleName(style.sourceFilterListUnreadOnly()); } else { sourceFiltersPanel.addStyleName(style.sourceFilterListUnreadOnly()); if (currentSource != rootSource && currentSource.getUnreadCount() == 0) { setShadowHeight(); selectSource(rootSource); return; } } setShadowHeight(); displayNotifications(currentSource.getFilter(), currentShowRead); } } /** * Marks all notifications read. * * @param ev * Event. */ @UiHandler("markAllReadButton") void onMarkAllReadClick(final ClickEvent ev) { // update on server ArrayList<Long> ids = new ArrayList<Long>(); for (InAppNotificationDTO item : notifsShowing) { if (!item.isRead()) { item.setRead(true); ids.add(item.getId()); } } if (ids.isEmpty()) { return; } NotificationListModel.getInstance().update(ids); // update the sources (unread counts and display) // This process needs to work upwards and downwards from the currently-displayed source. (e.g. marking all as // read on the Streams source needs to both decrement the appropriate number from "All"'s unread count as well // as zero out the unread count for every one of its child sources.) Given there are only three levels and very // few non-leaf sources, it is easiest to code for the specific cases. if (currentSource == rootSource) { // Root source. The root source contains everything, so set every source's unread count to zero. for (Source source : sourceIndex.values()) { source.setUnreadCount(0); updateDisplayString(source); if (source != rootSource) { source.getWidget().addStyleName(style.sourceFilterAllRead()); } } } else if (currentSource.isCategorySource()) { // Non-root category source. Set its and children's unread counts to zero; subtract from root's count. for (Source source : sourceIndex.values()) { if (source == currentSource || source.getParent() == currentSource) { source.setUnreadCount(0); updateDisplayString(source); source.getWidget().addStyleName(style.sourceFilterAllRead()); } } rootSource.setUnreadCount(rootSource.getUnreadCount() - ids.size()); updateDisplayString(rootSource); } else { // Leaf source. Work upwards. int number = ids.size(); for (Source source = currentSource; source != null; source = source.getParent()) { int unreadCount = source.getUnreadCount() - number; source.setUnreadCount(unreadCount); updateDisplayString(source); if (source != rootSource && unreadCount == 0) { source.getWidget().addStyleName(style.sourceFilterAllRead()); } } } // select a different source (or redraw root) if unread-only filter is active if (!currentShowRead) { setShadowHeight(); selectSource(rootSource); } // add the already-read style to all the individual notification widgets (since the widgets are cached and // reused until the dialog is closed) for (long id : ids) { notifWidgetIndex.get(id).addReadStyle(); } } /** * Shows the settings view. * * @param ev * Event. */ @UiHandler("settingsButton") void showSettings(final ClickEvent ev) { final NotificationSettingsWidget settings = new NotificationSettingsWidget(true); settings.setCloseCommand(new Command() { public void execute() { settings.removeFromParent(); main.setVisible(true); } }); // This assumes the dialog hosts the dialog content in a 1) dedicated panel which 2) allows multiple children. HasWidgets parent = (HasWidgets) main.getParent(); main.setVisible(false); parent.add(settings); } /** * Causes the source to apply the current display string to the widget. * * @param source * Source. */ private void updateDisplayString(final Source source) { Label widget = source.getWidget(); if (usingMozillaBinding(widget.getElement())) { int index = sourceFiltersPanel.getWidgetIndex(widget); if (index >= 0) { widget.removeFromParent(); widget.setText(source.getDisplayString()); sourceFiltersPanel.insert(widget, index); } } else { widget.setText(source.getDisplayString()); } } /** * Determines if the source's widget is using a Mozilla binding. The purpose is to check for the XUL ellipsis * binding, since that binding causes text updates to fail, thus we must do some trickery to work around it. * * @param elem * Element to check. * @return If using -moz-binding. */ private static native boolean usingMozillaBinding(final Element elem) /*-{ var v = $wnd.jQuery(elem).css('-moz-binding'); return v ? v !== 'none' : false; }-*/; /** * Local styles. */ interface LocalStyle extends CssResource { /** @return Extra style for entire modal. */ @ClassName("modal") String modal(); /** @return Style applied to the source list to only show sources with unread notifs. */ @ClassName("source-filter-list-unread-only") String sourceFilterListUnreadOnly(); /** @return Style applied to sources where all notifs are read. */ @ClassName("source-filter-all-read") String sourceFilterAllRead(); /** @return Style for sources. */ @ClassName("source-filter") String sourceFilter(); /** @return Added style for a selected filter (the selected source or unread/all). */ @ClassName("filter-selected") String filterSelected(); /** @return Added style for indented sources. */ @ClassName("source-filter-indented") String sourceFilterIndented(); } /** * Binder for building UI. */ interface LocalUiBinder extends UiBinder<Widget, NotificationsDialogContent> { } }