# HG changeset patch # User Goffi # Date 1426691718 -3600 # Node ID a90cc8fc96059dcdc68a8ff500074901b91ff1e3 # Parent 1bffc4c244c345817441981695627750debf02e4# Parent 2e087e093e7fbb835339939ea2fbc4ae6a42ea41 merged branch frontends_multi_profiles diff -r 1bffc4c244c3 -r a90cc8fc9605 .hgignore --- a/.hgignore Thu Feb 05 12:05:32 2015 +0100 +++ b/.hgignore Wed Mar 18 16:15:18 2015 +0100 @@ -7,7 +7,6 @@ tags twistd.log twistd.pid -output sat.egg-info *.un~ dist @@ -16,3 +15,4 @@ build/ Session.vim ctags_links/ +html/ diff -r 1bffc4c244c3 -r a90cc8fc9605 setup.py --- a/setup.py Thu Feb 05 12:05:32 2015 +0100 +++ b/setup.py Wed Mar 18 16:15:18 2015 +0100 @@ -119,7 +119,10 @@ os.symlink(os.path.dirname(sat.__file__), os.path.join(tmp_dir,"sat")) # FIXME: only work on unixes os.symlink(os.path.dirname(sat_frontends.__file__), os.path.join(tmp_dir,"sat_frontends")) # FIXME: only work on unixes os.symlink(os.path.dirname(libervia.__file__), os.path.join(tmp_dir,"libervia")) # FIXME: only work on unixes - result = subprocess.call(['pyjsbuild', 'libervia_main', '-d', '--no-compile-inplace', '-I', tmp_dir, '-o', self.pyjamas_output_dir]) + for module in ('libervia_main', 'libervia_test'): + result = subprocess.call(['pyjsbuild', module, '-d', '--no-compile-inplace', '-I', tmp_dir, '-o', self.pyjamas_output_dir]) + if result != 0: + continue shutil.rmtree(tmp_dir) os.chdir(cwd) return result diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/collections.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/collections.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,149 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +class OrderedDict(object): + """Naive implementation of OrderedDict which is compatible with pyjamas""" + + def __init__(self, *args, **kwargs): + self.__internal_dict = {} + self.__keys = [] # this list keep the keys in order + if args: + if len(args)>1: + raise TypeError("OrderedDict expected at most 1 arguments, got {}".format(len(args))) + if isinstance(args[0], (dict, OrderedDict)): + for key, value in args[0].iteritems(): + self[key] = value + for key, value in args[0]: + self[key] = value + + def __len__(self): + return len(self.__keys) + + def __setitem__(self, key, value): + if key not in self.__keys: + self.__keys.append(key) + self.__internal_dict[key] = value + + def __getitem__(self, key): + return self.__internal_dict[key] + + def __delitem__(self, key): + del self.__internal_dict[key] + self.__keys.remove(key) + + def __contains__(self, key): + return key in self.__keys + + def clear(self): + self.__internal_dict.clear() + del self.__keys[:] + + def copy(self): + return OrderedDict(self) + + @classmethod + def fromkeys(cls, seq, value=None): + ret = OrderedDict() + for key in seq: + ret[key] = value + return ret + + def get(self, key, default=None): + try: + return self.__internal_dict[key] + except KeyError: + return default + + def has_key(self, key): + return key in self.__keys + + def keys(self): + return self.__keys[:] + + def iterkeys(self): + for key in self.__keys: + yield key + + def items(self): + ret = [] + for key in self.__keys: + ret.append((key, self.__internal_dict[key])) + return ret + + def iteritems(self): + for key in self.__keys: + yield (key, self.__internal_dict[key]) + + def values(self): + ret = [] + for key in self.__keys: + ret.append(self.__internal_dict[key]) + return ret + + def itervalues(self): + for key in self.__keys: + yield (self.__internal_dict[key]) + + def popitem(self, last=True): + try: + key = self.__keys.pop(-1 if last else 0) + except IndexError: + raise KeyError('dictionnary is empty') + value = self.__internal_dict.pop(key) + return((key, value)) + + def setdefault(self, key, default=None): + try: + return self.__internal_dict[key] + except KeyError: + self[key] = default + return default + + def update(self, *args, **kwargs): + if len(args) > 1: + raise TypeError('udpate expected at most 1 argument, got {}'.format(len(args))) + if args: + if hasattr(args[0], 'keys'): + for k in args[0]: + self[k] = args[0][k] + else: + for (k, v) in args[0]: + self[k] = v + for k, v in kwargs.items(): + self[k] = v + + def pop(self, *args): + if not args: + raise TypeError('pop expected at least 1 argument, got 0') + try: + self.__internal_dict.pop(args[0]) + except KeyError: + if len(args) == 2: + return args[1] + raise KeyError(args[0]) + self.__keys.remove(args[0]) + + def viewitems(self): + raise NotImplementedError + + def viewkeys(self): + raise NotImplementedError + + def viewvalues(self): + raise NotImplementedError diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/libervia_main.py --- a/src/browser/libervia_main.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/libervia_main.py Wed Mar 18 16:15:18 2015 +0100 @@ -17,7 +17,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import pyjd # this is dummy in pyjs ### logging configuration ### from sat_browser import logging @@ -26,8 +25,16 @@ log = getLogger(__name__) ### +from sat.core.i18n import D_ + +from sat_frontends.quick_frontend.quick_app import QuickApp +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.quick_frontend import quick_menus + from sat_frontends.tools.misc import InputHistory from sat_frontends.tools import strings +from sat_frontends.tools import jid +from sat_frontends.tools import host_listener from sat.core.i18n import _ from pyjamas.ui.RootPanel import RootPanel @@ -35,19 +42,22 @@ from pyjamas.ui.KeyboardListener import KEY_ESCAPE from pyjamas.Timer import Timer from pyjamas import Window, DOM -from pyjamas.JSONService import JSONProxy +from sat_browser import json from sat_browser import register -from sat_browser import contact -from sat_browser import base_widget -from sat_browser import panels +from sat_browser.contact_list import ContactList +from sat_browser import widget +from sat_browser import main_panel +from sat_browser import blog from sat_browser import dialog -from sat_browser import jid from sat_browser import xmlui from sat_browser import html_tools from sat_browser import notification +from sat_browser import libervia_widget from sat_browser.constants import Const as C +import os.path + try: # FIXME: import plugin dynamically @@ -55,148 +65,36 @@ except ImportError: pass + +unicode = str # FIXME: pyjamas workaround + + MAX_MBLOG_CACHE = 500 # Max microblog entries kept in memories # Set to true to not create a new LiberviaWidget when a similar one # already exist (i.e. a chat panel with the same target). Instead # the existing widget will be eventually removed from its parent -# and added to new base_widget.WidgetsPanel, or replaced to the expected +# and added to new libervia_widget.WidgetsPanel, or replaced to the expected # position if the previous and the new parent are the same. -REUSE_EXISTING_LIBERVIA_WIDGETS = True - - -class LiberviaJsonProxy(JSONProxy): - def __init__(self, *args, **kwargs): - JSONProxy.__init__(self, *args, **kwargs) - self.handler = self - self.cb = {} - self.eb = {} - - def call(self, method, cb, *args): - _id = self.callMethod(method, args) - if cb: - if isinstance(cb, tuple): - if len(cb) != 2: - log.error("tuple syntax for bridge.call is (callback, errback), aborting") - return - if cb[0] is not None: - self.cb[_id] = cb[0] - self.eb[_id] = cb[1] - else: - self.cb[_id] = cb - - def onRemoteResponse(self, response, request_info): - if request_info.id in self.cb: - _cb = self.cb[request_info.id] - # if isinstance(_cb, tuple): - # #we have arguments attached to the callback - # #we send them after the answer - # callback, args = _cb - # callback(response, *args) - # else: - # #No additional argument, we call directly the callback - _cb(response) - del self.cb[request_info.id] - if request_info.id in self.eb: - del self.eb[request_info.id] - - def onRemoteError(self, code, errobj, request_info): - """def dump(obj): - print "\n\nDUMPING %s\n\n" % obj - for i in dir(obj): - print "%s: %s" % (i, getattr(obj,i))""" - if request_info.id in self.eb: - _eb = self.eb[request_info.id] - _eb((code, errobj)) - del self.cb[request_info.id] - del self.eb[request_info.id] - else: - if code != 0: - log.error("Internal server error") - """for o in code, error, request_info: - dump(o)""" - else: - if isinstance(errobj['message'], dict): - log.error("Error %s: %s" % (errobj['message']['faultCode'], errobj['message']['faultString'])) - else: - log.error("%s" % errobj['message']) +# REUSE_EXISTING_LIBERVIA_WIDGETS = True # FIXME -class RegisterCall(LiberviaJsonProxy): - def __init__(self): - LiberviaJsonProxy.__init__(self, "/register_api", - ["isRegistered", "isConnected", "asyncConnect", "registerParams", "getMenus"]) - - -class BridgeCall(LiberviaJsonProxy): - def __init__(self): - LiberviaJsonProxy.__init__(self, "/json_api", - ["getContacts", "addContact", "sendMessage", "sendMblog", "sendMblogComment", - "getMblogs", "getMassiveMblogs", "getMblogComments", "getProfileJid", - "getHistory", "getPresenceStatuses", "joinMUC", "mucLeave", "getRoomsJoined", - "inviteMUC", "launchTarotGame", "getTarotCardsPaths", "tarotGameReady", - "tarotGamePlayCards", "launchRadioCollective", "getMblogs", "getMblogsWithComments", - "getWaitingSub", "subscription", "delContact", "updateContact", "getCard", - "getEntityData", "getParamsUI", "asyncGetParamA", "setParam", "launchAction", - "disconnect", "chatStateComposing", "getNewAccountDomain", "confirmationAnswer", - "syntaxConvert", "getAccountDialogUI", "getLastResource" - ]) - - -class BridgeSignals(LiberviaJsonProxy): - RETRY_BASE_DELAY = 1000 - - def __init__(self, host): - self.host = host - self.retry_delay = self.RETRY_BASE_DELAY - LiberviaJsonProxy.__init__(self, "/json_signal_api", - ["getSignals"]) - - def onRemoteResponse(self, response, request_info): - self.retry_delay = self.RETRY_BASE_DELAY - LiberviaJsonProxy.onRemoteResponse(self, response, request_info) - - def onRemoteError(self, code, errobj, request_info): - if errobj['message'] == 'Empty Response': - Window.getLocation().reload() # XXX: reset page in case of session ended. - # FIXME: Should be done more properly without hard reload - LiberviaJsonProxy.onRemoteError(self, code, errobj, request_info) - #we now try to reconnect - if isinstance(errobj['message'], dict) and errobj['message']['faultCode'] == 0: - Window.alert('You are not allowed to connect to server') - else: - def _timerCb(timer): - self.host.bridge_signals.call('getSignals', self.host._getSignalsCB) - Timer(notify=_timerCb).schedule(self.retry_delay) - self.retry_delay *= 2 - - -class SatWebFrontend(InputHistory): +class SatWebFrontend(InputHistory, QuickApp): def onModuleLoad(self): log.info("============ onModuleLoad ==============") - panels.ChatPanel.registerClass() - panels.MicroblogPanel.registerClass() - self.whoami = None - self._selected_listeners = set() - self.bridge = BridgeCall() - self.bridge_signals = BridgeSignals(self) - self.uni_box = None - self.status_panel = HTML('
') - self.contact_panel = contact.ContactPanel(self) - self.panel = panels.MainPanel(self) - self.discuss_panel = self.panel.discuss_panel + self.bridge_signals = json.BridgeSignals(self) + QuickApp.__init__(self, json.BridgeCall) + self.uni_box = None # FIXME: to be removed + self.panel = main_panel.MainPanel(self) self.tab_panel = self.panel.tab_panel self.tab_panel.addTabListener(self) - self.libervia_widgets = set() # keep track of all actives LiberviaWidgets - self.room_list = [] # list of rooms - self.mblog_cache = [] # used to keep our own blog entries in memory, to show them in new mblog panel - self.avatars_cache = {} # keep track of jid's avatar hash (key=jid, value=file) self._register_box = None RootPanel().add(self.panel) + self.notification = notification.Notification() DOM.addEventPreview(self) self.importPlugins() - self._register = RegisterCall() + self._register = json.RegisterCall() self._register.call('getMenus', self.gotMenus) self._register.call('registerParams', None) self._register.call('isRegistered', self._isRegisteredCB) @@ -205,35 +103,65 @@ self.cached_params = {} self.next_rsm_index = 0 + #FIXME: microblog cache should be managed directly in blog module + self.mblog_cache = [] # used to keep our own blog entries in memory, to show them in new mblog panel + + # self._selected_listeners = set() # FIXME: to be done with new listeners mechanism + + @property + def whoami(self): + # XXX: works because Libervia is mono-profile + # if one day Libervia manage several profiles at once, this must be deleted + return self.profiles[C.PROF_KEY_NONE].whoami + + @property + def contact_list(self): + return self.contact_lists[C.PROF_KEY_NONE] + + @property + def visible_widgets(self): + widgets_panel = self.tab_panel.getCurrentPanel() + return [wid for wid in widgets_panel.widgets if isinstance(wid, quick_widgets.QuickWidget)] + + @property + def base_location(self): + """Return absolute base url of this Libervia instance""" + url = Window.getLocation().getHref() + if url.endswith(C.LIBERVIA_MAIN_PAGE): + url = url[:-len(C.LIBERVIA_MAIN_PAGE)] + if url.endswith("/"): + url = url[:-1] + return url + + def registerSignal(self, functionName, handler=None, iface="core", with_profile=True): + if handler is None: + callback = getattr(self, "{}{}".format(functionName, "Handler")) + else: + callback = handler + + self.bridge_signals.register(functionName, callback, with_profile=with_profile) + def importPlugins(self): self.plugins = {} - inhibited_menus = [] - # FIXME: plugins import should be dynamic and generic like in sat try: self.plugins['otr'] = plugin_sec_otr.OTR(self) except TypeError: # plugin_sec_otr has not been imported - inhibited_menus.append('OTR') + pass - class DummyPlugin(object): - def inhibitMenus(self): - return inhibited_menus - - self.plugins['dummy_plugin'] = DummyPlugin() - - def addSelectedListener(self, callback): - self._selected_listeners.add(callback) + # def addSelectedListener(self, callback): + # self._selected_listeners.add(callback) def getSelected(self): wid = self.tab_panel.getCurrentPanel() - if not isinstance(wid, base_widget.WidgetsPanel): - log.error("Tab widget is not a base_widget.WidgetsPanel, can't get selected widget") + if not isinstance(wid, libervia_widget.WidgetsPanel): + log.error("Tab widget is not a WidgetsPanel, can't get selected widget") return None return wid.selected def setSelected(self, widget): """Define the selected widget""" widgets_panel = self.tab_panel.getCurrentPanel() - if not isinstance(widgets_panel, base_widget.WidgetsPanel): + if not isinstance(widgets_panel, libervia_widget.WidgetsPanel): return selected = widgets_panel.selected @@ -244,13 +172,16 @@ if selected: selected.removeStyleName('selected_widget') + # FIXME: check that widget is in the current WidgetsPanel widgets_panel.selected = widget + self.selected_widget = widget if widget: widgets_panel.selected.addStyleName('selected_widget') - for callback in self._selected_listeners: - callback(widget) + # FIXME: + # for callback in self._selected_listeners: + # callback(widget) def resize(self): """Resize elements""" @@ -260,9 +191,11 @@ return True def onTabSelected(self, sender, tab_index): - selected = self.getSelected() - for callback in self._selected_listeners: - callback(selected) + pass + # selected = self.getSelected() + # FIXME: + # for callback in self._selected_listeners: + # callback(selected) def onEventPreview(self, event): if event.type in ["keydown", "keypress", "keyup"] and event.keyCode == KEY_ESCAPE: @@ -270,26 +203,24 @@ event.preventDefault() return True - def getAvatar(self, jid_str): + # FIXME: must not call _entityDataUpdatedCb by itself + # should not get VCard, backend plugin must be fixed too + def getAvatarURL(self, jid_): """Return avatar of a jid if in cache, else ask for it. - @param jid_str (str): JID of the contact - @return: the URL to the avatar (str) + @param jid_ (jid.JID): JID of the contact + @return: the URL to the avatar (unicode) """ - def dataReceived(result): - if 'avatar' in result: - self._entityDataUpdatedCb(jid_str, 'avatar', result['avatar']) - else: - self.bridge.call("getCard", None, jid_str) - - def avatarError(error_data): - # The jid is maybe not in our roster, we ask for the VCard - self.bridge.call("getCard", None, jid_str) - - if jid_str not in self.avatars_cache: - self.bridge.call('getEntityData', (dataReceived, avatarError), jid_str, ['avatar']) - self.avatars_cache[jid_str] = C.DEFAULT_AVATAR - return self.avatars_cache[jid_str] + assert isinstance(jid_, jid.JID) + contact_list = self.contact_list # pyjamas issue: need a temporary variable to call a property's method + avatar_hash = contact_list.getCache(jid_, 'avatar') + if avatar_hash is None: + # we have no value for avatar_hash, so we request the vcard + self.bridge.getCard(unicode(jid_), profile=C.PROF_KEY_NONE) + if not avatar_hash: + return C.DEFAULT_AVATAR_URL + ret = os.path.join(C.AVATARS_DIR, avatar_hash) + return ret def registerWidget(self, wid): log.debug("Registering %s" % wid.getDebugName()) @@ -304,64 +235,65 @@ def refresh(self): """Refresh the general display.""" self.panel.refresh() - if self.getCachedParam(C.COMPOSITION_KEY, C.ENABLE_UNIBOX_PARAM) == 'true': - self.uni_box = self.panel.unibox_panel.unibox - else: - self.uni_box = None for lib_wid in self.libervia_widgets: lib_wid.refresh() self.resize() - def addTab(self, label, wid, select=True): - """Create a new tab and eventually add a widget in - @param label: label of the tab - @param wid: LiberviaWidget to add - @param select: True to select the added tab - """ - widgets_panel = base_widget.WidgetsPanel(self) - self.tab_panel.add(widgets_panel, label) - widgets_panel.addWidget(wid) - if select: - self.tab_panel.selectTab(self.tab_panel.getWidgetCount() - 1) - return widgets_panel - def addWidget(self, wid, tab_index=None): """ Add a widget at the bottom of the current or specified tab + @param wid: LiberviaWidget to add - @param tab_index: index of the tab to add the widget to""" + @param tab_index: index of the tab to add the widget to + """ if tab_index is None or tab_index < 0 or tab_index >= self.tab_panel.getWidgetCount(): panel = self.tab_panel.getCurrentPanel() else: - panel = self.tab_panel.tabBar.getTabWidget(tab_index) + panel = self.tab_panel.deck.getWidget(tab_index) panel.addWidget(wid) def displayNotification(self, title, body): self.notification.notify(title, body) - def gotMenus(self, menus): + def gotMenus(self, backend_menus): """Put the menus data in cache and build the main menu bar - @param menus (list[tuple]): menu data + @param backend_menus (list[tuple]): menu data from backend """ - def process(menus, inhibited=None): - for raw_menu in menus: - id_, type_, path, path_i18n = raw_menu - if inhibited and path[0] in inhibited: - continue - menus_data = self.menus.setdefault(type_, []) - menus_data.append((id_, path, path_i18n)) + main_menu = self.panel.menu # most of global menu callbacks are in main_menu + + # Categories (with icons) + self.menus.addCategory(C.MENU_GLOBAL, [D_(u"General")], extra={'icon': 'home'}) + self.menus.addCategory(C.MENU_GLOBAL, [D_(u"Contacts")], extra={'icon': 'social'}) + self.menus.addCategory(C.MENU_GLOBAL, [D_(u"Groups")], extra={'icon': 'social'}) + self.menus.addCategory(C.MENU_GLOBAL, [D_(u"Games")], extra={'icon': 'games'}) + + # menus to have before backend menus + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Groups"), D_(u"Discussion")), callback=main_menu.onJoinRoom) + + # menus added by the backend/plugins (include other types than C.MENU_GLOBAL) + self.menus.addMenus(backend_menus, top_extra={'icon': 'plugins'}) + + # menus to have under backend menus + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Contacts"), D_(u"Manage groups")), callback=main_menu.onManageContactGroups) - self.menus = {} - inhibited = set() - extras = [] - for plugin in self.plugins.values(): - if hasattr(plugin, "inhibitMenus"): - inhibited.update(plugin.inhibitMenus()) - if hasattr(plugin, "extraMenus"): - extras.extend(plugin.extraMenus()) - process(menus, inhibited) - process(extras) - self.panel.menu.createMenus() + # separator and right hand menus + self.menus.addMenuItem(C.MENU_GLOBAL, [], quick_menus.MenuSeparator()) + + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Help"), D_("Social contract")), top_extra={'icon': 'help'}, callback=main_menu.onSocialContract) + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Help"), D_("About")), callback=main_menu.onAbout) + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Settings"), D_("Account")), top_extra={'icon': 'settings'}, callback=main_menu.onAccount) + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Settings"), D_("Parameters")), callback=main_menu.onParameters) + # XXX: temporary, will change when a full profile will be managed in SàT + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"Settings"), D_("Upload avatar")), callback=main_menu.onAvatarUpload) + + # we call listener to have menu added by local classes/plugins + self.callListeners('gotMenus') # FIXME: to be done another way or moved to quick_app + + # and finally the menus which must appear at the bottom + self.menus.addMenu(C.MENU_GLOBAL, (D_(u"General"), D_(u"Disconnect")), callback=main_menu.onDisconnect) + + # we can now display all the menus + main_menu.update(C.MENU_GLOBAL) def _isRegisteredCB(self, result): registered, warning = result @@ -386,17 +318,19 @@ self._register_box.hide() del self._register_box # don't work if self._register_box is None - # display the real presence status panel - self.panel.header.remove(self.status_panel) - self.status_panel = panels.PresenceStatusPanel(self) - self.panel.header.add(self.status_panel) + # display the presence status panel and tab bar + self.presence_status_panel = main_panel.PresenceStatusPanel(self) + self.panel.addPresenceStatusPanel(self.presence_status_panel) + self.panel.tab_panel.getTabBar().setVisible(True) + + self.bridge_signals.call('getSignals', self.bridge_signals.signalHandler) #it's time to fill the page - self.bridge.call('getContacts', self._getContactsCB) - self.bridge.call('getParamsUI', self._getParamsUICB) - self.bridge_signals.call('getSignals', self._getSignalsCB) - #We want to know our own jid - self.bridge.call('getProfileJid', self._getProfileJidCB) + # self.bridge.call('getContacts', self._getContactsCB) + # self.bridge.call('getParamsUI', self._getParamsUICB) + # self.bridge_signals.call('getSignals', self._getSignalsCB) + # #We want to know our own jid + # self.bridge.call('getProfileJid', self._getProfileJidCB) def domain_cb(value): self._defaultDomain = value @@ -405,18 +339,72 @@ def domain_eb(value): self._defaultDomain = "libervia.org" - self.bridge.call("getNewAccountDomain", (domain_cb, domain_eb)) - self.discuss_panel.addWidget(panels.MicroblogPanel(self, [])) + self.bridge.getNewAccountDomain(callback=domain_cb, errback=domain_eb) + self.plug_profiles([C.PROF_KEY_NONE]) # XXX: None was used intitially, but pyjamas bug when using variable arguments and None is the only arg. + # self.discuss_panel.addWidget(panel.MicroblogPanel(self, [])) + + # # get cached params and refresh the display + # def param_cb(cat, name, count): + # count[0] += 1 + # refresh = count[0] == len(C.CACHED_PARAMS) + # return lambda value: self._paramUpdate(name, value, cat, refresh) + + # count = [0] # used to do something similar to DeferredList + # for cat, name in C.CACHED_PARAMS: + # self.bridge.call('asyncGetParamA', param_cb(cat, name, count), name, cat) + + def profilePlugged(self, dummy): # FIXME: to be called as a "profilePlugged" listener? + QuickApp.profilePlugged(self, dummy) + + microblog_widget = self.displayWidget(blog.MicroblogPanel, ()) + self.setSelected(microblog_widget) + + # we fill the panels already here + for wid in self.widgets.getWidgets(blog.MicroblogPanel): + if wid.accept_all(): + self.bridge.getMassiveMblogs('ALL', (), None, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) + else: + self.bridge.getMassiveMblogs('GROUP', list(wid.accepted_groups), None, profile=C.PROF_KEY_NONE, callback=wid.massiveInsert) + + #we ask for our own microblogs: + self.loadOurMainEntries() - # get cached params and refresh the display - def param_cb(cat, name, count): - count[0] += 1 - refresh = count[0] == len(C.CACHED_PARAMS) - return lambda value: self._paramUpdate(name, value, cat, refresh) + def addContactList(self, dummy): + contact_list = ContactList(self) + self.panel.addContactList(contact_list) + + # FIXME: the contact list height has to be set manually the first time + self.resize() + + return contact_list + + def newWidget(self, wid): + log.debug("newWidget: {}".format(wid)) + self.addWidget(wid) - count = [0] # used to do something similar to DeferredList - for cat, name in C.CACHED_PARAMS: - self.bridge.call('asyncGetParamA', param_cb(cat, name, count), name, cat) + def newMessageHandler(self, from_jid_s, msg, type_, to_jid_s, extra, profile=C.PROF_KEY_NONE): + if type_ == C.MESS_TYPE_HEADLINE: + from_jid = jid.JID(from_jid_s) + if from_jid.domain == self._defaultDomain: + # we display announcement from the server in a dialog for better visibility + try: + title = extra['subject'] + except KeyError: + title = _('Announcement from %s') % from_jid + msg = strings.addURLToText(html_tools.XHTML2Text(msg)) + dialog.InfoDialog(title, msg).show() + return + QuickApp.newMessageHandler(self, from_jid_s, msg, type_, to_jid_s, extra, profile) + + def disconnectedHandler(self, profile): + QuickApp.disconnectedHandler(self, profile) + Window.getLocation().reload() + + def setStatusOnline(self, online=True, show='', statuses={}, profile=C.PROF_KEY_NONE): + self.presence_status_panel.setPresence(show) + if statuses: + # FIXME: retrieve user language status or fallback to 'default' + self.presence_status_panel.setStatus(statuses.values()[0]) def _tryAutoConnect(self, skip_validation=False): """This method retrieve the eventual URL parameters to auto-connect the user. @@ -447,7 +435,8 @@ elif "public_blog" in data: # TODO: use the bare instead of node when all blogs can be retrieved node = jid.JID(data['public_blog']).node - self.addTab("%s's blog" % node, panels.WebPanel(self, "/blog/%s" % node)) + # FIXME: "/blog/{}" won't work with unicode nodes + self.displayWidget(widget.WebWidget, "/blog/{}".format(node), show_url=False, new_tab=_(u"{}'s blog").format(unicode(node))) else: dialog.InfoDialog("Error", "Unmanaged action result", Width="400px").center() @@ -455,9 +444,9 @@ def _actionEb(self, err_data): err_code, err_obj = err_data dialog.InfoDialog("Error", - str(err_obj), Width="400px").center() + unicode(err_obj), Width="400px").center() - def launchAction(self, callback_id, data): + def launchAction(self, callback_id, data=None, callback=None, profile=C.PROF_KEY_NONE): """ Launch a dynamic action @param callback_id: id of the action to launch @param data: data needed only for certain actions @@ -465,78 +454,13 @@ """ if data is None: data = {} - self.bridge.call('launchAction', (self._actionCb, self._actionEb), callback_id, data) + self.bridge.launchAction(callback_id, data, profile=profile, callback=self._actionCb, errback=self._actionEb) def _getContactsCB(self, contacts_data): for contact_ in contacts_data: jid, attributes, groups = contact_ self._newContactCb(jid, attributes, groups) - def _getSignalsCB(self, signal_data): - self.bridge_signals.call('getSignals', self._getSignalsCB) - if len(signal_data) == 1: - signal_data.append([]) - log.debug("Got signal ==> name: %s, params: %s" % (signal_data[0], signal_data[1])) - name, args = signal_data - if name == 'personalEvent': - self._personalEventCb(*args) - elif name == 'newMessage': - self._newMessageCb(*args) - elif name == 'presenceUpdate': - self._presenceUpdateCb(*args) - elif name == 'paramUpdate': - self._paramUpdate(*args) - elif name == 'roomJoined': - self._roomJoinedCb(*args) - elif name == 'roomLeft': - self._roomLeftCb(*args) - elif name == 'roomUserJoined': - self._roomUserJoinedCb(*args) - elif name == 'roomUserLeft': - self._roomUserLeftCb(*args) - elif name == 'roomUserChangedNick': - self._roomUserChangedNickCb(*args) - elif name == 'askConfirmation': - self._askConfirmation(*args) - elif name == 'newAlert': - self._newAlert(*args) - elif name == 'tarotGamePlayers': - self._tarotGameStartedCb(True, *args) - elif name == 'tarotGameStarted': - self._tarotGameStartedCb(False, *args) - elif name == 'tarotGameNew' or \ - name == 'tarotGameChooseContrat' or \ - name == 'tarotGameShowCards' or \ - name == 'tarotGameInvalidCards' or \ - name == 'tarotGameCardsPlayed' or \ - name == 'tarotGameYourTurn' or \ - name == 'tarotGameScore': - self._tarotGameGenericCb(name, args[0], args[1:]) - elif name == 'radiocolPlayers': - self._radioColStartedCb(True, *args) - elif name == 'radiocolStarted': - self._radioColStartedCb(False, *args) - elif name == 'radiocolPreload': - self._radioColGenericCb(name, args[0], args[1:]) - elif name == 'radiocolPlay': - self._radioColGenericCb(name, args[0], args[1:]) - elif name == 'radiocolNoUpload': - self._radioColGenericCb(name, args[0], args[1:]) - elif name == 'radiocolUploadOk': - self._radioColGenericCb(name, args[0], args[1:]) - elif name == 'radiocolSongRejected': - self._radioColGenericCb(name, args[0], args[1:]) - elif name == 'subscribe': - self._subscribeCb(*args) - elif name == 'contactDeleted': - self._contactDeletedCb(*args) - elif name == 'newContact': - self._newContactCb(*args) - elif name == 'entityDataUpdated': - self._entityDataUpdatedCb(*args) - elif name == 'chatStateReceived': - self._chatStateReceivedCb(*args) - def _getParamsUICB(self, xml_ui): """Hide the parameters item if there's nothing to display""" if not xml_ui: @@ -558,48 +482,52 @@ _groups = set(mblog['groups'].split() if mblog['groups'] else []) else: _groups = None - mblog_entry = panels.MicroblogItem(mblog) + mblog_entry = blog.MicroblogItem(mblog) cache.append((_groups, mblog_entry)) self.mblog_cache.extend(cache) if len(self.mblog_cache) > MAX_MBLOG_CACHE: del self.mblog_cache[0:len(self.mblog_cache - MAX_MBLOG_CACHE)] - widget_list = [mblog_panel] if mblog_panel else self.libervia_widgets - for lib_wid in widget_list: - if isinstance(lib_wid, panels.MicroblogPanel): - self.fillMicroblogPanel(lib_wid, cache) + widget_list = [mblog_panel] if mblog_panel else self.widgets.getWidgets(blog.MicroblogPanel) + + for wid in widget_list: + self.fillMicroblogPanel(wid, cache) + + # FIXME if self.initialised: return self.initialised = True # initialisation phase is finished here for event_data in self.init_cache: # so we have to send all the cached events - self._personalEventCb(*event_data) + self.personalEventHandler(*event_data) del self.init_cache def _getProfileJidCB(self, jid_s): - self.whoami = jid.JID(jid_s) - #we can now ask our status - self.bridge.call('getPresenceStatuses', self._getPresenceStatusesCb) - #the rooms where we are - self.bridge.call('getRoomsJoined', self._getRoomsJoinedCb) - #and if there is any subscription request waiting for us - self.bridge.call('getWaitingSub', self._getWaitingSubCb) - #we fill the panels already here - for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.MicroblogPanel): - if lib_wid.accept_all(): - self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'ALL', []) - else: - self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'GROUP', lib_wid.accepted_groups) + # FIXME + raise Exception("should not be here !") + # self.whoami = jid.JID(jid_s) + # #we can now ask our status + # self.bridge.call('getPresenceStatuses', self._getPresenceStatusesCb) + # #the rooms where we are + # self.bridge.call('getRoomsJoined', self._getRoomsJoinedCb) + # #and if there is any subscription request waiting for us + # self.bridge.call('getWaitingSub', self._getWaitingSubCb) + # #we fill the panels already here + # for lib_wid in self.libervia_widgets: + # if isinstance(lib_wid, panel.MicroblogPanel): + # if lib_wid.accept_all(): + # self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'ALL', [], 10) + # else: + # self.bridge.call('getMassiveLastMblogs', lib_wid.massiveInsert, 'GROUP', lib_wid.accepted_groups, 10) - #we ask for our own microblogs: - self.loadOurMainEntries() + # #we ask for our own microblogs: + # self.loadOurMainEntries() - # initialize plugins which waited for the connection to be done - for plugin in self.plugins.values(): - if hasattr(plugin, 'profileConnected'): - plugin.profileConnected() + # # initialize plugins which waited for the connection to be done + # for plugin in self.plugins.values(): + # if hasattr(plugin, 'profileConnected'): + # plugin.profileConnected() def loadOurMainEntries(self, index=0, mblog_panel=None): """Load a page of our own blogs from the cache or ask them to the @@ -610,7 +538,7 @@ """ delta = index - self.next_rsm_index if delta < 0: - assert(mblog_panel is not None) + assert mblog_panel is not None self.fillMicroblogPanel(mblog_panel, self.mblog_cache[index:index + C.RSM_MAX_ITEMS]) return @@ -618,12 +546,13 @@ self._ownBlogsFills(result, mblog_panel) rsm = {'max': str(delta + C.RSM_MAX_ITEMS), 'index': str(self.next_rsm_index)} - self.bridge.call('getMassiveMblogs', cb, 'JID', [self.whoami.bare], rsm) + self.bridge.getMassiveMblogs('JID', [unicode(self.whoami.bare)], rsm, callback=cb, profile=C.PROF_KEY_NONE) self.next_rsm_index = index + C.RSM_MAX_ITEMS ## Signals callbacks ## - def _personalEventCb(self, sender, event_type, data): + def personalEventHandler(self, sender, event_type, data): + # FIXME: move some code from here to QuickApp if not self.initialised: self.init_cache.append((sender, event_type, data)) return @@ -636,11 +565,10 @@ _groups = set(data['groups'].split() if data['groups'] else []) else: _groups = None - mblog_entry = panels.MicroblogItem(data) + mblog_entry = blog.MicroblogItem(data) - for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.MicroblogPanel): - self.addBlogEntry(lib_wid, sender, _groups, mblog_entry) + for wid in self.widgets.getWidgets(blog.MicroblogPanel): + wid.addEntryIfAccepted(sender, _groups, mblog_entry) if sender == self.whoami.bare: found = False @@ -657,9 +585,8 @@ if len(self.mblog_cache) > MAX_MBLOG_CACHE: del self.mblog_cache[0:len(self.mblog_cache - MAX_MBLOG_CACHE)] elif event_type == 'MICROBLOG_DELETE': - for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.MicroblogPanel): - lib_wid.removeEntry(data['type'], data['id']) + for wid in self.widgets.getWidgets(blog.MicroblogPanel): + wid.removeEntry(data['type'], data['id']) log.debug("%s %s %s" % (self.whoami.bare, sender, data['type'])) if sender == self.whoami.bare and data['type'] == 'main_item': @@ -669,235 +596,276 @@ self.mblog_cache.remove(entry) break - def addBlogEntry(self, mblog_panel, sender, _groups, mblog_entry): - """Check if an entry can go in MicroblogPanel and add to it - @param mblog_panel: MicroblogPanel instance - @param sender: jid of the entry sender - @param _groups: groups which can receive this entry - @param mblog_entry: panels.MicroblogItem instance""" - if mblog_entry.type == "comment" or mblog_panel.isJidAccepted(sender) or (_groups == None and self.whoami and sender == self.whoami.bare) \ - or (_groups and _groups.intersection(mblog_panel.accepted_groups)): - mblog_panel.addEntry(mblog_entry) - def fillMicroblogPanel(self, mblog_panel, mblogs): """Fill a microblog panel with entries in cache + @param mblog_panel: MicroblogPanel instance """ #XXX: only our own entries are cached for cache_entry in mblogs: _groups, mblog_entry = cache_entry - self.addBlogEntry(mblog_panel, self.whoami.bare, *cache_entry) + mblog_panel.addEntryIfAccepted(self.whoami.bare, *cache_entry) def getEntityMBlog(self, entity): log.info("geting mblog for entity [%s]" % (entity,)) for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.MicroblogPanel): + if isinstance(lib_wid, blog.MicroblogPanel): if lib_wid.isJidAccepted(entity): - self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'JID', [entity]) + self.bridge.call('getMassiveMblogs', lib_wid.massiveInsert, 'JID', [unicode(entity)]) + + # def getLiberviaWidget(self, class_, entity, ignoreOtherTabs=True): + # """Get the corresponding panel if it exists. + # @param class_ (class): class of the panel (ChatPanel, MicroblogPanel...) + # @param entity (dict): dictionnary to define the entity. + # @param ignoreOtherTabs (bool): if True, the widgets that are not + # contained by the currently selected tab will be ignored + # @return: the existing widget that has been found or None.""" + # selected_tab = self.tab_panel.getCurrentPanel() + # for lib_wid in self.libervia_widgets: + # parent = lib_wid.getWidgetsPanel(expect=False) + # if parent is None or (ignoreOtherTabs and parent != selected_tab): + # # do not return a widget that is not in the currently selected tab + # continue + # if isinstance(lib_wid, class_): + # try: + # if lib_wid.matchEntity(*(entity.values())): # XXX: passing **entity bugs! + # log.debug("existing widget found: %s" % lib_wid.getDebugName()) + # return lib_wid + # except AttributeError as e: + # e.stack_list() + # return None + # return None - def getLiberviaWidget(self, class_, entity, ignoreOtherTabs=True): - """Get the corresponding panel if it exists. - @param class_ (class): class of the panel (ChatPanel, MicroblogPanel...) - @param entity (dict): dictionnary to define the entity. - @param ignoreOtherTabs (bool): if True, the widgets that are not - contained by the currently selected tab will be ignored - @return: the existing widget that has been found or None.""" - selected_tab = self.tab_panel.getCurrentPanel() - for lib_wid in self.libervia_widgets: - parent = lib_wid.getWidgetsPanel(expect=False) - if parent is None or (ignoreOtherTabs and parent != selected_tab): - # do not return a widget that is not in the currently selected tab - continue - if isinstance(lib_wid, class_): - try: - if lib_wid.matchEntity(*(entity.values())): # XXX: passing **entity bugs! - log.debug("existing widget found: %s" % lib_wid.getDebugName()) - return lib_wid - except AttributeError as e: - e.stack_list() - return None - return None + def displayWidget(self, class_, target, dropped=False, new_tab=None, *args, **kwargs): + """Get or create a LiberviaWidget and select it. When the user dropped + something, a new widget is always created, otherwise we look for an + existing widget and re-use it if it's in the current tab. + + @arg class_(class): see quick_widgets.getOrCreateWidget + @arg target: see quick_widgets.getOrCreateWidget + @arg dropped(bool): if True, assume the widget has been dropped + @arg new_tab(unicode): if not None, it holds the name of a new tab to + open for the widget. If None, use the default behavior. + @param args(list): optional args to create a new instance of class_ + @param kwargs(list): optional kwargs to create a new instance of class_ + @return: the widget + """ + kwargs['profile'] = C.PROF_KEY_NONE + + if dropped: + kwargs['on_new_widget'] = None + kwargs['on_existing_widget'] = C.WIDGET_RECREATE + wid = self.widgets.getOrCreateWidget(class_, target, *args, **kwargs) + self.setSelected(wid) + return wid + + if new_tab: + kwargs['on_new_widget'] = None + kwargs['on_existing_widget'] = C.WIDGET_RECREATE + wid = self.widgets.getOrCreateWidget(class_, target, *args, **kwargs) + self.tab_panel.addWidgetsTab(new_tab) + self.addWidget(wid, tab_index=self.tab_panel.getWidgetCount() - 1) + return wid - def getOrCreateLiberviaWidget(self, class_, entity, select=True, new_tab=None): - """Get the matching LiberviaWidget if it exists, or create a new one. - @param class_ (class): class of the panel (ChatPanel, MicroblogPanel...) - @param entity (dict): dictionnary to define the entity. - @param select (bool): if True, select the widget that has been found or created - @param new_tab (str): if not None, a widget which is created is created in - a new tab. In that case new_tab is a unicode to label that new tab. - If new_tab is not None and a widget is found, no tab is created. - @return: the newly created wigdet if REUSE_EXISTING_LIBERVIA_WIDGETS - is set to False or if the widget has not been found, the existing - widget that has been found otherwise.""" - lib_wid = None - tab = None - if REUSE_EXISTING_LIBERVIA_WIDGETS: - lib_wid = self.getLiberviaWidget(class_, entity, new_tab is None) - if lib_wid is None: # create a new widget - lib_wid = class_.createPanel(self, *(entity.values())) # XXX: passing **entity bugs! - if new_tab is None: - self.addWidget(lib_wid) - else: - tab = self.addTab(new_tab, lib_wid, False) - else: # reuse existing widget - tab = lib_wid.getWidgetsPanel(expect=False) - if new_tab is None: - if tab is not None: - tab.removeWidget(lib_wid) - self.addWidget(lib_wid) - if select: - if new_tab is not None: - self.tab_panel.selectTab(tab) - # must be done after the widget is added, - # for example to scroll to the bottom - self.setSelected(lib_wid) - lib_wid.refresh() - return lib_wid + kwargs['on_existing_widget'] = C.WIDGET_RAISE + try: + wid = self.widgets.getOrCreateWidget(class_, target, *args, **kwargs) + except quick_widgets.WidgetAlreadyExistsError: + kwargs['on_existing_widget'] = C.WIDGET_KEEP + wid = self.widgets.getOrCreateWidget(class_, target, *args, **kwargs) + widgets_panel = wid.getParent(libervia_widget.WidgetsPanel, expect=False) + if widgets_panel is None: + # The widget exists but is hidden + self.addWidget(wid) + elif widgets_panel != self.tab_panel.getCurrentPanel(): + # the widget is on an other tab, so we add a new one here + kwargs['on_existing_widget'] = C.WIDGET_RECREATE + wid = self.widgets.getOrCreateWidget(class_, target, *args, **kwargs) + self.addWidget(wid) + self.setSelected(wid) + return wid + - def getRoomWidget(self, target): - """Get the MUC widget for the given target. - - @param target (jid.JID): BARE jid of the MUC - @return: panels.ChatPanel instance or None - """ - entity = {'item': target, 'type_': 'group'} - if target.full() in self.room_list or target in self.room_list: # as JID is a string-based class, we don't know what will please Pyjamas... - return self.getLiberviaWidget(panels.ChatPanel, entity, ignoreOtherTabs=False) - return None - - def getOrCreateRoomWidget(self, target): - """Get the MUC widget for the given target, create it if necessary. + # def getOrCreateLiberviaWidget(self, class_, entity, select=True, new_tab=None): + # """Get the matching LiberviaWidget if it exists, or create a new one. + # @param class_ (class): class of the panel (ChatPanel, MicroblogPanel...) + # @param entity (dict): dictionnary to define the entity. + # @param select (bool): if True, select the widget that has been found or created + # @param new_tab (unicode): if not None, a widget which is created is created in + # a new tab. In that case new_tab is a unicode to label that new tab. + # If new_tab is not None and a widget is found, no tab is created. + # @return: the newly created wigdet if REUSE_EXISTING_LIBERVIA_WIDGETS + # is set to False or if the widget has not been found, the existing + # widget that has been found otherwise.""" + # lib_wid = None + # tab = None + # if REUSE_EXISTING_LIBERVIA_WIDGETS: + # lib_wid = self.getLiberviaWidget(class_, entity, new_tab is None) + # if lib_wid is None: # create a new widget + # lib_wid = class_.createPanel(self, *(entity.values())) # XXX: passing **entity bugs! + # if new_tab is None: + # self.addWidget(lib_wid) + # else: + # tab = self.addTab(new_tab, lib_wid, False) + # else: # reuse existing widget + # tab = lib_wid.getWidgetsPanel(expect=False) + # if new_tab is None: + # if tab is not None: + # tab.removeWidget(lib_wid) + # self.addWidget(lib_wid) + # if select: + # if new_tab is not None: + # self.tab_panel.selectTab(tab) + # # must be done after the widget is added, + # # for example to scroll to the bottom + # self.setSelected(lib_wid) + # lib_wid.refresh() + # return lib_wid - @param target (jid.JID): BARE jid of the MUC - @return: panels.ChatPanel instance - """ - lib_wid = self.getRoomWidget(target) - if lib_wid: - return lib_wid - - # XXX: target.node.startwith(...) raises an error "startswith is not a function" - # This happens when node a is property defined in the JID class - # FIXME: pyjamas doesn't handle the properties well - node = target.node + # def getRoomWidget(self, target): + # """Get the MUC widget for the given target. - # XXX: it's not really beautiful, but it works :) - if node.startswith('sat_tarot_'): - tab_name = "Tarot" - elif node.startswith('sat_radiocol_'): - tab_name = "Radio collective" - else: - tab_name = target.node + # @param target (jid.JID): BARE jid of the MUC + # @return: panel.ChatPanel instance or None + # """ + # entity = {'item': target, 'type_': 'group'} + # if target.full() in self.room_list or target in self.room_list: # as JID is a string-based class, we don't know what will please Pyjamas... + # return self.getLiberviaWidget(panel.ChatPanel, entity, ignoreOtherTabs=False) + # return None - self.room_list.append(target) - entity = {'item': target, 'type_': 'group'} - return self.getOrCreateLiberviaWidget(panels.ChatPanel, entity, new_tab=tab_name) + # def getOrCreateRoomWidget(self, target): + # """Get the MUC widget for the given target, create it if necessary. - def _newMessageCb(self, from_jid_s, msg, msg_type, to_jid_s, extra): - from_jid = jid.JID(from_jid_s) - to_jid = jid.JID(to_jid_s) - for plugin in self.plugins.values(): - if hasattr(plugin, 'messageReceivedTrigger'): - if not plugin.messageReceivedTrigger(from_jid, msg, msg_type, to_jid, extra): - return # plugin returned False to interrupt the process - self.newMessageCb(from_jid, msg, msg_type, to_jid, extra) + # @param target (jid.JID): BARE jid of the MUC + # @return: panel.ChatPanel instance + # """ + # lib_wid = self.getRoomWidget(target) + # if lib_wid: + # return lib_wid + + # # XXX: target.node.startwith(...) raises an error "startswith is not a function" + # # This happens when node a is property defined in the JID class + # # FIXME: pyjamas doesn't handle the properties well + # node = target.node + + # # XXX: it's not really beautiful, but it works :) + # if node.startswith('sat_tarot_'): + # tab_name = "Tarot" + # elif node.startswith('sat_radiocol_'): + # tab_name = "Radio collective" + # else: + # tab_name = target.node + + # self.room_list.append(target) + # entity = {'item': target, 'type_': 'group'} + # return self.getOrCreateLiberviaWidget(panel.ChatPanel, entity, new_tab=tab_name) - def newMessageCb(self, from_jid, msg, msg_type, to_jid, extra): - other = to_jid if from_jid.bare == self.whoami.bare else from_jid - lib_wid = self.getLiberviaWidget(panels.ChatPanel, {'item': other}, ignoreOtherTabs=False) - self.displayNotification(from_jid, msg) - if msg_type == 'headline' and from_jid.full() == self._defaultDomain: - try: - assert extra['subject'] # subject is defined and not empty - title = extra['subject'] - except (KeyError, AssertionError): - title = _('Announcement from %s') % from_jid.full() - msg = strings.addURLToText(html_tools.XHTML2Text(msg)) - dialog.InfoDialog(title, msg).show() - return - if lib_wid is not None: - if msg_type == C.MESS_TYPE_INFO: - lib_wid.printInfo(msg) - else: - lib_wid.printMessage(from_jid, msg, extra) - if 'header_info' in extra: - lib_wid.setHeaderInfo(extra['header_info']) - else: - # FIXME: "info" message and header info will be lost here - if not self.contact_panel.isContactInRoster(other.bare): - self.contact_panel.updateContact(other.bare, {}, [C.GROUP_NOT_IN_ROSTER]) - # The message has not been shown, we must indicate it - self.contact_panel.setContactMessageWaiting(other.bare, True) + # def _newMessageCb(self, from_jid_s, msg, msg_type, to_jid_s, extra): + # from_jid = jid.JID(from_jid_s) + # to_jid = jid.JID(to_jid_s) + # for plugin in self.plugins.values(): + # if hasattr(plugin, 'messageReceivedTrigger'): + # if not plugin.messageReceivedTrigger(from_jid, msg, msg_type, to_jid, extra): + # return # plugin returned False to interrupt the process + # self.newMessageCb(from_jid, msg, msg_type, to_jid, extra) + + # def newMessageCb(self, from_jid, msg, msg_type, to_jid, extra): + # other = to_jid if from_jid.bare == self.whoami.bare else from_jid + # lib_wid = self.getLiberviaWidget(panel.ChatPanel, {'item': other}, ignoreOtherTabs=False) + # self.displayNotification(from_jid, msg) + # if msg_type == 'headline' and from_jid.full() == self._defaultDomain: + # try: + # assert extra['subject'] # subject is defined and not empty + # title = extra['subject'] + # except (KeyError, AssertionError): + # title = _('Announcement from %s') % from_jid.full() + # msg = strings.addURLToText(html_tools.XHTML2Text(msg)) + # dialog.InfoDialog(title, msg).show() + # return + # if lib_wid is not None: + # if msg_type == C.MESS_TYPE_INFO: + # lib_wid.printInfo(msg) + # else: + # lib_wid.printMessage(from_jid, msg, extra) + # if 'header_info' in extra: + # lib_wid.setHeaderInfo(extra['header_info']) + # else: + # # FIXME: "info" message and header info will be lost here + # if not self.contact_panel.isContactInRoster(other.bare): + # self.contact_panel.updateContact(other.bare, {}, [C.GROUP_NOT_IN_ROSTER]) + # # The message has not been shown, we must indicate it + # self.contact_panel.setContactMessageWaiting(other.bare, True) - def _presenceUpdateCb(self, entity, show, priority, statuses): - entity_jid = jid.JID(entity) - if self.whoami and self.whoami == entity_jid: # XXX: QnD way to get our presence/status - assert(isinstance(self.status_panel, panels.PresenceStatusPanel)) - self.status_panel.setPresence(show) # pylint: disable=E1103 - if statuses: - self.status_panel.setStatus(statuses.values()[0]) # pylint: disable=E1103 - else: - bare_jid = entity_jid.bareJID() - if bare_jid.full() in self.room_list or bare_jid in self.room_list: # as JID is a string-based class, we don't know what will please Pyjamas... - wid = self.getRoomWidget(bare_jid) - else: - wid = self.contact_panel - if show == 'unavailable': # XXX: save some resources as for now we only need 'unavailable' - for plugin in self.plugins.values(): - if hasattr(plugin, 'presenceReceivedTrigger'): - plugin.presenceReceivedTrigger(entity_jid, show, priority, statuses) - if wid: - wid.setConnected(entity_jid.bare, entity_jid.resource, show, priority, statuses) + # def _presenceUpdateCb(self, entity, show, priority, statuses): + # entity_jid = jid.JID(entity) + # if self.whoami and self.whoami == entity_jid: # XXX: QnD way to get our presence/status + # assert(isinstance(self.status_panel, main_panel.PresenceStatusPanel)) + # self.status_panel.setPresence(show) # pylint: disable=E1103 + # if statuses: + # self.status_panel.setStatus(statuses.values()[0]) # pylint: disable=E1103 + # else: + # bare_jid = entity_jid.bareJID() + # if bare_jid.full() in self.room_list or bare_jid in self.room_list: # as JID is a string-based class, we don't know what will please Pyjamas... + # wid = self.getRoomWidget(bare_jid) + # else: + # wid = self.contact_panel + # if show == 'unavailable': # XXX: save some resources as for now we only need 'unavailable' + # for plugin in self.plugins.values(): + # if hasattr(plugin, 'presenceReceivedTrigger'): + # plugin.presenceReceivedTrigger(entity_jid, show, priority, statuses) + # if wid: + # wid.setConnected(entity_jid.bare, entity_jid.resource, show, priority, statuses) - def _roomJoinedCb(self, room_jid_s, room_nicks, user_nick): - chat_panel = self.getOrCreateRoomWidget(jid.JID(room_jid_s)) - chat_panel.setUserNick(user_nick) - chat_panel.setPresents(room_nicks) - chat_panel.refresh() + # def _roomJoinedCb(self, room_jid_s, room_nicks, user_nick): + # chat_panel = self.getOrCreateRoomWidget(jid.JID(room_jid_s)) + # chat_panel.setUserNick(user_nick) + # chat_panel.setPresents(room_nicks) + # chat_panel.refresh() - def _roomLeftCb(self, room_jid_s, room_nicks, user_nick): - try: - del self.room_list[room_jid_s] - except KeyError: - try: # as JID is a string-based class, we don't know what will please Pyjamas... - del self.room_list[jid.JID(room_jid_s)] - except KeyError: - pass + # def _roomLeftCb(self, room_jid_s, room_nicks, user_nick): + # try: + # del self.room_list[room_jid_s] + # except KeyError: + # try: # as JID is a string-based class, we don't know what will please Pyjamas... + # del self.room_list[jid.JID(room_jid_s)] + # except KeyError: + # pass - def _roomUserJoinedCb(self, room_jid_s, user_nick, user_data): - lib_wid = self.getOrCreateRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - lib_wid.userJoined(user_nick, user_data) + # def _roomUserJoinedCb(self, room_jid_s, user_nick, user_data): + # lib_wid = self.getOrCreateRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # lib_wid.userJoined(user_nick, user_data) - def _roomUserLeftCb(self, room_jid_s, user_nick, user_data): - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - lib_wid.userLeft(user_nick, user_data) + # def _roomUserLeftCb(self, room_jid_s, user_nick, user_data): + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # lib_wid.userLeft(user_nick, user_data) - def _roomUserChangedNickCb(self, room_jid_s, old_nick, new_nick): - """Called when an user joined a MUC room""" - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - lib_wid.changeUserNick(old_nick, new_nick) + # def _roomUserChangedNickCb(self, room_jid_s, old_nick, new_nick): + # """Called when an user joined a MUC room""" + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # lib_wid.changeUserNick(old_nick, new_nick) - def _tarotGameStartedCb(self, waiting, room_jid_s, referee, players): - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - lib_wid.startGame("Tarot", waiting, referee, players) + # def _tarotGameStartedCb(self, waiting, room_jid_s, referee, players): + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # lib_wid.startGame("Tarot", waiting, referee, players) - def _tarotGameGenericCb(self, event_name, room_jid_s, args): - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - getattr(lib_wid.getGame("Tarot"), event_name)(*args) + # def _tarotGameGenericCb(self, event_name, room_jid_s, args): + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # getattr(lib_wid.getGame("Tarot"), event_name)(*args) - def _radioColStartedCb(self, waiting, room_jid_s, referee, players, queue_data): - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - lib_wid.startGame("RadioCol", waiting, referee, players, queue_data) + # def _radioColStartedCb(self, waiting, room_jid_s, referee, players, queue_data): + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # lib_wid.startGame("RadioCol", waiting, referee, players, queue_data) - def _radioColGenericCb(self, event_name, room_jid_s, args): - lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) - if lib_wid: - getattr(lib_wid.getGame("RadioCol"), event_name)(*args) + # def _radioColGenericCb(self, event_name, room_jid_s, args): + # lib_wid = self.getRoomWidget(jid.JID(room_jid_s)) + # if lib_wid: + # getattr(lib_wid.getGame("RadioCol"), event_name)(*args) def _getPresenceStatusesCb(self, presence_data): for entity in presence_data: @@ -929,7 +897,8 @@ msg = HTML('The contact %s want to add you in his/her contact list, do you accept ?' % html_tools.html_sanitize(entity)) def ok_cb(ignore): - self.bridge.call('subscription', None, "subscribed", entity, '', _dialog.getSelectedGroups()) + self.bridge.call('subscription', None, "subscribed", entity) + self.bridge.updateContact(entity, '', _dialog.getSelectedGroups()) def cancel_cb(ignore): self.bridge.call('subscription', None, "unsubscribed", entity, '', '') @@ -945,31 +914,32 @@ self.contact_panel.updateContact(contact_jid, attributes, groups) def _entityDataUpdatedCb(self, entity_jid_s, key, value): + raise Exception # FIXME should not be here if key == "avatar": avatar = '/' + C.AVATARS_DIR + value self.avatars_cache[entity_jid_s] = avatar self.contact_panel.updateAvatar(entity_jid_s, avatar) for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.MicroblogPanel): + if isinstance(lib_wid, blog.MicroblogPanel): if lib_wid.isJidAccepted(entity_jid_s) or (self.whoami and entity_jid_s == self.whoami.bare): lib_wid.updateValue('avatar', entity_jid_s, avatar) - def _chatStateReceivedCb(self, from_jid_s, state): - """Callback when a new chat state is received. - @param from_jid_s: JID of the contact who sent his state, or '@ALL@' - @param state: new state (string) - """ - if from_jid_s == '@ALL@': - for lib_wid in self.libervia_widgets: - if isinstance(lib_wid, panels.ChatPanel): - lib_wid.setState(state, nick=C.ALL_OCCUPANTS) - return - from_jid = jid.JID(from_jid_s) - lib_wid = self.getLiberviaWidget(panels.ChatPanel, {'item': from_jid}, ignoreOtherTabs=False) - lib_wid.setState(state, nick=from_jid.resource) + # def _chatStateReceivedCb(self, from_jid_s, state): + # """Callback when a new chat state is received. + # @param from_jid_s: JID of the contact who sent his state, or '@ALL@' + # @param state (unicode): new state + # """ + # if from_jid_s == '@ALL@': + # for lib_wid in self.libervia_widgets: + # if isinstance(lib_wid, panel.ChatPanel): + # lib_wid.setState(state, nick=C.ALL_OCCUPANTS) + # return + # from_jid = jid.JID(from_jid_s) + # lib_wid = self.getLiberviaWidget(panel.ChatPanel, {'item': from_jid}, ignoreOtherTabs=False) + # lib_wid.setState(state, nick=from_jid.resource) - def _askConfirmation(self, confirmation_id, confirmation_type, data): + def askConfirmationHandler(self, confirmation_id, confirmation_type, data): answer_data = {} def confirm_cb(result): @@ -996,51 +966,53 @@ def getCachedParam(self, category, name): """Return a parameter cached value (e.g for refreshing the UI) - @param category (str): the parameter category - @pram name (str): the parameter name + @param category (unicode): the parameter category + @pram name (unicode): the parameter name """ return self.cached_params[(category, name)] if (category, name) in self.cached_params else None def sendError(self, errorData): dialog.InfoDialog("Error while sending message", "Your message can't be sent", Width="400px").center() - log.error("sendError: %s" % str(errorData)) + log.error("sendError: %s" % unicode(errorData)) - def send(self, targets, text, extra={}): - """Send a message to any target type. - @param targets: list of tuples (type, entities, addr) with: - - type in ("PUBLIC", "GROUP", "COMMENT", "STATUS" , "groupchat" , "chat") - - entities could be a JID, a list groups, a node hash... depending the target - - addr in ("To", "Cc", "Bcc") - ignore case - @param text: the message content - @param extra: options - """ - # FIXME: too many magic strings, we should use constants instead - addresses = [] - for target in targets: - type_, entities, addr = target[0], target[1], 'to' if len(target) < 3 else target[2].lower() - if type_ in ("PUBLIC", "GROUP"): - self.bridge.call("sendMblog", None, type_, entities if type_ == "GROUP" else None, text, extra) - elif type_ == "COMMENT": - self.bridge.call("sendMblogComment", None, entities, text, extra) - elif type_ == "STATUS": - assert(isinstance(self.status_panel, panels.PresenceStatusPanel)) - self.bridge.call('setStatus', None, self.status_panel.presence, text) # pylint: disable=E1103 - elif type_ in ("groupchat", "chat"): - addresses.append((addr, entities)) - else: - log.error("Unknown target type") - if addresses: - if len(addresses) == 1 and addresses[0][0] == 'to': - to_jid_s = addresses[0][1] - for plugin in self.plugins.values(): - if hasattr(plugin, 'sendMessageTrigger'): - if not plugin.sendMessageTrigger(jid.JID(to_jid_s), text, type_, extra): - return # plugin returned False to interrupt the process - self.bridge.call('sendMessage', (None, self.sendError), to_jid_s, text, '', type_, extra) - else: - extra.update({'address': '\n'.join([('%s:%s' % entry) for entry in addresses])}) - self.bridge.call('sendMessage', (None, self.sendError), self.whoami.domain, text, '', type_, extra) + # FIXME: this method is fat too complicated and depend of widget type + # must be refactored and moved to each widget instead + # def send(self, targets, text, extra={}): + # """Send a message to any target type. + # @param targets: list of tuples (type, entities, addr) with: + # - type in ("PUBLIC", "GROUP", "COMMENT", "STATUS" , "groupchat" , "chat") + # - entities could be a JID, a list groups, a node hash... depending the target + # - addr in ("To", "Cc", "Bcc") - ignore case + # @param text: the message content + # @param extra: options + # """ + # # FIXME: too many magic strings, we should use constants instead + # addresses = [] + # for target in targets: + # type_, entities, addr = target[0], target[1], 'to' if len(target) < 3 else target[2].lower() + # if type_ in ("PUBLIC", "GROUP"): + # self.bridge.call("sendMblog", None, type_, entities if type_ == "GROUP" else None, text, extra) + # elif type_ == "COMMENT": + # self.bridge.call("sendMblogComment", None, entities, text, extra) + # elif type_ == "STATUS": + # assert(isinstance(self.status_panel, main_panel.PresenceStatusPanel)) + # self.bridge.call('setStatus', None, self.status_panel.presence, text) # pylint: disable=E1103 + # elif type_ in ("groupchat", "chat"): + # addresses.append((addr, entities)) + # else: + # log.error("Unknown target type") + # if addresses: + # if len(addresses) == 1 and addresses[0][0] == 'to': + # to_jid_s = addresses[0][1] + # for plugin in self.plugins.values(): + # if hasattr(plugin, 'sendMessageTrigger'): + # if not plugin.sendMessageTrigger(jid.JID(to_jid_s), text, type_, extra): + # return # plugin returned False to interrupt the process + # self.bridge.call('sendMessage', (None, self.sendError), to_jid_s, text, '', type_, extra) + # else: + # extra.update({'address': '\n'.join([('%s:%s' % entry) for entry in addresses])}) + # self.bridge.call('sendMessage', (None, self.sendError), self.whoami.domain, text, '', type_, extra) def showWarning(self, type_=None, msg=None): """Display a popup information message, e.g. to notify the recipient of a message being composed. @@ -1049,11 +1021,25 @@ @msg: message to be displayed """ if not hasattr(self, "warning_popup"): - self.warning_popup = panels.WarningPopup() + self.warning_popup = main_panel.WarningPopup() self.warning_popup.showWarning(type_, msg) + def showDialog(self, message, title="", type_="info", answer_cb=None, answer_data=None): + if type_ == 'info': + popup = dialog.InfoDialog(unicode(title), unicode(message), callback=answer_cb) + elif type_ == 'error': + popup = dialog.InfoDialog(unicode(title), unicode(message), callback=answer_cb) + elif type_ == 'yes/no': + popup = dialog.ConfirmDialog(lambda answer: answer_cb(answer, answer_data), + text=unicode(message), title=unicode(title)) + popup.cancel_button.setText(_("No")) + else: + popup = dialog.InfoDialog(unicode(title), unicode(message), callback=answer_cb) + log.error(_('unmanaged dialog type: %s'), type_) + popup.show() + if __name__ == '__main__': app = SatWebFrontend() app.onModuleLoad() - pyjd.run() + host_listener.callListeners(app) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/libervia_test.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/libervia_test.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,78 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +# Just visit /test. If you don't get any AssertError pop-up, +# everything is fine. #TODO: nicely display the results in HTML output. + + +### logging configuration ### +from sat_browser import logging +logging.configure() +from sat.core.log import getLogger +log = getLogger(__name__) +### + +from sat_frontends.tools import jid +from sat_browser import contact_list + + +def test_JID(): + """Check that the JID class reproduces the Twisted behavior""" + j1 = jid.JID("t1@test.org") + j1b = jid.JID("t1@test.org") + t1 = "t1@test.org" + + assert j1 == j1b + assert j1 != t1 + assert t1 != j1 + assert hash(j1) == hash(j1b) + assert hash(j1) != hash(t1) + + +def test_JIDIterable(): + """Check that our iterables reproduce the Twisted behavior""" + + j1 = jid.JID("t1@test.org") + j1b = jid.JID("t1@test.org") + j2 = jid.JID("t2@test.org") + t1 = "t1@test.org" + t2 = "t2@test.org" + jid_set = set([j1, t2]) + jid_list = contact_list.JIDList([j1, t2]) + jid_dict = {j1: "dummy 1", t2: "dummy 2"} + for iterable in (jid_set, jid_list, jid_dict): + log.info("Testing %s" % type(iterable)) + assert j1 in iterable + assert j1b in iterable + assert j2 not in iterable + assert t1 not in iterable + assert t2 in iterable + + # Check that the extra JIDList class is still needed + log.info("Testing Pyjamas native list") + jid_native_list = ([j1, t2]) + assert j1 in jid_native_list + assert j1b not in jid_native_list # this is NOT Twisted's behavior + assert j2 in jid_native_list # this is NOT Twisted's behavior + assert t1 in jid_native_list # this is NOT Twisted's behavior + assert t2 in jid_native_list + +test_JID() +test_JIDIterable() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/public/libervia.css --- a/src/browser/public/libervia.css Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/public/libervia.css Wed Mar 18 16:15:18 2015 +0100 @@ -137,10 +137,13 @@ .header { background-color: #eee; border-bottom: 1px solid #ddd; + width: 100%; + height: 64px; } -.menuContainer { - margin: 0 32px 0 20px; +.mainPanel { + width: 100%; + height: 100%; } .mainMenuBar { @@ -148,7 +151,6 @@ background: -webkit-gradient(linear, left top, left bottom, from(#444444), to(#222222)); background: -webkit-linear-gradient(top, #444444, #222222); background: linear-gradient(to bottom, #444444, #222222); - width: 100%; height: 28px; padding: 5px 5px 0 5px; border: 1px solid #ddd; @@ -157,6 +159,10 @@ -webkit-box-shadow: 0px 1px 4px #000; box-shadow: 0px 1px 4px #000; display: inline-block; + position: absolute; + left: 20px; + right: 20px; + width: auto; } .mainMenuBar .gwt-MenuItem { @@ -342,7 +348,6 @@ text-shadow: 1px 1px 1px rgba(0,0,0,0.2); padding: 3px 5px 3px 5px; margin: 10px 5px 10px 5px; - color: #fff; font-weight: bold; font-size: 1em; border: none; @@ -350,7 +355,12 @@ transition: color 0.2s linear; } -.gwt-DialogBox .gwt-button:hover { +.gwt-DialogBox .gwt-button:enabled { + cursor: pointer; + color: #fff; +} + +.gwt-DialogBox .gwt-button:enabled:hover { background-color: #cf2828; background: -webkit-gradient(linear, left top, left bottom, from(#cf2828), to(#981a1a)); background: -webkit-linear-gradient(top, #cf2828, #981a1a); @@ -420,16 +430,9 @@ } /* Contact List */ -div.contactPanel { +div.contactList { width: 100%; - /* We want the contact panel to not use all the available height when displayed - in the unibox panel (grey part), because the dialogs panels (white part) should - still be visible. The setting max-height: fit-content would be appropriate here - but it doesn't work with firefox 24.0. TODO: check if the current setting works - with other browsers... the panel should of course not be displayed on 100px - but exactly fit the contacts box. - */ - max-height: 100px; + margin-top: 9px; } .contactTitle { @@ -447,13 +450,14 @@ border: 0; padding: 0; font-size: large; + margin-top: 9px; } -.groupList { +.groupPanel { width: 100%; } -.groupList tr:first-child td { +.groupPanel tr:first-child td { padding-top: 10px; } @@ -495,7 +499,7 @@ background: #EDEDED; } -.contactBox img { +.contactBox img, .muc_contact img { width: 32px; height: 32px; border-radius: 5px; @@ -539,6 +543,14 @@ background-color: rgb(175, 175, 175); } +/* Contacts in MUC */ + +.muc_contact { + border-radius: 5px; + background: #EDEDED; + margin: 2px; +} + /* START - contact presence status */ .contactLabel-connected { color: #3c7e0c; @@ -610,10 +622,9 @@ height:45px; } -.statusPanel { +.presenceStatusPanel { margin: auto; text-align: center; - width: 100%; padding: 5px 0px; text-shadow: 0 -1px 1px rgba(255,255,255,0.25); font-size: 1.2em; @@ -813,6 +824,12 @@ /* BorderWidgets */ +.borderWidgetOnDrag { + background-color: lightgray; + border: 1px dashed #000; + border-radius: 1em; +} + .bottomBorderWidget { height: 10px !important; } @@ -821,6 +838,14 @@ width: 10px !important; } +.leftBorderWidget { + float: right; +} + +.rightBorderWidget { + float: left; +} + /* Microblog */ .microblogPanel { @@ -1040,10 +1065,9 @@ white-space: nowrap; } -.occupantsList { +.occupantsPanelCell { border-right: 2px dotted #ddd; - margin-left: 5px; - margin-right: 10px; + padding-left: 5px; height: 100%; } @@ -1148,9 +1172,6 @@ /* Tab panel */ -.liberviaTabPanel { -} - .gwt-TabPanel { } @@ -1164,13 +1185,6 @@ border-bottom: 3px solid #a01c1c; } -.mainTabPanel .gwt-TabBar { - z-index: 10; - position: fixed; - bottom: 0; - left: 0; -} - .gwt-TabBar .gwt-TabBarFirst { height: 100%; } @@ -1178,12 +1192,28 @@ .gwt-TabBar .gwt-TabBarRest { } -.liberviaTabPanel .gwt-TabBar {; +.mainPanel .gwt-TabBar { + z-index: 10; +} + +.mainPanel .gwt-TabBar-oneTab { + position: fixed; + left: 0px; + bottom: 0px; + border: none; } -.liberviaTabPanel .gwt-TabBar .gwt-TabBarItem { - cursor: pointer; - margin-right: 5px; +.mainPanel .gwt-TabBar-oneTab .gwt-TabBarItem-wrapper { + display: none; +} + +.mainPanel .gwt-TabBar-oneTab .gwt-TabBarItem-wrapper:nth-child(3) { + display: block; +} + +.liberviaTabPanel { + width: 100%; + height: 100%; } .liberviaTabPanel .gwt-TabBarItem div { @@ -1201,6 +1231,8 @@ padding: 4px 15px 4px 15px; border-radius: 1em 1em 0 0; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.2); + cursor: pointer; + margin-right: 5px; } .liberviaTabPanel .gwt-TabBarItem-selected { @@ -1214,6 +1246,7 @@ padding: 4px 15px 4px 15px; border-radius: 1em 1em 0 0; text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); + cursor: default; } .liberviaTabPanel div.gwt-TabBarItem:hover { @@ -1229,10 +1262,6 @@ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2); } -.liberviaTabPanel .gwt-TabBar .gwt-TabBarItem-selected { - cursor: default; -} - .globalLeftArea { margin-top: 9px; } @@ -1357,23 +1386,23 @@ /* Recipients panel */ -.recipientButtonCell { +.itemButtonCell { width:55px; } -.recipientTypeMenu { +.itemKeyMenu { } -.recipientTypeItem { +.itemKey { cursor: pointer; border-radius: 5px; width: 50px; } -.recipientPanel { +.itemPanel { } -.recipientTextBox { +.itemTextBox { cursor: pointer; width: auto; border-radius: 5px 5px 5px 5px; @@ -1385,32 +1414,26 @@ font-size: 1em; } -.recipientTextBox-invalid { +.itemTextBox-invalid { -webkit-box-shadow: inset 0px 1px 4px rgba(255, 0, 0, 0.6); box-shadow: inset 0px 1px 4px rgba(255, 0, 0, 0.6); border: 1px solid rgb(255, 0, 0); } -.recipientRemoveButton { +.itemRemoveButton { margin: 0px 10px 0px 0px; padding: 0px; border: 1px dashed red; border-radius: 5px 5px 5px 5px; } -.recipientRemoveIcon { +.itemRemoveIcon { color: red; width:15px; height:15px; vertical-align: baseline; } -.dragover-recipientPanel { - border-radius: 5px; - background: none repeat scroll 0% 0% rgb(135, 179, 255); - border: 1px dashed rgb(35,79,255); -} - .recipientSpacer { height: 15px; } @@ -1448,6 +1471,12 @@ vertical-align:middle; } +.contactGroupPanel.dragover { + border-radius: 5px !important; + background: none repeat scroll 0% 0% rgb(135, 179, 255) !important; + border: 1px dashed rgb(35,79,255) !important; +} + .toggleAssignedContacts { white-space: nowrap; } diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/base_menu.py --- a/src/browser/sat_browser/base_menu.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/base_menu.py Wed Mar 18 16:15:18 2015 +0100 @@ -24,201 +24,45 @@ by base_widget.py, and the import sequence caused a JS runtime error.""" -import pyjd # this is dummy in pyjs from sat.core.log import getLogger log = getLogger(__name__) -from sat.core import exceptions from pyjamas.ui.MenuBar import MenuBar -from pyjamas.ui.UIObject import UIObject from pyjamas.ui.MenuItem import MenuItem from pyjamas import Window - -import re +from sat_frontends.quick_frontend import quick_menus +from sat_browser import html_tools -class MenuCmd: - """Return an object with an "execute" method that can be set to a menu item callback""" - - def __init__(self, object_, handler=None, data=None): - """ - @param object_ (object): a callable or a class instance - @param handler (str): method name if object_ is a class instance - @param data (dict): data to pass as the callback argument - """ - if handler is None: - assert(callable(object_)) - self.callback = object_ - else: - self.callback = getattr(object_, handler) - self.data = data - - def execute(self): - self.callback(self.data) if self.data else self.callback() - - -class PluginMenuCmd: - """Like MenuCmd, but instead of executing a method, it will command the bridge to launch an action""" - - def __init__(self, host, action_id, menu_data=None): - self.host = host - self.action_id = action_id - self.menu_data = menu_data - - def execute(self): - self.host.launchAction(self.action_id, self.menu_data) +unicode = str # FIXME: pyjamas workaround -class MenuNode(object): - """MenuNode is a basic data structure to build a menu hierarchy. - When Pyjamas MenuBar and MenuItem defines UI elements, MenuNode - stores the logical relation between them.""" - - """This class has been introduced to deal with "flattened menus", when you - want the items of a sub-menu to be displayed in the parent menu. It was - needed to break the naive relation of "one MenuBar = one category".""" +class MenuCmd(object): + """Return an object with an "execute" method that can be set to a menu item callback""" - def __init__(self, name=None, item=None, menu=None, flat_level=0): - """ - @param name (str): node name - @param item (MenuItem): associated menu item - @param menu (GenericMenuBar): associated menu bar - @param flat_level (int): sub-menus until that level see their items - displayed in the parent menu bar, instead of in a callback popup. - """ - self.name = name - self.item = item or None # associated menu item - self.menu = menu or None # associated menu bar (sub-menu) - self.flat_level = max(flat_level, -1) - self.children = [] - - def _getOrCreateCategory(self, path, path_i18n=None, types=None, create=False, sub_menu=None): - """Return the requested category. If create is True, path_i18n and - types are specified, recursively create the category and its parent. - - @param path (list[str]): path to the category - @param path_i18n (list[str]): internationalized path to the category - @param types (list[str]): types of the category and its parents - @param create (bool): if True, create the category - @param sub_menu (GenericMenuBar): instance to popup as the category - sub-menu, if it is created. Otherwise keep the previous sub-menu. - @return: MenuNode or None + def __init__(self, menu_item, caller=None): """ - assert(len(path) > 0 and len(path) == len(path_i18n) == len(types)) - if len(path) > 1: - cat = self._getOrCreateCategory(path[:1], path_i18n[:1], types[:1], create) - return cat._getOrCreateCategory(path[1:], path_i18n[1:], types[1:], create, sub_menu) if cat else None - cats = [child for child in self.children if child.menu and child.name == path[0]] - if len(cats) == 1: - return cats[0] - assert(cats == []) # there should not be more than 1 category with the same name - if create: - html = self.menu.getCategoryHTML(path_i18n[0], types[0]) - sub_menu = sub_menu if sub_menu else GenericMenuBar(self.menu.host, vertical=True) - return self.addItem(html, True, sub_menu, name=path[0]) - return None - - def getCategories(self, target_path=None): - """Return all the categories of the current node, or those of the - sub-category which is specified by target_path. - - @param target_path (list[str]): path to the target node - @return: list[MenuNode] + @param menu_item(quick_menu.MenuItem): instance of a callbable MenuItem + @param caller: menu caller """ - assert(self.menu) # this method applies to category nodes - if target_path: - assert(isinstance(target_path, list)) - cat = self._getOrCreateCategory(target_path[:-1]) - return cat.getCategories(target_path[-1:]) if cat else None - return [child for child in self.children if child.menu] - - def addMenuItem(self, path, path_i18n, types, callback=None, asHTML=False): - """Recursively add a new node, which could be a category or a leaf node. - - @param path (list[str], str): path to the item - @param path_i18n (list[str], str): internationalized path to the item - @param types (list[str], str): types of the item and its parents - @param callback (MenuCmd, PluginMenuCmd or GenericMenuBar): instance to - execute as a leaf's callback or to popup as a category sub-menu - @param asHTML (boolean): True to display the UI item as HTML - """ - log.info("addMenuItem: %s %s %s %s" % (path, path_i18n, types, callback)) + self.item = menu_item + self._caller = caller - leaf_node = hasattr(callback, "execute") - category = isinstance(callback, GenericMenuBar) - assert(not leaf_node or not category) - - path = [path] if isinstance(path, str) else path - path_i18n = [path_i18n] if isinstance(path_i18n, str) else path_i18n - types = [types for dummy in range(len(path_i18n))] if isinstance(types, str) else types - - if category: - return self._getOrCreateCategory(path, path_i18n, types, True, callback) - - if len(path) == len(path_i18n) - 1: - path.append(None) # dummy name for a leaf node - - parent = self._getOrCreateCategory(path[:-1], path_i18n[:-1], types[:-1], True) - return parent.addItem(path_i18n[-1], asHTML=asHTML, popup=callback) + def execute(self): + self.item.call(self._caller) - def addCategory(self, path, path_i18n, types, menu_bar=None): - """Recursively add a new category. - @param path (list[str], str): path to the category - @param path_i18n (list[str], str): internationalized path to the category - @param types (list[str], str): types of the category and its parents - @param menu_bar (GenericMenuBar): instance to popup as the category sub-menu. - """ - if menu_bar: - assert(isinstance(menu_bar, GenericMenuBar)) - else: - menu_bar = GenericMenuBar(self.menu.host, vertical=True) - return self.addMenuItem(path, path_i18n, types, menu_bar) - - def addItem(self, item, asHTML=None, popup=None, name=None): - """Add a single child to the current node. +class SimpleCmd(object): + """Return an object with an "executre" method that launch a callback""" - @param item: see MenuBar.addItem - @param asHTML: see MenuBar.addItem - @param popup: see MenuBar.addItem - @param name (str): the item node's name + def __init__(self, callback): """ - if item is None: # empty string is allowed to set a separator - return None - item = MenuBar.addItem(self.menu, item, asHTML, popup) - node_menu = item.getSubMenu() # node eventually uses it's own menu - - # XXX: all the dealing with flattened menus is done here - if self.flat_level > 0: - item.setSubMenu(None) # eventually remove any sub-menu callback - if item.getCommand(): - node_menu = None # node isn't a category, it needs no menu - else: - node_menu = self.menu # node uses the menu of its parent - item.setStyleName(self.menu.styles["flattened-category"]) + @param callback: method to call when menu is selected + """ + self.callback = callback - node = MenuNode(name=name, item=item, menu=node_menu, flat_level=self.flat_level - 1) - self.children.append(node) - return node - - def addCachedMenus(self, type_, menu_data=None): - """Add cached menus to instance. - - @param type_: menu type like in sat.core.sat_main.importMenu - @param menu_data: data to send with these menus - """ - menus = self.menu.host.menus.get(type_, []) - for action_id, path, path_i18n in menus: - if len(path) != len(path_i18n): - log.error("inconsistency between menu paths") - continue - if isinstance(action_id, str): - callback = PluginMenuCmd(self.menu.host, action_id, menu_data) - elif callable(action_id): - callback = MenuCmd(action_id, data=menu_data) - else: - raise exceptions.InternalError - self.addMenuItem(path, path_i18n, 'plugins', callback) + def execute(self): + self.callback() class GenericMenuBar(MenuBar): @@ -236,45 +80,63 @@ """ MenuBar.__init__(self, vertical, **kwargs) self.host = host - self.styles = {'separator': 'menuSeparator', 'flattened-category': 'menuFlattenedCategory'} + self.styles = {} if styles: self.styles.update(styles) - if 'menu_bar' in self.styles: + try: self.setStyleName(self.styles['menu_bar']) - self.node = MenuNode(menu=self, flat_level=flat_level) + except KeyError: + pass + self.menus_container = None + self.flat_level = flat_level + + def update(self, type_, caller=None): + """Method to call when menus have changed + + @param type_: menu type like in sat.core.sat_main.importMenu + @param caller: instance linked to the menus + """ + self.menus_container = self.host.menus.getMainContainer(type_) + self._caller=caller + self.createMenus() @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): + def getCategoryHTML(cls, category): """Build the html to be used for displaying a category item. Inheriting classes may overwrite this method. - @param menu_name_i18n (str): internationalized category name - @param type_ (str): category type - @return: str + @param category(quick_menus.MenuCategory): category to add + @return(unicode): HTML to display """ - return menu_name_i18n + return html_tools.html_sanitize(category.name) + + def _buildMenus(self, container, flat_level, caller=None): + """Recursively build menus of the container - def setStyleName(self, style): - # XXX: pyjamas set the style to object string representation! - # FIXME: fix the bug upstream - menu_style = ['gwt-MenuBar'] - menu_style.append(menu_style[0] + '-' + ('vertical' if self.vertical else 'horizontal')) - for classname in style.split(' '): - if classname not in menu_style: - menu_style.append(classname) - UIObject.setStyleName(self, ' '.join(menu_style)) + @param container: a quick_menus.MenuContainer instance + @param caller: instance linked to the menus + """ + for child in container.getActiveMenus(): + if isinstance(child, quick_menus.MenuContainer): + item = self.addCategory(child, flat=bool(flat_level)) + submenu = item.getSubMenu() + if submenu is None: + submenu = self + submenu._buildMenus(child, flat_level-1 if flat_level else 0, caller) + elif isinstance(child, quick_menus.MenuSeparator): + item = MenuItem(text='', asHTML=None, StyleName="menuSeparator") + self.addItem(item) + elif isinstance(child, quick_menus.MenuItem): + self.addItem(child.name, False, MenuCmd(child, caller) if child.CALLABLE else None) + else: + log.error(u"Unknown child type: {}".format(child)) - def addStyleName(self, style): - # XXX: same kind of problem then with setStyleName - # FIXME: fix the bug upstream - if not re.search('(^| )%s( |$)' % style, self.getStyleName()): - UIObject.setStyleName(self, self.getStyleName() + ' ' + style) - - def removeStyleName(self, style): - # XXX: same kind of problem then with setStyleName - # FIXME: fix the bug upstream - style = re.sub('(^| )%s( |$)' % style, ' ', self.getStyleName()).strip() - UIObject.setStyleName(self, style) + def createMenus(self): + self.clearItems() + if self.menus_container is None: + log.debug("Menu is empty") + return + self._buildMenus(self.menus_container, self.flat_level, self._caller) def doItemAction(self, item, fireCommand): """Overwrites the default behavior for the popup menu to fit in the screen""" @@ -294,29 +156,28 @@ if item.getAbsoluteLeft() > max_left: self.popup.setPopupPosition(new_left, top) # eventually smooth the popup edges to fit the menu own style - if 'moved_popup' in self.styles: + try: self.popup.addStyleName(self.styles['moved_popup']) - - def getCategories(self, parent_path=None): - """Return all the categories items. + except KeyError: + pass - @return: list[CategoryItem] + def addCategory(self, category, menu_bar=None, flat=False): + """Add a new category. + + @param menu_container(quick_menus.MenuCategory): Category to add + @param menu_bar (GenericMenuBar): instance to popup as the category sub-menu. """ - return [cat.item for cat in self.node.getCategories(parent_path)] - - def addMenuItem(self, path, path_i18n, types, menu_cmd, asHTML=False): - return self.node.addMenuItem(path, path_i18n, types, menu_cmd, asHTML).item + html = self.getCategoryHTML(category) - def addCategory(self, path, path_i18n, types, menu_bar): - return self.node.addCategory(path, path_i18n, types, menu_bar).item - - def addItem(self, item, asHTML=None, popup=None): - return self.node.addItem(item, asHTML, popup).item + if menu_bar is not None: + assert not flat # can't have a menu_bar and be flat at the same time + sub_menu = menu_bar + elif not flat: + sub_menu = GenericMenuBar(self.host, vertical=True) + else: + sub_menu = None - def addCachedMenus(self, type_, menu_data=None): - self.node.addCachedMenus(type_, menu_data) - - def addSeparator(self): - """Add a separator between the categories""" - item = MenuItem(text='', asHTML=None, StyleName=self.styles['separator']) - return self.addItem(item) + item = self.addItem(html, True, sub_menu) + if flat: + item.setStyleName("menuFlattenedCategory") + return item diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/base_panel.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/base_panel.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,227 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat.core.i18n import _ + +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.ScrollPanel import ScrollPanel +from pyjamas.ui.Button import Button +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.PopupPanel import PopupPanel +from pyjamas.ui.StackPanel import StackPanel +from pyjamas.ui.TextArea import TextArea +from pyjamas.ui.Event import BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT +from pyjamas import DOM + + +### Menus ### + + +class PopupMenuPanel(PopupPanel): + """This implementation of a popup menu (context menu) allow you to assign + two special methods which are common to all the items, in order to hide + certain items and also easily define their callbacks. The menu can be + bound to any of the mouse button (left, middle, right). + """ + def __init__(self, entries, hide=None, callback=None, vertical=True, style=None, **kwargs): + """ + @param entries: a dict of dicts, where each sub-dict is representing + one menu item: the sub-dict key can be used as the item text and + description, but optional "title" and "desc" entries would be used + if they exists. The sub-dicts may be extended later to do + more complicated stuff or overwrite the common methods. + @param hide: function with 2 args: widget, key as string and + returns True if that item should be hidden from the context menu. + @param callback: function with 2 args: sender, key as string + @param vertical: True or False, to set the direction + @param item_style: alternative CSS class for the menu items + @param menu_style: supplementary CSS class for the sender widget + """ + PopupPanel.__init__(self, autoHide=True, **kwargs) + self._entries = entries + self._hide = hide + self._callback = callback + self.vertical = vertical + self.style = {"selected": None, "menu": "itemKeyMenu", "item": "popupMenuItem"} + if isinstance(style, dict): + self.style.update(style) + self._senders = {} + + def _show(self, sender): + """Popup the menu relative to this sender's position. + @param sender: the widget that has been clicked + """ + menu = VerticalPanel() if self.vertical is True else HorizontalPanel() + menu.setStyleName(self.style["menu"]) + + def button_cb(item): + """You can not put that method in the loop and rely + on _key, because it is overwritten by each step. + You can rely on item.key instead, which is copied + from _key after the item creation. + @param item: the menu item that has been clicked + """ + if self._callback is not None: + self._callback(sender=sender, key=item.key) + self.hide(autoClosed=True) + + for _key in self._entries.keys(): + entry = self._entries[_key] + if self._hide is not None and self._hide(sender=sender, key=_key) is True: + continue + title = entry["title"] if "title" in entry.keys() else _key + item = Button(title, button_cb) + item.key = _key + item.setStyleName(self.style["item"]) + item.setTitle(entry["desc"] if "desc" in entry.keys() else title) + menu.add(item) + if len(menu.getChildren()) == 0: + return + self.add(menu) + if self.vertical is True: + x = sender.getAbsoluteLeft() + sender.getOffsetWidth() + y = sender.getAbsoluteTop() + else: + x = sender.getAbsoluteLeft() + y = sender.getAbsoluteTop() + sender.getOffsetHeight() + self.setPopupPosition(x, y) + self.show() + if self.style["selected"]: + sender.addStyleDependentName(self.style["selected"]) + + def _onHide(popup): + if self.style["selected"]: + sender.removeStyleDependentName(self.style["selected"]) + return PopupPanel.onHideImpl(self, popup) + + self.onHideImpl = _onHide + + def registerClickSender(self, sender, button=BUTTON_LEFT): + """Bind the menu to the specified sender. + @param sender: the widget to which the menu should be bound + @param: BUTTON_LEFT, BUTTON_MIDDLE or BUTTON_RIGHT + """ + self._senders.setdefault(sender, []) + self._senders[sender].append(button) + + if button == BUTTON_RIGHT: + # WARNING: to disable the context menu is a bit tricky... + # The following seems to work on Firefox 24.0, but: + # TODO: find a cleaner way to disable the context menu + sender.getElement().setAttribute("oncontextmenu", "return false") + + def _onBrowserEvent(event): + button = DOM.eventGetButton(event) + if DOM.eventGetType(event) == "mousedown" and button in self._senders[sender]: + self._show(sender) + return sender.__class__.onBrowserEvent(sender, event) + + sender.onBrowserEvent = _onBrowserEvent + + def registerMiddleClickSender(self, sender): + self.registerClickSender(sender, BUTTON_MIDDLE) + + def registerRightClickSender(self, sender): + self.registerClickSender(sender, BUTTON_RIGHT) + + +### Generic panels ### + + +class ToggleStackPanel(StackPanel): + """This is a pyjamas.ui.StackPanel with modified behavior. All sub-panels ca be + visible at the same time, clicking a sub-panel header will not display it and hide + the others but only toggle its own visibility. The argument 'visibleStack' is ignored. + Note that the argument 'visible' has been added to listener's 'onStackChanged' method. + """ + + def __init__(self, **kwargs): + StackPanel.__init__(self, **kwargs) + + def onBrowserEvent(self, event): + if DOM.eventGetType(event) == "click": + index = self.getDividerIndex(DOM.eventGetTarget(event)) + if index != -1: + self.toggleStack(index) + + def add(self, widget, stackText="", asHTML=False, visible=False): + StackPanel.add(self, widget, stackText, asHTML) + self.setStackVisible(self.getWidgetCount() - 1, visible) + + def toggleStack(self, index): + if index >= self.getWidgetCount(): + return + visible = not self.getWidget(index).getVisible() + self.setStackVisible(index, visible) + for listener in self.stackListeners: + listener.onStackChanged(self, index, visible) + + +class TitlePanel(ToggleStackPanel): + """A toggle panel to set the message title""" + def __init__(self): + ToggleStackPanel.__init__(self, Width="100%") + self.text_area = TextArea() + self.add(self.text_area, _("Title")) + self.addStackChangeListener(self) + + def onStackChanged(self, sender, index, visible=None): + if visible is None: + visible = sender.getWidget(index).getVisible() + text = self.text_area.getText() + suffix = "" if (visible or not text) else (": %s" % text) + sender.setStackText(index, _("Title") + suffix) + + def getText(self): + return self.text_area.getText() + + def setText(self, text): + self.text_area.setText(text) + + +class ScrollPanelWrapper(SimplePanel): + """Scroll Panel like component, wich use the full available space + to work around percent size issue, it use some of the ideas found + here: http://code.google.com/p/google-web-toolkit/issues/detail?id=316 + specially in code given at comment #46, thanks to Stefan Bachert""" + + def __init__(self, *args, **kwargs): + SimplePanel.__init__(self) + self.spanel = ScrollPanel(*args, **kwargs) + SimplePanel.setWidget(self, self.spanel) + DOM.setStyleAttribute(self.getElement(), "position", "relative") + DOM.setStyleAttribute(self.getElement(), "top", "0px") + DOM.setStyleAttribute(self.getElement(), "left", "0px") + DOM.setStyleAttribute(self.getElement(), "width", "100%") + DOM.setStyleAttribute(self.getElement(), "height", "100%") + DOM.setStyleAttribute(self.spanel.getElement(), "position", "absolute") + DOM.setStyleAttribute(self.spanel.getElement(), "width", "100%") + DOM.setStyleAttribute(self.spanel.getElement(), "height", "100%") + + def setWidget(self, widget): + self.spanel.setWidget(widget) + + def setScrollPosition(self, position): + self.spanel.setScrollPosition(position) + + def scrollToBottom(self): + self.setScrollPosition(self.spanel.getElement().scrollHeight) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/base_panels.py --- a/src/browser/sat_browser/base_panels.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,621 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pyjd # this is dummy in pyjs -from sat.core.log import getLogger -log = getLogger(__name__) -from sat.core.i18n import _ -from sat_frontends.tools import strings - -from pyjamas.ui.AbsolutePanel import AbsolutePanel -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.HTMLPanel import HTMLPanel -from pyjamas.ui.Button import Button -from pyjamas.ui.HTML import HTML -from pyjamas.ui.SimplePanel import SimplePanel -from pyjamas.ui.PopupPanel import PopupPanel -from pyjamas.ui.StackPanel import StackPanel -from pyjamas.ui.TextArea import TextArea -from pyjamas.ui.Event import BUTTON_LEFT, BUTTON_MIDDLE, BUTTON_RIGHT -from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_SHIFT, KeyboardHandler -from pyjamas.ui.FocusListener import FocusHandler -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas import DOM - -from datetime import datetime -from time import time - -import html_tools -from constants import Const as C - - -class ChatText(HTMLPanel): - - def __init__(self, timestamp, nick, mymess, msg, xhtml=None): - _date = datetime.fromtimestamp(float(timestamp or time())) - _msg_class = ["chat_text_msg"] - if mymess: - _msg_class.append("chat_text_mymess") - HTMLPanel.__init__(self, "%(timestamp)s %(nick)s %(msg)s" % - {"timestamp": _date.strftime("%H:%M"), - "nick": "[%s]" % html_tools.html_sanitize(nick), - "msg_class": ' '.join(_msg_class), - "msg": strings.addURLToText(html_tools.html_sanitize(msg)) if not xhtml else html_tools.inlineRoot(xhtml)} # FIXME: images and external links must be removed according to preferences - ) - self.setStyleName('chatText') - - -class Occupant(HTML): - """Occupant of a MUC room""" - - def __init__(self, nick, state=None, special=""): - """ - @param nick: the user nickname - @param state: the user chate state (XEP-0085) - @param special: a string of symbols (e.g: for activities) - """ - HTML.__init__(self, StyleName="occupant") - self.nick = nick - self._state = state - self.special = special - self._refresh() - - def __str__(self): - return self.nick - - def setState(self, state): - self._state = state - self._refresh() - - def addSpecial(self, special): - """@param special: unicode""" - if special not in self.special: - self.special += special - self._refresh() - - def removeSpecials(self, special): - """@param special: unicode or list""" - if not isinstance(special, list): - special = [special] - for symbol in special: - self.special = self.special.replace(symbol, "") - self._refresh() - - def _refresh(self): - state = (' %s' % C.MUC_USER_STATES[self._state]) if self._state else '' - special = "" if len(self.special) == 0 else " %s" % self.special - self.setHTML("%s%s%s" % (html_tools.html_sanitize(self.nick), special, state)) - - -class OccupantsList(AbsolutePanel): - """Panel user to show occupants of a room""" - - def __init__(self): - AbsolutePanel.__init__(self) - self.occupants_list = {} - self.setStyleName('occupantsList') - - def addOccupant(self, nick): - if nick in self.occupants_list: - return - _occupant = Occupant(nick) - self.occupants_list[nick] = _occupant - self.add(_occupant) - - def removeOccupant(self, nick): - try: - self.remove(self.occupants_list[nick]) - except KeyError: - log.error("trying to remove an unexisting nick") - - def getOccupantBox(self, nick): - """Get the widget element of the given nick. - - @return: Occupant - """ - try: - return self.occupants_list[nick] - except KeyError: - return None - - def clear(self): - self.occupants_list.clear() - AbsolutePanel.clear(self) - - def updateSpecials(self, occupants=[], html=""): - """Set the specified html "symbol" to the listed occupants, - and eventually remove it from the others (if they got it). - This is used for example to visualize who is playing a game. - @param occupants: list of the occupants that need the symbol - @param html: unicode symbol (actually one character or more) - or a list to assign different symbols of the same family. - """ - index = 0 - special = html - for occupant in self.occupants_list.keys(): - if occupant in occupants: - if isinstance(html, list): - special = html[index] - index = (index + 1) % len(html) - self.occupants_list[occupant].addSpecial(special) - else: - self.occupants_list[occupant].removeSpecials(html) - - -class PopupMenuPanel(PopupPanel): - """This implementation of a popup menu (context menu) allow you to assign - two special methods which are common to all the items, in order to hide - certain items and also easily define their callbacks. The menu can be - bound to any of the mouse button (left, middle, right). - """ - def __init__(self, entries, hide=None, callback=None, vertical=True, style=None, **kwargs): - """ - @param entries: a dict of dicts, where each sub-dict is representing - one menu item: the sub-dict key can be used as the item text and - description, but optional "title" and "desc" entries would be used - if they exists. The sub-dicts may be extended later to do - more complicated stuff or overwrite the common methods. - @param hide: function with 2 args: widget, key as string and - returns True if that item should be hidden from the context menu. - @param callback: function with 2 args: sender, key as string - @param vertical: True or False, to set the direction - @param item_style: alternative CSS class for the menu items - @param menu_style: supplementary CSS class for the sender widget - """ - PopupPanel.__init__(self, autoHide=True, **kwargs) - self._entries = entries - self._hide = hide - self._callback = callback - self.vertical = vertical - self.style = {"selected": None, "menu": "recipientTypeMenu", "item": "popupMenuItem"} - if isinstance(style, dict): - self.style.update(style) - self._senders = {} - - def _show(self, sender): - """Popup the menu relative to this sender's position. - @param sender: the widget that has been clicked - """ - menu = VerticalPanel() if self.vertical is True else HorizontalPanel() - menu.setStyleName(self.style["menu"]) - - def button_cb(item): - """You can not put that method in the loop and rely - on _key, because it is overwritten by each step. - You can rely on item.key instead, which is copied - from _key after the item creation. - @param item: the menu item that has been clicked - """ - if self._callback is not None: - self._callback(sender=sender, key=item.key) - self.hide(autoClosed=True) - - for _key in self._entries.keys(): - entry = self._entries[_key] - if self._hide is not None and self._hide(sender=sender, key=_key) is True: - continue - title = entry["title"] if "title" in entry.keys() else _key - item = Button(title, button_cb) - item.key = _key - item.setStyleName(self.style["item"]) - item.setTitle(entry["desc"] if "desc" in entry.keys() else title) - menu.add(item) - if len(menu.getChildren()) == 0: - return - self.add(menu) - if self.vertical is True: - x = sender.getAbsoluteLeft() + sender.getOffsetWidth() - y = sender.getAbsoluteTop() - else: - x = sender.getAbsoluteLeft() - y = sender.getAbsoluteTop() + sender.getOffsetHeight() - self.setPopupPosition(x, y) - self.show() - if self.style["selected"]: - sender.addStyleDependentName(self.style["selected"]) - - def _onHide(popup): - if self.style["selected"]: - sender.removeStyleDependentName(self.style["selected"]) - return PopupPanel.onHideImpl(self, popup) - - self.onHideImpl = _onHide - - def registerClickSender(self, sender, button=BUTTON_LEFT): - """Bind the menu to the specified sender. - @param sender: the widget to which the menu should be bound - @param: BUTTON_LEFT, BUTTON_MIDDLE or BUTTON_RIGHT - """ - self._senders.setdefault(sender, []) - self._senders[sender].append(button) - - if button == BUTTON_RIGHT: - # WARNING: to disable the context menu is a bit tricky... - # The following seems to work on Firefox 24.0, but: - # TODO: find a cleaner way to disable the context menu - sender.getElement().setAttribute("oncontextmenu", "return false") - - def _onBrowserEvent(event): - button = DOM.eventGetButton(event) - if DOM.eventGetType(event) == "mousedown" and button in self._senders[sender]: - self._show(sender) - return sender.__class__.onBrowserEvent(sender, event) - - sender.onBrowserEvent = _onBrowserEvent - - def registerMiddleClickSender(self, sender): - self.registerClickSender(sender, BUTTON_MIDDLE) - - def registerRightClickSender(self, sender): - self.registerClickSender(sender, BUTTON_RIGHT) - - -class ToggleStackPanel(StackPanel): - """This is a pyjamas.ui.StackPanel with modified behavior. All sub-panels ca be - visible at the same time, clicking a sub-panel header will not display it and hide - the others but only toggle its own visibility. The argument 'visibleStack' is ignored. - Note that the argument 'visible' has been added to listener's 'onStackChanged' method. - """ - - def __init__(self, **kwargs): - StackPanel.__init__(self, **kwargs) - - def onBrowserEvent(self, event): - if DOM.eventGetType(event) == "click": - index = self.getDividerIndex(DOM.eventGetTarget(event)) - if index != -1: - self.toggleStack(index) - - def add(self, widget, stackText="", asHTML=False, visible=False): - StackPanel.add(self, widget, stackText, asHTML) - self.setStackVisible(self.getWidgetCount() - 1, visible) - - def toggleStack(self, index): - if index >= self.getWidgetCount(): - return - visible = not self.getWidget(index).getVisible() - self.setStackVisible(index, visible) - for listener in self.stackListeners: - listener.onStackChanged(self, index, visible) - - -class TitlePanel(ToggleStackPanel): - """A toggle panel to set the message title""" - def __init__(self): - ToggleStackPanel.__init__(self, Width="100%") - self.text_area = TextArea() - self.add(self.text_area, _("Title")) - self.addStackChangeListener(self) - - def onStackChanged(self, sender, index, visible=None): - if visible is None: - visible = sender.getWidget(index).getVisible() - text = self.text_area.getText() - suffix = "" if (visible or not text) else (": %s" % text) - sender.setStackText(index, _("Title") + suffix) - - def getText(self): - return self.text_area.getText() - - def setText(self, text): - self.text_area.setText(text) - - -class BaseTextEditor(object): - """Basic definition of a text editor. The method edit gets a boolean parameter which - should be set to True when you want to edit the text and False to only display it.""" - - def __init__(self, content=None, strproc=None, modifiedCb=None, afterEditCb=None): - """ - Remark when inheriting this class: since the setContent method could be - overwritten by the child class, you should consider calling this __init__ - after all the parameters affecting this setContent method have been set. - @param content: dict with at least a 'text' key - @param strproc: method to be applied on strings to clean the content - @param modifiedCb: method to be called when the text has been modified. - If this method returns: - - True: the modification will be saved and afterEditCb called; - - False: the modification won't be saved and afterEditCb called; - - None: the modification won't be saved and afterEditCb not called. - @param afterEditCb: method to be called when the edition is done - """ - if content is None: - content = {'text': ''} - assert('text' in content) - if strproc is None: - def strproc(text): - try: - return text.strip() - except (TypeError, AttributeError): - return text - self.strproc = strproc - self.__modifiedCb = modifiedCb - self._afterEditCb = afterEditCb - self.initialized = False - self.edit_listeners = [] - self.setContent(content) - - def setContent(self, content=None): - """Set the editable content. The displayed content, which is set from the child class, could differ. - @param content: dict with at least a 'text' key - """ - if content is None: - content = {'text': ''} - elif not isinstance(content, dict): - content = {'text': content} - assert('text' in content) - self._original_content = {} - for key in content: - self._original_content[key] = self.strproc(content[key]) - - def getContent(self): - """Get the current edited or editable content. - @return: dict with at least a 'text' key - """ - raise NotImplementedError - - def setOriginalContent(self, content): - """Use this method with care! Content initialization should normally be - done with self.setContent. This method exists to let you trick the editor, - e.g. for self.modified to return True also when nothing has been modified. - @param content: dict - """ - self._original_content = content - - def getOriginalContent(self): - """ - @return the original content before modification (dict) - """ - return self._original_content - - def modified(self, content=None): - """Check if the content has been modified. - Remark: we don't use the direct comparison because we want to ignore empty elements - @content: content to be check against the original content or None to use the current content - @return: True if the content has been modified. - """ - if content is None: - content = self.getContent() - # the following method returns True if one non empty element exists in a but not in b - diff1 = lambda a, b: [a[key] for key in set(a.keys()).difference(b.keys()) if a[key]] != [] - # the following method returns True if the values for the common keys are not equals - diff2 = lambda a, b: [1 for key in set(a.keys()).intersection(b.keys()) if a[key] != b[key]] != [] - # finally the combination of both to return True if a difference is found - diff = lambda a, b: diff1(a, b) or diff1(b, a) or diff2(a, b) - - return diff(content, self._original_content) - - def edit(self, edit, abort=False, sync=False): - """ - Remark: the editor must be visible before you call this method. - @param edit: set to True to edit the content or False to only display it - @param abort: set to True to cancel the edition and loose the changes. - If edit and abort are both True, self.abortEdition can be used to ask for a - confirmation. When edit is False and abort is True, abortion is actually done. - @param sync: set to True to cancel the edition after the content has been saved somewhere else - """ - if edit: - if not self.initialized: - self.syncToEditor() # e.g.: use the selected target and unibox content - self.setFocus(True) - if abort: - content = self.getContent() - if not self.modified(content) or self.abortEdition(content): # e.g: ask for confirmation - self.edit(False, True, sync) - return - if sync: - self.syncFromEditor(content) # e.g.: save the content to unibox - return - else: - if not self.initialized: - return - content = self.getContent() - if abort: - self._afterEditCb(content) - return - if self.__modifiedCb and self.modified(content): - result = self.__modifiedCb(content) # e.g.: send a message or update something - if result is not None: - if self._afterEditCb: - self._afterEditCb(content) # e.g.: restore the display mode - if result is True: - self.setContent(content) - elif self._afterEditCb: - self._afterEditCb(content) - - self.initialized = True - - def setFocus(self, focus): - """ - @param focus: set to True to focus the editor - """ - raise NotImplementedError - - def syncToEditor(self): - pass - - def syncFromEditor(self, content): - pass - - def abortEdition(self, content): - return True - - def addEditListener(self, listener): - """Add a method to be called whenever the text is edited. - @param listener: method taking two arguments: sender, keycode""" - self.edit_listeners.append(listener) - - -class SimpleTextEditor(BaseTextEditor, FocusHandler, KeyboardHandler, ClickHandler): - """Base class for manage a simple text editor.""" - - def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): - """ - @param content - @param modifiedCb - @param afterEditCb - @param options: dict with the following value: - - no_xhtml: set to True to clean any xhtml content. - - enhance_display: if True, the display text will be enhanced with strings.addURLToText - - listen_keyboard: set to True to terminate the edition with or . - - listen_focus: set to True to terminate the edition when the focus is lost. - - listen_click: set to True to start the edition when you click on the widget. - """ - self.options = {'no_xhtml': False, - 'enhance_display': True, - 'listen_keyboard': True, - 'listen_focus': False, - 'listen_click': False - } - if options: - self.options.update(options) - self.__shift_down = False - if self.options['listen_focus']: - FocusHandler.__init__(self) - if self.options['listen_click']: - ClickHandler.__init__(self) - KeyboardHandler.__init__(self) - strproc = lambda text: html_tools.html_sanitize(html_tools.html_strip(text)) if self.options['no_xhtml'] else html_tools.html_strip(text) - BaseTextEditor.__init__(self, content, strproc, modifiedCb, afterEditCb) - self.textarea = self.display = None - - def setContent(self, content=None): - BaseTextEditor.setContent(self, content) - - def getContent(self): - raise NotImplementedError - - def edit(self, edit, abort=False, sync=False): - BaseTextEditor.edit(self, edit) - if edit: - if self.options['listen_focus'] and self not in self.textarea._focusListeners: - self.textarea.addFocusListener(self) - if self.options['listen_click']: - self.display.clearClickListener() - if self not in self.textarea._keyboardListeners: - self.textarea.addKeyboardListener(self) - else: - self.setDisplayContent() - if self.options['listen_focus']: - try: - self.textarea.removeFocusListener(self) - except ValueError: - pass - if self.options['listen_click'] and self not in self.display._clickListeners: - self.display.addClickListener(self) - try: - self.textarea.removeKeyboardListener(self) - except ValueError: - pass - - def setDisplayContent(self): - text = self._original_content['text'] - if not self.options['no_xhtml']: - text = strings.addURLToImage(text) - if self.options['enhance_display']: - text = strings.addURLToText(text) - self.display.setHTML(html_tools.convertNewLinesToXHTML(text)) - - def setFocus(self, focus): - raise NotImplementedError - - def onKeyDown(self, sender, keycode, modifiers): - for listener in self.edit_listeners: - listener(self.textarea, keycode) - if not self.options['listen_keyboard']: - return - if keycode == KEY_SHIFT or self.__shift_down: # allow input a new line with + - self.__shift_down = True - return - if keycode == KEY_ENTER: # finish the edition - self.textarea.setFocus(False) - if not self.options['listen_focus']: - self.edit(False) - - def onKeyUp(self, sender, keycode, modifiers): - if keycode == KEY_SHIFT: - self.__shift_down = False - - def onLostFocus(self, sender): - """Finish the edition when focus is lost""" - if self.options['listen_focus']: - self.edit(False) - - def onClick(self, sender=None): - """Start the edition when the widget is clicked""" - if self.options['listen_click']: - self.edit(True) - - def onBrowserEvent(self, event): - if self.options['listen_focus']: - FocusHandler.onBrowserEvent(self, event) - if self.options['listen_click']: - ClickHandler.onBrowserEvent(self, event) - KeyboardHandler.onBrowserEvent(self, event) - - -class HTMLTextEditor(SimpleTextEditor, HTML, FocusHandler, KeyboardHandler): - """Manage a simple text editor with the HTML 5 "contenteditable" property.""" - - def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): - HTML.__init__(self) - SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options) - self.textarea = self.display = self - - def getContent(self): - text = DOM.getInnerHTML(self.getElement()) - return {'text': self.strproc(text) if text else ''} - - def edit(self, edit, abort=False, sync=False): - if edit: - self.textarea.setHTML(self._original_content['text']) - self.getElement().setAttribute('contenteditable', 'true' if edit else 'false') - SimpleTextEditor.edit(self, edit, abort, sync) - - def setFocus(self, focus): - if focus: - self.getElement().focus() - else: - self.getElement().blur() - - -class LightTextEditor(SimpleTextEditor, SimplePanel, FocusHandler, KeyboardHandler): - """Manage a simple text editor with a TextArea for editing, HTML for display.""" - - def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): - SimplePanel.__init__(self) - SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options) - self.textarea = TextArea() - self.display = HTML() - - def getContent(self): - text = self.textarea.getText() - return {'text': self.strproc(text) if text else ''} - - def edit(self, edit, abort=False, sync=False): - if edit: - self.textarea.setText(self._original_content['text']) - self.setWidget(self.textarea if edit else self.display) - SimpleTextEditor.edit(self, edit, abort, sync) - - def setFocus(self, focus): - if focus and self.isAttached(): - self.textarea.setCursorPos(len(self.textarea.getText())) - self.textarea.setFocus(focus) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/base_widget.py --- a/src/browser/sat_browser/base_widget.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/base_widget.py Wed Mar 18 16:15:18 2015 +0100 @@ -17,154 +17,21 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import pyjd # this is dummy in pyjs from sat.core.log import getLogger log = getLogger(__name__) -from pyjamas.ui.SimplePanel import SimplePanel -from pyjamas.ui.AbsolutePanel import AbsolutePanel -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.ScrollPanel import ScrollPanel -from pyjamas.ui.FlexTable import FlexTable -from pyjamas.ui.TabPanel import TabPanel -from pyjamas.ui.HTMLPanel import HTMLPanel -from pyjamas.ui.Label import Label -from pyjamas.ui.HTML import HTML -from pyjamas.ui.Button import Button -from pyjamas.ui.Widget import Widget -from pyjamas.ui.DragWidget import DragWidget -from pyjamas.ui.DropWidget import DropWidget -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas.ui import HasAlignment -from pyjamas import DOM -from pyjamas import Window - -from __pyjamas__ import doc - -import dialog import base_menu - - -class DragLabel(DragWidget): - - def __init__(self, text, _type): - DragWidget.__init__(self) - self._text = text - self._type = _type - - def onDragStart(self, event): - dt = event.dataTransfer - dt.setData('text/plain', "%s\n%s" % (self._text, self._type)) - dt.setDragImage(self.getElement(), 15, 15) - - -class LiberviaDragWidget(DragLabel): - """ A DragLabel which keep the widget being dragged as class value """ - current = None # widget currently dragged - - def __init__(self, text, _type, widget): - DragLabel.__init__(self, text, _type) - self.widget = widget - - def onDragStart(self, event): - LiberviaDragWidget.current = self.widget - DragLabel.onDragStart(self, event) - - def onDragEnd(self, event): - LiberviaDragWidget.current = None +from sat_frontends.quick_frontend import quick_menus -class DropCell(DropWidget): - """Cell in the middle grid which replace itself with the dropped widget on DnD""" - drop_keys = {} - - def __init__(self, host): - DropWidget.__init__(self) - self.host = host - self.setStyleName('dropCell') - - @classmethod - def addDropKey(cls, key, callback): - DropCell.drop_keys[key] = callback +### Exceptions ### - def onDragEnter(self, event): - if self == LiberviaDragWidget.current: - return - self.addStyleName('dragover') - DOM.eventPreventDefault(event) - - def onDragLeave(self, event): - if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop() or\ - event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1 or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1: - # We check that we are inside widget's box, and we don't remove the style in this case because - # if the mouse is over a widget inside the DropWidget, if will leave the DropWidget, and we - # don't want that - self.removeStyleName('dragover') - - def onDragOver(self, event): - DOM.eventPreventDefault(event) - - def _getCellAndRow(self, grid, event): - """Return cell and row index where the event is occuring""" - cell = grid.getEventTargetCell(event) - row = DOM.getParent(cell) - return (row.rowIndex, cell.cellIndex) - def onDrop(self, event): - self.removeStyleName('dragover') - DOM.eventPreventDefault(event) - dt = event.dataTransfer - # 'text', 'text/plain', and 'Text' are equivalent. - try: - item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed - if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and - item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report - # item_type = dt.getData("type") - log.debug("message: %s" % item) - log.debug("type: %s" % item_type) - except: - log.debug("no message found") - item = ' ' - item_type = None - if item_type == "WIDGET": - if not LiberviaDragWidget.current: - log.error("No widget registered in LiberviaDragWidget !") - return - _new_panel = LiberviaDragWidget.current - if self == _new_panel: # We can't drop on ourself - return - # we need to remove the widget from the panel as it will be inserted elsewhere - widgets_panel = _new_panel.getWidgetsPanel() - wid_row = widgets_panel.getWidgetCoords(_new_panel)[0] - row_wids = widgets_panel.getLiberviaRowWidgets(wid_row) - if len(row_wids) == 1 and wid_row == widgets_panel.getWidgetCoords(self)[0]: - # the dropped widget is the only one in the same row - # as the target widget (self), we don't do anything - return - widgets_panel.removeWidget(_new_panel) - elif item_type in self.drop_keys: - _new_panel = self.drop_keys[item_type](self.host, item) - else: - log.warning("unmanaged item type") - return - if isinstance(self, LiberviaWidget): - self.host.unregisterWidget(self) - self.onQuit() - if not isinstance(_new_panel, LiberviaWidget): - log.warning("droping an object which is not a class of LiberviaWidget") - _flextable = self.getParent() - _widgetspanel = _flextable.getParent().getParent() - row_idx, cell_idx = self._getCellAndRow(_flextable, event) - if self.host.getSelected == self: - self.host.setSelected(None) - _widgetspanel.changeWidget(row_idx, cell_idx, _new_panel) - """_unempty_panels = filter(lambda wid:not isinstance(wid,EmptyWidget),list(_flextable)) - _width = 90/float(len(_unempty_panels) or 1) - #now we resize all the cell of the column - for panel in _unempty_panels: - td_elt = panel.getElement().parentNode - DOM.setStyleAttribute(td_elt, "width", "%s%%" % _width)""" - #FIXME: delete object ? Check the right way with pyjamas +class NoLiberviaWidgetException(Exception): + """A Libervia widget was expected""" + pass + + +### Menus ### class WidgetMenuBar(base_menu.GenericMenuBar): @@ -172,629 +39,28 @@ ITEM_TPL = "" def __init__(self, parent, host, vertical=False, styles=None): + """ + + @param parent (Widget): LiberviaWidget, or instance of another class + implementing the method addMenus + @param host (SatWebFrontend) + @param vertical (bool): if True, set the menu vertically + @param styles (dict): optional styles dict + """ menu_styles = {'menu_bar': 'widgetHeader_buttonGroup'} if styles: menu_styles.update(styles) base_menu.GenericMenuBar.__init__(self, host, vertical=vertical, styles=menu_styles) - if hasattr(parent, 'addMenus'): - # regroup all the dynamic menu categories in a sub-menu - sub_menu = WidgetSubMenuBar(host, vertical=True) - parent.addMenus(sub_menu) - if len(sub_menu.getCategories()) > 0: - self.addCategory('', '', 'plugins', sub_menu) - - @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): - return cls.ITEM_TPL % type_ - - -class WidgetSubMenuBar(base_menu.GenericMenuBar): - - def __init__(self, host, vertical=True): - base_menu.GenericMenuBar.__init__(self, host, vertical=vertical, flat_level=1) + # regroup all the dynamic menu categories in a sub-menu + for menu_context in parent.plugin_menu_context: + main_cont = host.menus.getMainContainer(menu_context) + if len(main_cont)>0: # we don't add the icon if the menu is empty + sub_menu = base_menu.GenericMenuBar(host, vertical=True, flat_level=1) + sub_menu.update(menu_context, parent) + menu_category = quick_menus.MenuCategory("plugins", extra={'icon':'plugins'}) + self.addCategory(menu_category, sub_menu) @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): - return menu_name_i18n - - -class WidgetHeader(AbsolutePanel, LiberviaDragWidget): - - def __init__(self, parent, host, title, info=None): - """ - @param parent (LiberviaWidget): LiberWidget instance - @param host (SatWebFrontend): SatWebFrontend instance - @param title (Label, HTML): text widget instance - @param info (Widget): text widget instance - """ - AbsolutePanel.__init__(self) - self.add(title) - if info: - # FIXME: temporary design to display the info near the menu - button_group_wrapper = HorizontalPanel() - button_group_wrapper.add(info) - else: - button_group_wrapper = SimplePanel() - button_group_wrapper.setStyleName('widgetHeader_buttonsWrapper') - button_group = WidgetMenuBar(parent, host) - button_group.addItem('', True, base_menu.MenuCmd(parent, 'onSetting')) - button_group.addItem('', True, base_menu.MenuCmd(parent, 'onClose')) - button_group_wrapper.add(button_group) - self.add(button_group_wrapper) - self.addStyleName('widgetHeader') - LiberviaDragWidget.__init__(self, "", "WIDGET", parent) - - -class LiberviaWidget(DropCell, VerticalPanel, ClickHandler): - """Libervia's widget which can replace itself with a dropped widget on DnD""" - - def __init__(self, host, title='', info=None, selectable=False): - """Init the widget - @param host (SatWebFrontend): SatWebFrontend instance - @param title (str): title shown in the header of the widget - @param info (str, callable): info shown in the header of the widget - @param selectable (bool): True is widget can be selected by user""" - VerticalPanel.__init__(self) - DropCell.__init__(self, host) - ClickHandler.__init__(self) - self.__selectable = selectable - self.__title_id = HTMLPanel.createUniqueId() - self.__setting_button_id = HTMLPanel.createUniqueId() - self.__close_button_id = HTMLPanel.createUniqueId() - self.__title = Label(title) - self.__title.setStyleName('widgetHeader_title') - if info is not None: - if isinstance(info, str): - self.__info = HTML(info) - else: # the info will be set by a callback - assert(callable(info)) - self.__info = HTML() - info(self.__info.setHTML) - self.__info.setStyleName('widgetHeader_info') - else: - self.__info = None - self._close_listeners = [] - header = WidgetHeader(self, host, self.__title, self.__info) - self.add(header) - self.setSize('100%', '100%') - self.addStyleName('widget') - if self.__selectable: - self.addClickListener(self) - - def onClose(sender): - """Check dynamically if the unibox is enable or not""" - if self.host.uni_box: - self.host.uni_box.onWidgetClosed(sender) - - self.addCloseListener(onClose) - self.host.registerWidget(self) - - def getDebugName(self): - return "%s (%s)" % (self, self.__title.getText()) - - def getWidgetsPanel(self, expect=True): - return self.getParent(WidgetsPanel, expect) - - def getParent(self, class_=None, expect=True): - """Return the closest ancestor of the specified class. - - Note: this method overrides pyjamas.ui.Widget.getParent - - @param class_: class of the ancestor to look for or None to return the first parent - @param expect: set to True if the parent is expected (print a message if not found) - @return: the parent/ancestor or None if it has not been found - """ - current = Widget.getParent(self) - if class_ is None: - return current # this is the default behavior - while current is not None and not isinstance(current, class_): - current = Widget.getParent(current) - if current is None and expect: - log.error("Can't find parent %s for %s" % (class_, self)) - return current - - def onClick(self, sender): - self.host.setSelected(self) - - def onClose(self, sender): - """ Called when the close button is pushed """ - _widgetspanel = self.getWidgetsPanel() - _widgetspanel.removeWidget(self) - for callback in self._close_listeners: - callback(self) - self.onQuit() - - def onQuit(self): - """ Called when the widget is actually ending """ - pass - - def addCloseListener(self, callback): - """Add a close listener to this widget - @param callback: function to be called from self.onClose""" - self._close_listeners.append(callback) - - def refresh(self): - """This can be overwritten by a child class to refresh the display when, - instead of creating a new one, an existing widget is found and reused. - """ - pass - - def onSetting(self, sender): - widpanel = self.getWidgetsPanel() - row, col = widpanel.getIndex(self) - body = VerticalPanel() - - # colspan & rowspan - colspan = widpanel.getColSpan(row, col) - rowspan = widpanel.getRowSpan(row, col) - - def onColSpanChange(value): - widpanel.setColSpan(row, col, value) - - def onRowSpanChange(value): - widpanel.setRowSpan(row, col, value) - colspan_setter = dialog.IntSetter("Columns span", colspan) - colspan_setter.addValueChangeListener(onColSpanChange) - colspan_setter.setWidth('100%') - rowspan_setter = dialog.IntSetter("Rows span", rowspan) - rowspan_setter.addValueChangeListener(onRowSpanChange) - rowspan_setter.setWidth('100%') - body.add(colspan_setter) - body.add(rowspan_setter) - - # size - width_str = self.getWidth() - if width_str.endswith('px'): - width = int(width_str[:-2]) - else: - width = 0 - height_str = self.getHeight() - if height_str.endswith('px'): - height = int(height_str[:-2]) - else: - height = 0 - - def onWidthChange(value): - if not value: - self.setWidth('100%') - else: - self.setWidth('%dpx' % value) - - def onHeightChange(value): - if not value: - self.setHeight('100%') - else: - self.setHeight('%dpx' % value) - width_setter = dialog.IntSetter("width (0=auto)", width) - width_setter.addValueChangeListener(onWidthChange) - width_setter.setWidth('100%') - height_setter = dialog.IntSetter("height (0=auto)", height) - height_setter.addValueChangeListener(onHeightChange) - height_setter.setHeight('100%') - body.add(width_setter) - body.add(height_setter) - - # reset - def onReset(sender): - colspan_setter.setValue(1) - rowspan_setter.setValue(1) - width_setter.setValue(0) - height_setter.setValue(0) - - reset_bt = Button("Reset", onReset) - body.add(reset_bt) - body.setCellHorizontalAlignment(reset_bt, HasAlignment.ALIGN_CENTER) - - _dialog = dialog.GenericDialog("Widget setting", body) - _dialog.show() - - def setTitle(self, text): - """change the title in the header of the widget - @param text: text of the new title""" - self.__title.setText(text) - - def setHeaderInfo(self, text): - """change the info in the header of the widget - @param text: text of the new title""" - try: - self.__info.setHTML(text) - except TypeError: - log.error("LiberviaWidget.setInfo: info widget has not been initialized!") - - def isSelectable(self): - return self.__selectable - - def setSelectable(self, selectable): - if not self.__selectable: - try: - self.removeClickListener(self) - except ValueError: - pass - if self.selectable and not self in self._clickListeners: - self.addClickListener(self) - self.__selectable = selectable - - def getWarningData(self): - """ Return exposition warning level when this widget is selected and something is sent to it - This method should be overriden by children - @return: tuple (warning level type/HTML msg). Type can be one of: - - PUBLIC - - GROUP - - ONE2ONE - - MISC - - NONE - """ - if not self.__selectable: - log.error("getWarningLevel must not be called for an unselectable widget") - raise Exception - # TODO: cleaner warning types (more general constants) - return ("NONE", None) - - def setWidget(self, widget, scrollable=True): - """Set the widget that will be in the body of the LiberviaWidget - @param widget: widget to put in the body - @param scrollable: if true, the widget will be in a ScrollPanelWrapper""" - if scrollable: - _scrollpanelwrapper = ScrollPanelWrapper() - _scrollpanelwrapper.setStyleName('widgetBody') - _scrollpanelwrapper.setWidget(widget) - body_wid = _scrollpanelwrapper - else: - body_wid = widget - self.add(body_wid) - self.setCellHeight(body_wid, '100%') - - def doDetachChildren(self): - # We need to force the use of a panel subclass method here, - # for the same reason as doAttachChildren - VerticalPanel.doDetachChildren(self) - - def doAttachChildren(self): - # We need to force the use of a panel subclass method here, else - # the event will not propagate to children - VerticalPanel.doAttachChildren(self) - - def matchEntity(self, item): - """Check if this widget corresponds to the given entity. - - This method should be overwritten by child classes. - @return: True if the widget matches the entity""" - raise NotImplementedError - - def addMenus(self, menu_bar): - """Add menus to the header. - - This method can be overwritten by child classes. - @param menu_bar (GenericMenuBar): menu bar of the widget's header - """ - pass - - -class ScrollPanelWrapper(SimplePanel): - """Scroll Panel like component, wich use the full available space - to work around percent size issue, it use some of the ideas found - here: http://code.google.com/p/google-web-toolkit/issues/detail?id=316 - specially in code given at comment #46, thanks to Stefan Bachert""" - - def __init__(self, *args, **kwargs): - SimplePanel.__init__(self) - self.spanel = ScrollPanel(*args, **kwargs) - SimplePanel.setWidget(self, self.spanel) - DOM.setStyleAttribute(self.getElement(), "position", "relative") - DOM.setStyleAttribute(self.getElement(), "top", "0px") - DOM.setStyleAttribute(self.getElement(), "left", "0px") - DOM.setStyleAttribute(self.getElement(), "width", "100%") - DOM.setStyleAttribute(self.getElement(), "height", "100%") - DOM.setStyleAttribute(self.spanel.getElement(), "position", "absolute") - DOM.setStyleAttribute(self.spanel.getElement(), "width", "100%") - DOM.setStyleAttribute(self.spanel.getElement(), "height", "100%") - - def setWidget(self, widget): - self.spanel.setWidget(widget) - - def setScrollPosition(self, position): - self.spanel.setScrollPosition(position) - - def scrollToBottom(self): - self.setScrollPosition(self.spanel.getElement().scrollHeight) - - -class EmptyWidget(DropCell, SimplePanel): - """Empty dropable panel""" - - def __init__(self, host): - SimplePanel.__init__(self) - DropCell.__init__(self, host) - #self.setWidget(HTML('')) - self.setSize('100%', '100%') - - -class BorderWidget(EmptyWidget): - def __init__(self, host): - EmptyWidget.__init__(self, host) - self.addStyleName('borderPanel') - - -class LeftBorderWidget(BorderWidget): - def __init__(self, host): - BorderWidget.__init__(self, host) - self.addStyleName('leftBorderWidget') - - -class RightBorderWidget(BorderWidget): - def __init__(self, host): - BorderWidget.__init__(self, host) - self.addStyleName('rightBorderWidget') - - -class BottomBorderWidget(BorderWidget): - def __init__(self, host): - BorderWidget.__init__(self, host) - self.addStyleName('bottomBorderWidget') - - -class WidgetsPanel(ScrollPanelWrapper): - - def __init__(self, host, locked=False): - ScrollPanelWrapper.__init__(self) - self.setSize('100%', '100%') - self.host = host - self.locked = locked # if True: tab will not be removed when there are no more widgets inside - self.selected = None - self.flextable = FlexTable() - self.flextable.setSize('100%', '100%') - self.setWidget(self.flextable) - self.setStyleName('widgetsPanel') - _bottom = BottomBorderWidget(self.host) - self.flextable.setWidget(0, 0, _bottom) # There will be always an Empty widget on the last row, - # dropping a widget there will add a new row - td_elt = _bottom.getElement().parentNode - DOM.setStyleAttribute(td_elt, "height", "1px") # needed so the cell adapt to the size of the border (specially in webkit) - self._max_cols = 1 # give the maximum number of columns i a raw - - def isLocked(self): - return self.locked - - def changeWidget(self, row, col, wid): - """Change the widget in the given location, add row or columns when necessary""" - log.debug("changing widget: %s %s %s" % (wid.getDebugName(), row, col)) - last_row = max(0, self.flextable.getRowCount() - 1) - try: - prev_wid = self.flextable.getWidget(row, col) - except: - log.error("Trying to change an unexisting widget !") - return - - cellFormatter = self.flextable.getFlexCellFormatter() - - if isinstance(prev_wid, BorderWidget): - # We are on a border, we must create a row and/or columns - log.debug("BORDER WIDGET") - prev_wid.removeStyleName('dragover') - - if isinstance(prev_wid, BottomBorderWidget): - # We are on the bottom border, we create a new row - self.flextable.insertRow(last_row) - self.flextable.setWidget(last_row, 0, LeftBorderWidget(self.host)) - self.flextable.setWidget(last_row, 1, wid) - self.flextable.setWidget(last_row, 2, RightBorderWidget(self.host)) - cellFormatter.setHorizontalAlignment(last_row, 2, HasAlignment.ALIGN_RIGHT) - row = last_row - - elif isinstance(prev_wid, LeftBorderWidget): - if col != 0: - log.error("LeftBorderWidget must be on the first column !") - return - self.flextable.insertCell(row, col + 1) - self.flextable.setWidget(row, 1, wid) - - elif isinstance(prev_wid, RightBorderWidget): - if col != self.flextable.getCellCount(row) - 1: - log.error("RightBorderWidget must be on the last column !") - return - self.flextable.insertCell(row, col) - self.flextable.setWidget(row, col, wid) - - else: - prev_wid.removeFromParent() - self.flextable.setWidget(row, col, wid) - - _max_cols = max(self._max_cols, self.flextable.getCellCount(row)) - if _max_cols != self._max_cols: - self._max_cols = _max_cols - self._sizesAdjust() - - def _sizesAdjust(self): - cellFormatter = self.flextable.getFlexCellFormatter() - width = 100.0 / max(1, self._max_cols - 2) # we don't count the borders - - for row_idx in xrange(self.flextable.getRowCount()): - for col_idx in xrange(self.flextable.getCellCount(row_idx)): - _widget = self.flextable.getWidget(row_idx, col_idx) - if not isinstance(_widget, BorderWidget): - td_elt = _widget.getElement().parentNode - DOM.setStyleAttribute(td_elt, "width", "%.2f%%" % width) - - last_row = max(0, self.flextable.getRowCount() - 1) - cellFormatter.setColSpan(last_row, 0, self._max_cols) - - def addWidget(self, wid): - """Add a widget to a new cell on the next to last row""" - last_row = max(0, self.flextable.getRowCount() - 1) - log.debug("putting widget %s at %d, %d" % (wid.getDebugName(), last_row, 0)) - self.changeWidget(last_row, 0, wid) - - def removeWidget(self, wid): - """Remove a widget and the cell where it is""" - _row, _col = self.flextable.getIndex(wid) - self.flextable.remove(wid) - self.flextable.removeCell(_row, _col) - if not self.getLiberviaRowWidgets(_row): # we have no more widgets, we remove the row - self.flextable.removeRow(_row) - _max_cols = 1 - for row_idx in xrange(self.flextable.getRowCount()): - _max_cols = max(_max_cols, self.flextable.getCellCount(row_idx)) - if _max_cols != self._max_cols: - self._max_cols = _max_cols - self._sizesAdjust() - current = self - - blank_page = self.getLiberviaWidgetsCount() == 0 # do we still have widgets on the page ? - - if blank_page and not self.isLocked(): - # we now notice the MainTabPanel that the WidgetsPanel is empty and need to be removed - while current is not None: - if isinstance(current, MainTabPanel): - current.onWidgetPanelRemove(self) - return - current = current.getParent() - log.error("no MainTabPanel found !") - - def getWidgetCoords(self, wid): - return self.flextable.getIndex(wid) - - def getLiberviaRowWidgets(self, row): - """ Return all the LiberviaWidget in the row """ - return [wid for wid in self.getRowWidgets(row) if isinstance(wid, LiberviaWidget)] - - def getRowWidgets(self, row): - """ Return all the widgets in the row """ - widgets = [] - cols = self.flextable.getCellCount(row) - for col in xrange(cols): - widgets.append(self.flextable.getWidget(row, col)) - return widgets - - def getLiberviaWidgetsCount(self): - """ Get count of contained widgets """ - return len([wid for wid in self.flextable if isinstance(wid, LiberviaWidget)]) - - def getIndex(self, wid): - return self.flextable.getIndex(wid) - - def getColSpan(self, row, col): - cellFormatter = self.flextable.getFlexCellFormatter() - return cellFormatter.getColSpan(row, col) - - def setColSpan(self, row, col, value): - cellFormatter = self.flextable.getFlexCellFormatter() - return cellFormatter.setColSpan(row, col, value) - - def getRowSpan(self, row, col): - cellFormatter = self.flextable.getFlexCellFormatter() - return cellFormatter.getRowSpan(row, col) - - def setRowSpan(self, row, col, value): - cellFormatter = self.flextable.getFlexCellFormatter() - return cellFormatter.setRowSpan(row, col, value) - - -class DropTab(Label, DropWidget): - - def __init__(self, tab_panel, text): - Label.__init__(self, text) - DropWidget.__init__(self, tab_panel) - self.tab_panel = tab_panel - self.setStyleName('dropCell') - self.setWordWrap(False) - DOM.setStyleAttribute(self.getElement(), "min-width", "30px") - - def _getIndex(self): - """ get current index of the DropTab """ - # XXX: awful hack, but seems the only way to get index - return self.tab_panel.tabBar.panel.getWidgetIndex(self.getParent().getParent()) - 1 - - def onDragEnter(self, event): - #if self == LiberviaDragWidget.current: - # return - self.addStyleName('dragover') - DOM.eventPreventDefault(event) - - def onDragLeave(self, event): - self.removeStyleName('dragover') - - def onDragOver(self, event): - DOM.eventPreventDefault(event) - - def onDrop(self, event): - DOM.eventPreventDefault(event) - self.removeStyleName('dragover') - if self._getIndex() == self.tab_panel.tabBar.getSelectedTab(): - # the widget come from the DragTab, so nothing to do, we let it there - return - - # FIXME: quite the same stuff as in DropCell, need some factorisation - dt = event.dataTransfer - # 'text', 'text/plain', and 'Text' are equivalent. - try: - item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed - if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and - item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report - # item_type = dt.getData("type") - log.debug("message: %s" % item) - log.debug("type: %s" % item_type) - except: - log.debug("no message found") - item = ' ' - item_type = None - if item_type == "WIDGET": - if not LiberviaDragWidget.current: - log.error("No widget registered in LiberviaDragWidget !") - return - _new_panel = LiberviaDragWidget.current - _new_panel.getWidgetsPanel().removeWidget(_new_panel) - elif item_type in DropCell.drop_keys: - _new_panel = DropCell.drop_keys[item_type](self.tab_panel.host, item) - else: - log.warning("unmanaged item type") - return - - widgets_panel = self.tab_panel.getWidget(self._getIndex()) - widgets_panel.addWidget(_new_panel) - - -class MainTabPanel(TabPanel): - - def __init__(self, host): - TabPanel.__init__(self) - self.host = host - self.tabBar.setVisible(False) - self.setStyleName('liberviaTabPanel') - self.addStyleName('mainTabPanel') - Window.addWindowResizeListener(self) - - def getCurrentPanel(self): - """ Get the panel of the currently selected tab """ - return self.deck.visibleWidget - - def onWindowResized(self, width, height): - tab_panel_elt = self.getElement() - _elts = doc().getElementsByClassName('gwt-TabBar') - if not _elts.length: - log.error("no TabBar found, it should exist !") - tab_bar_h = 0 - else: - tab_bar_h = _elts.item(0).offsetHeight - ideal_height = height - DOM.getAbsoluteTop(tab_panel_elt) - tab_bar_h - 5 - ideal_width = width - DOM.getAbsoluteLeft(tab_panel_elt) - 5 - self.setWidth("%s%s" % (ideal_width, "px")) - self.setHeight("%s%s" % (ideal_height, "px")) - - def add(self, widget, text=''): - tab = DropTab(self, text) - TabPanel.add(self, widget, tab, False) - if self.getWidgetCount() > 1: - self.tabBar.setVisible(True) - self.host.resize() - - def onWidgetPanelRemove(self, panel): - """ Called when a child WidgetsPanel is empty and need to be removed """ - self.remove(panel) - widgets_count = self.getWidgetCount() - if widgets_count == 1: - self.tabBar.setVisible(False) - self.host.resize() - self.selectTab(0) - else: - self.selectTab(widgets_count - 1) + def getCategoryHTML(cls, category): + return cls.ITEM_TPL % category.icon diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/blog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/blog.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,771 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _, D_ + +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.Label import Label +from pyjamas.ui.HTML import HTML +from pyjamas.ui.Image import Image +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.FlowPanel import FlowPanel +from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler +from pyjamas.ui.FocusListener import FocusHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.Timer import Timer + +from datetime import datetime +from time import time + +import html_tools +import dialog +import richtext +import editor_widget +import libervia_widget +from constants import Const as C +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.tools import jid + + +unicode = str # XXX: pyjamas doesn't manage unicode + + +class MicroblogItem(): + # XXX: should be moved in a separated module + + def __init__(self, data): + self.id = data['id'] + self.type = data.get('type', 'main_item') + self.empty = data.get('new', False) + self.title = data.get('title', '') + self.title_xhtml = data.get('title_xhtml', '') + self.content = data.get('content', '') + self.content_xhtml = data.get('content_xhtml', '') + self.author = jid.JID(data['author']) + self.updated = float(data.get('updated', 0)) # XXX: int doesn't work here + self.published = float(data.get('published', self.updated)) # XXX: int doesn't work here + self.service = data.get('service', '') + self.node = data.get('node', '') + self.comments = data.get('comments', False) + self.comments_service = data.get('comments_service', '') + self.comments_node = data.get('comments_node', '') + + +class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler): + + def __init__(self, blog_panel, data): + """ + @param blog_panel: the parent panel + @param data: dict containing the blog item data, or a MicroblogItem instance. + """ + self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data) + for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml', + 'author', 'updated', 'published', 'comments', 'service', 'node', + 'comments_service', 'comments_node']: + getter = lambda attr: lambda inst: getattr(inst._base_item, attr) + setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value) + setattr(MicroblogEntry, attr, property(getter(attr), setter(attr))) + + SimplePanel.__init__(self) + self._blog_panel = blog_panel + + self.panel = FlowPanel() + self.panel.setStyleName('mb_entry') + + self.header = HorizontalPanel(StyleName='mb_entry_header') + self.panel.add(self.header) + + self.entry_actions = VerticalPanel() + self.entry_actions.setStyleName('mb_entry_actions') + self.panel.add(self.entry_actions) + + entry_avatar = SimplePanel() + entry_avatar.setStyleName('mb_entry_avatar') + assert isinstance(self.author, jid.JID) # FIXME: temporary + self.avatar = Image(self._blog_panel.host.getAvatarURL(self.author)) # FIXME: self.author should be initially a jid.JID + entry_avatar.add(self.avatar) + self.panel.add(entry_avatar) + + self.entry_dialog = VerticalPanel() + self.entry_dialog.setStyleName('mb_entry_dialog') + self.panel.add(self.entry_dialog) + + self.add(self.panel) + ClickHandler.__init__(self) + self.addClickListener(self) + + self.__pub_data = (self.service, self.node, self.id) + self.__setContent() + + def __setContent(self): + """Actually set the entry content (header, icons, bubble...)""" + self.delete_label = self.update_label = self.comment_label = None + self.bubble = self._current_comment = None + self.__setHeader() + self.__setBubble() + self.__setIcons() + + def __setHeader(self): + """Set the entry header.""" + if self.empty: + return + update_text = u" — ✍ " + "" % datetime.fromtimestamp(self.updated) + self.header.add(HTML(""" + on + %(updated)s + """ % {'author': html_tools.html_sanitize(unicode(self.author)), + 'published': datetime.fromtimestamp(self.published), + 'updated': update_text if self.published != self.updated else '' + })) + if self.comments: + self.comments_count = self.hidden_count = 0 + self.show_comments_link = HTML('') + self.header.add(self.show_comments_link) + + def updateHeader(self, comments_count=None, hidden_count=None, inc=None): + """Update the header. + + @param comments_count (int): total number of comments. + @param hidden_count (int): number of hidden comments. + @param inc (int): number to increment the total number of comments with. + """ + if comments_count is not None: + self.comments_count = comments_count + if hidden_count is not None: + self.hidden_count = hidden_count + if inc is not None: + self.comments_count += inc + + if self.hidden_count > 0: + comments = D_('comments') if self.hidden_count > 1 else D_('comment') + text = D_("show %(count)d previous %(comments)s") % {'count': self.hidden_count, + 'comments': comments} + if self not in self.show_comments_link._clickListeners: + self.show_comments_link.addClickListener(self) + else: + if self.comments_count > 1: + text = "%(count)d %(comments)s" % {'count': self.comments_count, + 'comments': D_('comments')} + elif self.comments_count == 1: + text = D_('1 comment') + else: + text = '' + try: + self.show_comments_link.removeClickListener(self) + except ValueError: + pass + + self.show_comments_link.setHTML("""%(text)s""" % {'text': text}) + + def __setIcons(self): + """Set the entry icons (delete, update, comment)""" + if self.empty: + return + + def addIcon(label, title): + label = Label(label) + label.setTitle(title) + label.addClickListener(self) + self.entry_actions.add(label) + return label + + if self.comments: + self.comment_label = addIcon(u"↶", "Comment this message") + self.comment_label.setStyleName('mb_entry_action_larger') + is_publisher = self.author == self._blog_panel.host.whoami.bare + if is_publisher: + self.update_label = addIcon(u"✍", "Edit this message") + if is_publisher or unicode(self.node).endswith(unicode(self._blog_panel.host.whoami.bare)): + self.delete_label = addIcon(u"✗", "Delete this message") + + def updateAvatar(self, new_avatar): + """Change the avatar of the entry + @param new_avatar: path to the new image""" + self.avatar.setUrl(new_avatar) + + def onClick(self, sender): + if sender == self: + self._blog_panel.setSelectedEntry(self) + elif sender == self.delete_label: + self._delete() + elif sender == self.update_label: + self.edit(True) + elif sender == self.comment_label: + self._comment() + elif sender == self.show_comments_link: + self._blog_panel.loadAllCommentsForEntry(self) + + def __modifiedCb(self, content): + """Send the new content to the backend + @return: False to restore the original content if a deletion has been cancelled + """ + if not content['text']: # previous content has been emptied + self._delete(True) + return False + extra = {'published': unicode(self.published)} + if isinstance(self.bubble, richtext.RichTextEditor): + # TODO: if the user change his parameters after the message edition started, + # the message syntax could be different then the current syntax: pass the + # message syntax in extra for the frontend to use it instead of current syntax. + extra.update({'content_rich': content['text'], 'title': content['title']}) + if self.empty: + if self.type == 'main_item': + self._blog_panel.host.bridge.call('sendMblog', None, None, tuple(self._blog_panel.accepted_groups), content['text'], extra) + else: + self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra) + else: + self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra) + return True + + def __afterEditCb(self, content): + """Remove the entry if it was an empty one (used for creating a new blog post). + Data for the actual new blog post will be received from the bridge""" + if self.empty: + self._blog_panel.removeEntry(self.type, self.id, update_header=False) + if self.type == 'main_item': # restore the "New message" button + self._blog_panel.addNewMessageEntry() + else: # allow to create a new comment + self._parent_entry._current_comment = None + self.entry_dialog.setWidth('auto') + try: + self.toggle_syntax_button.removeFromParent() + except (AttributeError, TypeError): + pass + + def __setBubble(self, edit=False): + """Set the bubble displaying the initial content.""" + content = {'text': self.content_xhtml if self.content_xhtml else self.content, + 'title': self.title_xhtml if self.title_xhtml else self.title} + if self.content_xhtml: + content.update({'syntax': C.SYNTAX_XHTML}) + if self.author != self._blog_panel.host.whoami.bare: + options = ['read_only'] + else: + options = [] if self.empty else ['update_msg'] + self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options) + else: # assume raw text message have no title + self.bubble = editor_widget.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True}) + self.bubble.addStyleName("bubble") + try: + self.toggle_syntax_button.removeFromParent() + except (AttributeError, TypeError): + pass + self.entry_dialog.add(self.bubble) + self.edit(edit) + self.bubble.addEditListener(self.__showWarning) + + def __showWarning(self, sender, keycode): + if keycode == KEY_ENTER: + self._blog_panel.host.showWarning(None, None) + else: + self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment')) + + def _delete(self, empty=False): + """Ask confirmation for deletion. + @return: False if the deletion has been cancelled.""" + def confirm_cb(answer): + if answer: + self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments) + else: # restore the text if it has been emptied during the edition + self.bubble.setContent(self.bubble._original_content) + + if self.empty: + text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.") + dialog.InfoDialog(_("Information"), text).show() + return + text = "" + if empty: + text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.
") + target = _('message and all its comments') if self.comments else _('comment') + text += _("Do you really want to delete this %s?") % target + dialog.ConfirmDialog(confirm_cb, text=text).show() + + def _comment(self): + """Add an empty entry for a new comment""" + if self._current_comment: + self._current_comment.bubble.setFocus(True) + self._blog_panel.setSelectedEntry(self._current_comment, True) + return + data = {'id': unicode(time()), + 'new': True, + 'type': 'comment', + 'author': unicode(self._blog_panel.host.whoami.bare), + 'service': self.comments_service, + 'node': self.comments_node + } + entry = self._blog_panel.addEntry(data, update_header=False) + if entry is None: + log.info("The entry of id %s can not be commented" % self.id) + return + entry._parent_entry = self + self._current_comment = entry + self.edit(True, entry) + self._blog_panel.setSelectedEntry(entry, True) + + def edit(self, edit, entry=None): + """Toggle the bubble between display and edit mode + @edit: boolean value + @entry: MicroblogEntry instance, or None to use self + """ + if entry is None: + entry = self + try: + entry.toggle_syntax_button.removeFromParent() + except (AttributeError, TypeError): + pass + entry.bubble.edit(edit) + if edit: + if isinstance(entry.bubble, richtext.RichTextEditor): + image = 'A' + html = 'raw text' + title = _('Switch to raw text edition') + else: + image = '' + html = 'rich text' + title = _('Switch to rich text edition') + entry.toggle_syntax_button = HTML(html) + entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax) + entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax') + entry.entry_dialog.add(entry.toggle_syntax_button) + entry.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS + entry.toggle_syntax_button.setStyleAttribute('left', '-20px') + + def toggleContentSyntax(self): + """Toggle the editor between raw and rich text""" + original_content = self.bubble.getOriginalContent() + rich = not isinstance(self.bubble, richtext.RichTextEditor) + if rich: + original_content['syntax'] = C.SYNTAX_XHTML + + def setBubble(text): + self.content = text + self.content_xhtml = text if rich else '' + self.content_title = self.content_title_xhtml = '' + self.bubble.removeFromParent() + self.__setBubble(True) + self.bubble.setOriginalContent(original_content) + if rich: + self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble + + text = self.bubble.getContent()['text'] + if not text: + setBubble(' ') # something different than empty string is needed to initialize the rich text editor + return + if not rich: + def confirm_cb(answer): + if answer: + self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT) + dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show() + else: + self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML) + + +class MicroblogPanel(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget, MouseHandler): + warning_msg_public = "This message will be PUBLIC and everybody will be able to see it, even people you don't know" + warning_msg_group = "This message will be published for all the people of the following groups: %s" + # FIXME: all the generic parts must be moved to quick_frontends + + def __init__(self, host, targets, profiles=None): + """Panel used to show microblog + + @param targets (tuple(unicode)): contact groups displayed in this panel. + If empty, show all microblogs from all contacts. + """ + # do not mix self.targets (set of tuple of unicode) and self.accepted_groups (set of unicode) + quick_widgets.QuickWidget.__init__(self, host, targets, C.PROF_KEY_NONE) + libervia_widget.LiberviaWidget.__init__(self, host, ", ".join(self.accepted_groups), selectable=True) + MouseHandler.__init__(self) + self.entries = {} + self.comments = {} + self.vpanel = VerticalPanel() + self.vpanel.setStyleName('microblogPanel') + self.setWidget(self.vpanel) + self.addNewMessageEntry() + + self.footer = HTML('', StyleName='microblogPanel_footer') + self.footer.waiting = False + self.footer.addClickListener(self) + self.footer.addMouseListener(self) + self.vpanel.add(self.footer) + self.next_rsm_index = 0 + + # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) + self.avatarListener = self.onAvatarUpdate + host.addListener('avatar', self.avatarListener, [C.PROF_KEY_NONE]) + + def __str__(self): + return u"Blog Widget [target: {}, profile: {}]".format(', '.join(self.accepted_groups), self.profile) + + def onDelete(self): + quick_widgets.QuickWidget.onDelete(self) + self.host.removeListener('avatar', self.avatarListener) + + def onAvatarUpdate(self, jid_, hash_, profile): + """Called on avatar update events + + @param jid_: jid of the entity with updated avatar + @param hash_: hash of the avatar + @param profile: %(doc_profile)s + """ + whoami = self.host.profiles[self.profile].whoami + if self.isJidAccepted(jid_) or jid_.bare == whoami.bare: + self.updateValue('avatar', jid_, hash_) + + def addNewMessageEntry(self): + """Add an empty entry for writing a new message if needed.""" + if self.getNewMainEntry(): + return # there's already one + data = {'id': unicode(time()), + 'new': True, + 'author': unicode(self.host.whoami.bare), + } + entry = self.addEntry(data, update_header=False) + entry.edit(True) + + def getNewMainEntry(self): + """Get the new entry being edited, or None if it doesn't exists. + + @return (MicroblogEntry): the new entry being edited. + """ + if len(self.vpanel.children) < 2: + return None # there's only the footer + first = self.vpanel.children[0] + assert(first.type == 'main_item') + return first if first.empty else None + + @staticmethod + def onGroupDrop(host, targets): + """Create a microblog panel for one, several or all contact groups. + + @param host (SatWebFrontend): the SatWebFrontend instance + @param targets (tuple(unicode)): tuple of groups (empty for "all groups") + @return: the created MicroblogPanel + """ + # XXX: pyjamas doesn't support use of cls directly + widget = host.displayWidget(MicroblogPanel, targets, dropped=True) + widget.loadMoreMainEntries() + return widget + + @property + def accepted_groups(self): + """Return a set of the accepted groups""" + return set().union(*self.targets) + + def loadAllCommentsForEntry(self, main_entry): + """Load all the comments for the given main entry. + + @param main_entry (MicroblogEntry): main entry having comments. + """ + index = str(main_entry.comments_count - main_entry.hidden_count) + rsm = {'max': str(main_entry.hidden_count), 'index': index} + self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm) + + def loadMoreMainEntries(self): + if self.footer.waiting: + return + self.footer.waiting = True + self.footer.setHTML("loading...") + + self.host.loadOurMainEntries(self.next_rsm_index, self) + + type_ = 'ALL' if self.accepted_groups == [] else 'GROUP' + rsm = {'max': str(C.RSM_MAX_ITEMS), 'index': str(self.next_rsm_index)} + self.host.bridge.getMassiveMblogs(type_, list(self.accepted_groups), rsm, profile=C.PROF_KEY_NONE, callback=self.massiveInsert) + + def getWarningData(self, comment): + """ + @param comment: set to True if the composed message is a comment + @return: a couple (type, msg) for calling self.host.showWarning""" + if comment: + return ("PUBLIC", "This is a comment and keep the initial post visibility, so it is potentialy public") + elif not self.accepted_groups: + # we have a meta MicroblogPanel, we publish publicly + return ("PUBLIC", self.warning_msg_public) + else: + # FIXME: manage several groups + return ("GROUP", self.warning_msg_group % ' '.join(self.accepted_groups)) + + def onTextEntered(self, text): + if not self.accepted_groups: + # we are entering a public microblog + self.bridge.call("sendMblog", None, "PUBLIC", (), text, {}) + else: + self.bridge.call("sendMblog", None, "GROUP", tuple(self.accepted_groups), text, {}) + + def accept_all(self): + return not self.accepted_groups # we accept every microblog only if we are not filtering by groups + + def getEntries(self): + """Ask all the entries for the currenly accepted groups, + and fill the panel""" + + def massiveInsert(self, mblogs): + """Insert several microblogs at once + + @param mblogs (dict): dictionary mapping a publisher to microblogs data: + - key: publisher (str) + - value: couple (list[dict], dict) with: + - list of microblogs data + - RSM response data + """ + count_pub = len(mblogs) + count_msg = sum([len(value) for value in mblogs.values()]) + log.debug("massive insertion of {count_msg} blogs for {count_pub} contacts".format(count_msg=count_msg, count_pub=count_pub)) + for publisher in mblogs: + log.debug("adding {count} blogs for [{publisher}]".format(count=len(mblogs[publisher]), publisher=publisher)) + self.mblogsInsert(mblogs[publisher]) + self.next_rsm_index += C.RSM_MAX_ITEMS + self.footer.waiting = False + self.footer.setHTML('show older messages') + + def mblogsInsert(self, mblogs): + """ Insert several microblogs from the same node at once. + + @param mblogs (list): couple (list[dict], dict) with: + - list of microblogs data + - RSM response data + """ + mblogs, rsm = mblogs + + for mblog in mblogs: + if "content" not in mblog: + log.warning("No content found in microblog [%s]" % mblog) + continue + self.addEntry(mblog, update_header=False) + + hashes = set([(entry['service'], entry['node']) for entry in mblogs if entry['type'] == 'comment']) + assert(len(hashes) < 2) # ensure the blogs come from the same node + if len(hashes) == 1: + main_entry = self.comments[hashes.pop()] + count = int(rsm['count']) + hidden = count - (int(rsm['index']) + len(mblogs)) + main_entry.updateHeader(count, hidden) + + def _chronoInsert(self, vpanel, entry, reverse=True): + """ Insert an entry in chronological order + @param vpanel: VerticalPanel instance + @param entry: MicroblogEntry + @param reverse: more recent entry on top if True, chronological order else""" + # XXX: for now we can't use "published" timestamp because the entries + # are retrieved using the "updated" field. We don't want new items + # inserted with RSM to be inserted "randomly" in the panel, they + # should be added at the bottom of the list. + assert(isinstance(reverse, bool)) + if entry.empty: + entry.updated = time() + # we look for the right index to insert our entry: + # if reversed, we insert the entry above the first entry + # in the past + idx = 0 + + for child in vpanel.children[0:-1]: # ignore the footer + if not isinstance(child, MicroblogEntry): + idx += 1 + continue + condition_to_stop = child.empty or (child.updated > entry.updated) + if condition_to_stop != reverse: # != is XOR + break + idx += 1 + + vpanel.insert(entry, idx) + + def addEntryIfAccepted(self, sender, groups, mblog_entry): + """Check if an entry can go in MicroblogPanel and add to it + + @param sender(jid.JID): jid of the entry sender + @param groups: groups which can receive this entry + @param mblog_entry: panels.MicroblogItem instance + """ + assert isinstance(sender, jid.JID) # FIXME temporary + if (mblog_entry.type == "comment" + or self.isJidAccepted(sender) + or (groups is None and sender == self.host.profiles[self.profile].whoami.bare) + or (groups and groups.intersection(self.accepted_groups))): + self.addEntry(mblog_entry) + + def addEntry(self, data, update_header=True): + """Add an entry to the panel + + @param data (dict): dict containing the item data + @param update_header (bool): update or not the main comment header + @return: the added MicroblogEntry instance, or None + """ + _entry = MicroblogEntry(self, data) + if _entry.type == "comment": + comments_hash = (_entry.service, _entry.node) + if comments_hash not in self.comments: + # The comments node is not known in this panel + return None + parent = self.comments[comments_hash] + parent_idx = self.vpanel.getWidgetIndex(parent) + # we find or create the panel where the comment must be inserted + try: + sub_panel = self.vpanel.getWidget(parent_idx + 1) + except IndexError: + sub_panel = None + if not sub_panel or not isinstance(sub_panel, VerticalPanel): + sub_panel = VerticalPanel() + sub_panel.setStyleName('microblogPanel') + sub_panel.addStyleName('subPanel') + self.vpanel.insert(sub_panel, parent_idx + 1) + + for idx in xrange(0, len(sub_panel.getChildren())): + comment = sub_panel.getIndexedChild(idx) + if comment.id == _entry.id: + # update an existing comment + sub_panel.remove(comment) + sub_panel.insert(_entry, idx) + return _entry + # we want comments to be inserted in chronological order + self._chronoInsert(sub_panel, _entry, reverse=False) + if update_header: + parent.updateHeader(inc=+1) + return _entry + + if _entry.comments: + # entry has comments, we keep the comments service/node as a reference + comments_hash = (_entry.comments_service, _entry.comments_node) + self.comments[comments_hash] = _entry + self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node) + + if _entry.id in self.entries: # update + old_entry = self.entries[_entry.id] + idx = self.vpanel.getWidgetIndex(old_entry) + counts = (old_entry.comments_count, old_entry.hidden_count) + self.vpanel.remove(old_entry) + self.vpanel.insert(_entry, idx) + _entry.updateHeader(*counts) + else: # new entry + self._chronoInsert(self.vpanel, _entry) + if _entry.comments: + self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node) + + self.entries[_entry.id] = _entry + + return _entry + + def removeEntry(self, type_, id_, update_header=True): + """Remove an entry from the panel + + @param type_ (str): entry type ('main_item' or 'comment') + @param id_ (str): entry id + @param update_header (bool): update or not the main comment header + """ + for child in self.vpanel.getChildren(): + if isinstance(child, MicroblogEntry) and type_ == 'main_item': + if child.id == id_: + main_idx = self.vpanel.getWidgetIndex(child) + try: + sub_panel = self.vpanel.getWidget(main_idx + 1) + if isinstance(sub_panel, VerticalPanel): + sub_panel.removeFromParent() + except IndexError: + pass + child.removeFromParent() + break + elif isinstance(child, VerticalPanel) and type_ == 'comment': + for comment in child.getChildren(): + if comment.id == id_: + if update_header: + hash_ = (comment.service, comment.node) + self.comments[hash_].updateHeader(inc=-1) + comment.removeFromParent() + break + + def ensureVisible(self, entry): + """Scroll to an entry to ensure its visibility + + @param entry (MicroblogEntry): the entry + """ + try: + self.vpanel.getParent().ensureVisible(entry) # scroll to the clicked entry + except AttributeError: + log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!") + + def setSelectedEntry(self, entry, ensure_visible=False): + """Select an entry. + + @param entry (MicroblogEntry): the entry to select + @param ensure_visible (boolean): if True, also scroll to the entry + """ + if ensure_visible: + self.ensureVisible(entry) + + entry.addStyleName('selected_entry') # blink the clicked entry + clicked_entry = entry # entry may be None when the timer is done + Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry')) + + def updateValue(self, type_, jid_, value): + """Update a jid value in entries + + @param type_: one of 'avatar', 'nick' + @param jid_(jid.JID): jid concerned + @param value: new value""" + assert isinstance(jid_, jid.JID) # FIXME: temporary + def updateVPanel(vpanel): + avatar_url = self.host.getAvatarURL(jid_) + for child in vpanel.children: + if isinstance(child, MicroblogEntry) and child.author == jid_: + child.updateAvatar(avatar_url) + elif isinstance(child, VerticalPanel): + updateVPanel(child) + if type_ == 'avatar': + updateVPanel(self.vpanel) + + def addAcceptedGroups(self, groups): + """Add one or more group(s) which can be displayed in this panel. + + @param groups (tuple(unicode)): tuple of groups to add + """ + # FIXME: update the widget's hash in QuickApp._widgets[MicroblogPanel] + self.targets.update(groups) + + def isJidAccepted(self, jid_): + """Tell if a jid is actepted and must be shown in this panel + + @param jid_(jid.JID): jid to check + @return: True if the jid is accepted + """ + assert isinstance(jid_, jid.JID) # FIXME temporary + if self.accept_all(): + return True + for group in self.accepted_groups: + if self.host.contact_lists[self.profile].isEntityInGroup(jid_, group): + return True + return False + + def onClick(self, sender): + if sender == self.footer: + self.loadMoreMainEntries() + + def onMouseEnter(self, sender): + if sender == self.footer: + self.loadMoreMainEntries() + + +libervia_widget.LiberviaWidget.addDropKey("GROUP", lambda host, item: MicroblogPanel.onGroupDrop(host, (item,))) + +# Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group +libervia_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", lambda host, item: MicroblogPanel.onGroupDrop(host, ())) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/card_game.py --- a/src/browser/sat_browser/card_game.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,387 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pyjd # this is dummy in pyjs -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.tools.games import TarotCard -from sat.core.i18n import _ - -from pyjamas.ui.AbsolutePanel import AbsolutePanel -from pyjamas.ui.DockPanel import DockPanel -from pyjamas.ui.SimplePanel import SimplePanel -from pyjamas.ui.Image import Image -from pyjamas.ui.Label import Label -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas.ui.MouseListener import MouseHandler -from pyjamas.ui import HasAlignment -from pyjamas import Window -from pyjamas import DOM - -import dialog -import xmlui - - -CARD_WIDTH = 74 -CARD_HEIGHT = 136 -CARD_DELTA_Y = 30 -MIN_WIDTH = 950 # Minimum size of the panel -MIN_HEIGHT = 500 - - -class CardWidget(TarotCard, Image, MouseHandler): - """This class is used to represent a card, graphically and logically""" - - def __init__(self, parent, file_): - """@param file: path of the PNG file""" - self._parent = parent - Image.__init__(self, file_) - root_name = file_[file_.rfind("/") + 1:-4] - suit, value = root_name.split('_') - TarotCard.__init__(self, (suit, value)) - MouseHandler.__init__(self) - self.addMouseListener(self) - - def onMouseEnter(self, sender): - if self._parent.state == "ecart" or self._parent.state == "play": - DOM.setStyleAttribute(self.getElement(), "top", "0px") - - def onMouseLeave(self, sender): - if not self in self._parent.hand: - return - if not self in list(self._parent.selected): # FIXME: Workaround pyjs bug, must report it - DOM.setStyleAttribute(self.getElement(), "top", "%dpx" % CARD_DELTA_Y) - - def onMouseUp(self, sender, x, y): - if self._parent.state == "ecart": - if self not in list(self._parent.selected): - self._parent.addToSelection(self) - else: - self._parent.removeFromSelection(self) - elif self._parent.state == "play": - self._parent.playCard(self) - - -class CardPanel(DockPanel, ClickHandler): - - def __init__(self, parent, referee, player_nick, players): - DockPanel.__init__(self) - ClickHandler.__init__(self) - self._parent = parent - self._autoplay = None # XXX: use 0 to activate fake play, None else - self.referee = referee - self.players = players - self.player_nick = player_nick - self.bottom_nick = self.player_nick - idx = self.players.index(self.player_nick) - idx = (idx + 1) % len(self.players) - self.right_nick = self.players[idx] - idx = (idx + 1) % len(self.players) - self.top_nick = self.players[idx] - idx = (idx + 1) % len(self.players) - self.left_nick = self.players[idx] - self.bottom_nick = player_nick - self.selected = set() # Card choosed by the player (e.g. during ecart) - self.hand_size = 13 # number of cards in a hand - self.hand = [] - self.to_show = [] - self.state = None - self.setSize("%dpx" % MIN_WIDTH, "%dpx" % MIN_HEIGHT) - self.setStyleName("cardPanel") - - # Now we set up the layout - _label = Label(self.top_nick) - _label.setStyleName('cardGamePlayerNick') - self.add(_label, DockPanel.NORTH) - self.setCellWidth(_label, '100%') - self.setCellHorizontalAlignment(_label, HasAlignment.ALIGN_CENTER) - - self.hand_panel = AbsolutePanel() - self.add(self.hand_panel, DockPanel.SOUTH) - self.setCellWidth(self.hand_panel, '100%') - self.setCellHorizontalAlignment(self.hand_panel, HasAlignment.ALIGN_CENTER) - - _label = Label(self.left_nick) - _label.setStyleName('cardGamePlayerNick') - self.add(_label, DockPanel.WEST) - self.setCellHeight(_label, '100%') - self.setCellVerticalAlignment(_label, HasAlignment.ALIGN_MIDDLE) - - _label = Label(self.right_nick) - _label.setStyleName('cardGamePlayerNick') - self.add(_label, DockPanel.EAST) - self.setCellHeight(_label, '100%') - self.setCellHorizontalAlignment(_label, HasAlignment.ALIGN_RIGHT) - self.setCellVerticalAlignment(_label, HasAlignment.ALIGN_MIDDLE) - - self.center_panel = DockPanel() - self.inner_left = SimplePanel() - self.inner_left.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) - self.center_panel.add(self.inner_left, DockPanel.WEST) - self.center_panel.setCellHeight(self.inner_left, '100%') - self.center_panel.setCellHorizontalAlignment(self.inner_left, HasAlignment.ALIGN_RIGHT) - self.center_panel.setCellVerticalAlignment(self.inner_left, HasAlignment.ALIGN_MIDDLE) - - self.inner_right = SimplePanel() - self.inner_right.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) - self.center_panel.add(self.inner_right, DockPanel.EAST) - self.center_panel.setCellHeight(self.inner_right, '100%') - self.center_panel.setCellVerticalAlignment(self.inner_right, HasAlignment.ALIGN_MIDDLE) - - self.inner_top = SimplePanel() - self.inner_top.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) - self.center_panel.add(self.inner_top, DockPanel.NORTH) - self.center_panel.setCellHorizontalAlignment(self.inner_top, HasAlignment.ALIGN_CENTER) - self.center_panel.setCellVerticalAlignment(self.inner_top, HasAlignment.ALIGN_BOTTOM) - - self.inner_bottom = SimplePanel() - self.inner_bottom.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) - self.center_panel.add(self.inner_bottom, DockPanel.SOUTH) - self.center_panel.setCellHorizontalAlignment(self.inner_bottom, HasAlignment.ALIGN_CENTER) - self.center_panel.setCellVerticalAlignment(self.inner_bottom, HasAlignment.ALIGN_TOP) - - self.inner_center = SimplePanel() - self.center_panel.add(self.inner_center, DockPanel.CENTER) - self.center_panel.setCellHorizontalAlignment(self.inner_center, HasAlignment.ALIGN_CENTER) - self.center_panel.setCellVerticalAlignment(self.inner_center, HasAlignment.ALIGN_MIDDLE) - - self.add(self.center_panel, DockPanel.CENTER) - self.setCellWidth(self.center_panel, '100%') - self.setCellHeight(self.center_panel, '100%') - self.setCellVerticalAlignment(self.center_panel, HasAlignment.ALIGN_MIDDLE) - self.setCellHorizontalAlignment(self.center_panel, HasAlignment.ALIGN_CENTER) - - self.loadCards() - self.mouse_over_card = None # contain the card to highlight - self.visible_size = CARD_WIDTH / 2 # number of pixels visible for cards - self.addClickListener(self) - - def loadCards(self): - """Load all the cards in memory""" - def _getTarotCardsPathsCb(paths): - log.debug("_getTarotCardsPathsCb") - for file_ in paths: - log.debug("path: %s" % file_) - card = CardWidget(self, file_) - log.debug("card: %s" % card) - self.cards[(card.suit, card.value)] = card - self.deck.append(card) - self._parent.host.bridge.call('tarotGameReady', None, self.player_nick, self.referee) - self.cards = {} - self.deck = [] - self.cards["atout"] = {} # As Tarot is a french game, it's more handy & logical to keep french names - self.cards["pique"] = {} # spade - self.cards["coeur"] = {} # heart - self.cards["carreau"] = {} # diamond - self.cards["trefle"] = {} # club - self._parent.host.bridge.call('getTarotCardsPaths', _getTarotCardsPathsCb) - - def onClick(self, sender): - if self.state == "chien": - self.to_show = [] - self.state = "wait" - self.updateToShow() - elif self.state == "wait_for_ecart": - self.state = "ecart" - self.hand.extend(self.to_show) - self.hand.sort() - self.to_show = [] - self.updateToShow() - self.updateHand() - - def tarotGameNew(self, hand): - """Start a new game, with given hand""" - if hand is []: # reset the display after the scores have been showed - self.selected.clear() - del self.hand[:] - del self.to_show[:] - self.state = None - #empty hand - self.updateHand() - #nothing on the table - self.updateToShow() - for pos in ['top', 'left', 'bottom', 'right']: - getattr(self, "inner_%s" % pos).setWidget(None) - self._parent.host.bridge.call('tarotGameReady', None, self.player_nick, self.referee) - return - for suit, value in hand: - self.hand.append(self.cards[(suit, value)]) - self.hand.sort() - self.state = "init" - self.updateHand() - - def updateHand(self): - """Show the cards in the hand in the hand_panel (SOUTH panel)""" - self.hand_panel.clear() - self.hand_panel.setSize("%dpx" % (self.visible_size * (len(self.hand) + 1)), "%dpx" % (CARD_HEIGHT + CARD_DELTA_Y + 10)) - x_pos = 0 - y_pos = CARD_DELTA_Y - for card in self.hand: - self.hand_panel.add(card, x_pos, y_pos) - x_pos += self.visible_size - - def updateToShow(self): - """Show cards in the center panel""" - if not self.to_show: - _widget = self.inner_center.getWidget() - if _widget: - self.inner_center.remove(_widget) - return - panel = AbsolutePanel() - panel.setSize("%dpx" % ((CARD_WIDTH + 5) * len(self.to_show) - 5), "%dpx" % (CARD_HEIGHT)) - x_pos = 0 - y_pos = 0 - for card in self.to_show: - panel.add(card, x_pos, y_pos) - x_pos += CARD_WIDTH + 5 - self.inner_center.setWidget(panel) - - def _ecartConfirm(self, confirm): - if not confirm: - return - ecart = [] - for card in self.selected: - ecart.append((card.suit, card.value)) - self.hand.remove(card) - self.selected.clear() - self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, ecart) - self.state = "wait" - self.updateHand() - - def addToSelection(self, card): - self.selected.add(card) - if len(self.selected) == 6: - dialog.ConfirmDialog(self._ecartConfirm, "Put these cards into chien ?").show() - - def tarotGameInvalidCards(self, phase, played_cards, invalid_cards): - """Invalid cards have been played - @param phase: phase of the game - @param played_cards: all the cards played - @param invalid_cards: cards which are invalid""" - - if phase == "play": - self.state = "play" - elif phase == "ecart": - self.state = "ecart" - else: - log.error("INTERNAL ERROR: unmanaged game phase") # FIXME: raise an exception here - - for suit, value in played_cards: - self.hand.append(self.cards[(suit, value)]) - - self.hand.sort() - self.updateHand() - if self._autoplay == None: # No dialog if there is autoplay - Window.alert('Cards played are invalid !') - self.__fakePlay() - - def removeFromSelection(self, card): - self.selected.remove(card) - if len(self.selected) == 6: - dialog.ConfirmDialog(self._ecartConfirm, "Put these cards into chien ?").show() - - def tarotGameChooseContrat(self, xml_data): - """Called when the player has to select his contrat - @param xml_data: SàT xml representation of the form""" - body = xmlui.create(self._parent.host, xml_data, flags=['NO_CANCEL']) - _dialog = dialog.GenericDialog(_('Please choose your contrat'), body, options=['NO_CLOSE']) - body.setCloseCb(_dialog.close) - _dialog.show() - - def tarotGameShowCards(self, game_stage, cards, data): - """Display cards in the middle of the game (to show for e.g. chien ou poignée)""" - self.to_show = [] - for suit, value in cards: - self.to_show.append(self.cards[(suit, value)]) - self.updateToShow() - if game_stage == "chien" and data['attaquant'] == self.player_nick: - self.state = "wait_for_ecart" - else: - self.state = "chien" - - def getPlayerLocation(self, nick): - """return player location (top,bottom,left or right)""" - for location in ['top', 'left', 'bottom', 'right']: - if getattr(self, '%s_nick' % location) == nick: - return location - log.error("This line should not be reached") - - def tarotGameCardsPlayed(self, player, cards): - """A card has been played by player""" - if not len(cards): - log.warning("cards should not be empty") - return - if len(cards) > 1: - log.error("can't manage several cards played") - if self.to_show: - self.to_show = [] - self.updateToShow() - suit, value = cards[0] - player_pos = self.getPlayerLocation(player) - player_panel = getattr(self, "inner_%s" % player_pos) - - if player_panel.getWidget() != None: - #We have already cards on the table, we remove them - for pos in ['top', 'left', 'bottom', 'right']: - getattr(self, "inner_%s" % pos).setWidget(None) - - card = self.cards[(suit, value)] - DOM.setElemAttribute(card.getElement(), "style", "") - player_panel.setWidget(card) - - def tarotGameYourTurn(self): - """Called when we have to play :)""" - if self.state == "chien": - self.to_show = [] - self.updateToShow() - self.state = "play" - self.__fakePlay() - - def __fakePlay(self): - """Convenience method for stupid autoplay - /!\ don't forgot to comment any interactive dialog for invalid card""" - if self._autoplay == None: - return - if self._autoplay >= len(self.hand): - self._autoplay = 0 - card = self.hand[self._autoplay] - self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, [(card.suit, card.value)]) - del self.hand[self._autoplay] - self.state = "wait" - self._autoplay += 1 - - def playCard(self, card): - self.hand.remove(card) - self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, [(card.suit, card.value)]) - self.state = "wait" - self.updateHand() - - def tarotGameScore(self, xml_data, winners, loosers): - """Show score at the end of a round""" - if not winners and not loosers: - title = "Draw game" - else: - if self.player_nick in winners: - title = "You win !" - else: - title = "You loose :(" - body = xmlui.create(self._parent.host, xml_data, title=title, flags=['NO_CANCEL']) - _dialog = dialog.GenericDialog(title, body, options=['NO_CLOSE']) - body.setCloseCb(_dialog.close) - _dialog.show() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/chat.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/chat.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,347 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat.core.log import getLogger +log = getLogger(__name__) + +# from sat_frontends.tools.games import SYMBOLS +from sat_frontends.tools import strings +from sat_frontends.tools import jid +from sat_frontends.quick_frontend import quick_widgets, quick_games, quick_menus +from sat_frontends.quick_frontend.quick_chat import QuickChat +from sat.core.i18n import _ + +from pyjamas.ui.AbsolutePanel import AbsolutePanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.Label import Label +from pyjamas.ui.HTML import HTML +from pyjamas.ui.KeyboardListener import KEY_ENTER, KeyboardHandler +from pyjamas.ui.HTMLPanel import HTMLPanel +from pyjamas import DOM + +from datetime import datetime +from time import time + +import html_tools +import libervia_widget +import base_panel +import contact_panel +import editor_widget +import contact_list +from constants import Const as C +import plugin_xep_0085 +import game_tarot +import game_radiocol + + +unicode = str # FIXME: pyjamas workaround + + +class ChatText(HTMLPanel): + + def __init__(self, nick, mymess, msg, extra): + try: + timestamp = float(extra['timestamp']) + except KeyError: + timestamp=None + xhtml = extra.get('xhtml') + _date = datetime.fromtimestamp(float(timestamp or time())) + _msg_class = ["chat_text_msg"] + if mymess: + _msg_class.append("chat_text_mymess") + HTMLPanel.__init__(self, "%(timestamp)s %(nick)s %(msg)s" % + {"timestamp": _date.strftime("%H:%M"), + "nick": "[%s]" % html_tools.html_sanitize(nick), + "msg_class": ' '.join(_msg_class), + "msg": strings.addURLToText(html_tools.html_sanitize(msg)) if not xhtml else html_tools.inlineRoot(xhtml)} # FIXME: images and external links must be removed according to preferences + ) + self.setStyleName('chatText') + + +class Chat(QuickChat, libervia_widget.LiberviaWidget, KeyboardHandler): + + def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None): + """Panel used for conversation (one 2 one or group chat) + + @param host: SatWebFrontend instance + @param target: entity (jid.JID) with who we have a conversation (contact's jid for one 2 one chat, or MUC room) + @param type: one2one for simple conversation, group for MUC + """ + QuickChat.__init__(self, host, target, type_, profiles=profiles) + self.vpanel = VerticalPanel() + self.vpanel.setSize('100%', '100%') + + # FIXME: temporary dirty initialization to display the OTR state + header_info = host.plugins['otr'].getInfoTextForUser(target) if (type_ == C.CHAT_ONE2ONE and 'otr' in host.plugins) else None + + libervia_widget.LiberviaWidget.__init__(self, host, title=unicode(target.bare), info=header_info, selectable=True) + self._body = AbsolutePanel() + self._body.setStyleName('chatPanel_body') + chat_area = HorizontalPanel() + chat_area.setStyleName('chatArea') + if type_ == C.CHAT_GROUP: + self.occupants_panel = contact_panel.ContactsPanel(host, merge_resources=False, + contacts_style="muc_contact", + contacts_menus=(C.MENU_JID_CONTEXT), + contacts_display=('resource',)) + chat_area.add(self.occupants_panel) + DOM.setAttribute(chat_area.getWidgetTd(self.occupants_panel), "className", "occupantsPanelCell") + self._body.add(chat_area) + self.content = AbsolutePanel() + self.content.setStyleName('chatContent') + self.content_scroll = base_panel.ScrollPanelWrapper(self.content) + chat_area.add(self.content_scroll) + chat_area.setCellWidth(self.content_scroll, '100%') + self.vpanel.add(self._body) + self.vpanel.setCellHeight(self._body, '100%') + self.addStyleName('chatPanel') + self.setWidget(self.vpanel) + self.state_machine = plugin_xep_0085.ChatStateMachine(self.host, unicode(self.target)) + self._state = None + self.refresh() + if type_ == C.CHAT_ONE2ONE: + self.historyPrint(profile=self.profile) + + @property + def target(self): + # FIXME: for unknow reason, pyjamas doesn't use the method inherited from QuickChat + # FIXME: must remove this when either pyjamas is fixed, or we use an alternative + if self.type == C.CHAT_GROUP: + return self.current_target.bare + return self.current_target + + @property + def profile(self): + # FIXME: for unknow reason, pyjamas doesn't use the method inherited from QuickWidget + # FIXME: must remove this when either pyjamas is fixed, or we use an alternative + assert len(self.profiles) == 1 and not self.PROFILES_MULTIPLE and not self.PROFILES_ALLOW_NONE + return list(self.profiles)[0] + + @property + def plugin_menu_context(self): + return (C.MENU_ROOM,) if self.type == C.CHAT_GROUP else (C.MENU_SINGLE,) + + # @classmethod + # def createPanel(cls, host, item, type_=C.CHAT_ONE2ONE): + # assert(item) + # _contact = item if isinstance(item, jid.JID) else jid.JID(item) + # host.contact_panel.setContactMessageWaiting(_contact.bare, False) + # _new_panel = Chat(host, _contact, type_) # XXX: pyjamas doesn't seems to support creating with cls directly + # _new_panel.historyPrint() + # host.setSelected(_new_panel) + # _new_panel.refresh() + # return _new_panel + + def refresh(self): + """Refresh the display of this widget. If the unibox is disabled, + add a message box at the bottom of the panel""" + # FIXME: must be checked + # self.host.contact_panel.setContactMessageWaiting(self.target.bare, False) + # self.content_scroll.scrollToBottom() + + enable_box = self.host.uni_box is None + if hasattr(self, 'message_box'): + self.message_box.setVisible(enable_box) + elif enable_box: + self.message_box = editor_widget.MessageBox(self.host) + self.message_box.onSelectedChange(self) + self.message_box.addKeyboardListener(self) + self.vpanel.add(self.message_box) + + def onKeyDown(self, sender, keycode, modifiers): + if keycode == KEY_ENTER: + self.host.showWarning(None, None) + else: + self.host.showWarning(*self.getWarningData()) + + def getWarningData(self): + if self.type not in [C.CHAT_ONE2ONE, C.CHAT_GROUP]: + raise Exception("Unmanaged type !") + if self.type == C.CHAT_ONE2ONE: + msg = "This message will be sent to your contact %s" % self.target + elif self.type == C.CHAT_GROUP: + msg = "This message will be sent to all the participants of the multi-user room %s" % self.target + return ("ONE2ONE" if self.type == C.CHAT_ONE2ONE else "GROUP", msg) + + def onTextEntered(self, text): + self.host.sendMessage(self.target, + text, + mess_type=C.MESS_TYPE_GROUPCHAT if self.type == C.CHAT_GROUP else C.MESS_TYPE_CHAT, + errback=self.host.sendError, + profile_key=C.PROF_KEY_NONE + ) + self.state_machine._onEvent("active") + + def onQuit(self): + libervia_widget.LiberviaWidget.onQuit(self) + if self.type == C.CHAT_GROUP: + self.host.bridge.call('mucLeave', None, unicode(self.target.bare)) + + def setUserNick(self, nick): + """Set the nick of the user, usefull for e.g. change the color of the user""" + self.nick = nick + + def setPresents(self, nicks): + """Set the occupants of a group chat. + + @param nicks (list[unicode]): sorted list of nicknames + """ + QuickChat.setPresents(self, nicks) + self.occupants_panel.setList([jid.JID(u"%s/%s" % (self.target, nick)) for nick in nicks]) + + def replaceUser(self, nick, show_info=True): + """Add user if it is not in the group list""" + QuickChat.replaceUser(self, nick, show_info) + occupant_jid = jid.JID("%s/%s" % (unicode(self.target), nick)) + self.occupants_panel.addContact(occupant_jid) + + def removeUser(self, nick, show_info=True): + """Remove a user from the group list""" + QuickChat.removeUser(self, nick, show_info) + occupant_jid = jid.JID("%s/%s" % (unicode(self.target), nick)) + self.occupants_panel.removeContact(occupant_jid) + + def changeUserNick(self, old_nick, new_nick): + assert self.type == C.CHAT_GROUP + # FIXME + # self.occupants_panel.removeOccupant(old_nick) + # self.occupants_panel.addOccupant(new_nick) + self.printInfo(_("%(old_nick)s is now known as %(new_nick)s") % {'old_nick': old_nick, 'new_nick': new_nick}) + + # def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT): + # """Print the initial history""" + # def getHistoryCB(history): + # # display day change + # day_format = "%A, %d %b %Y" + # previous_day = datetime.now().strftime(day_format) + # for line in history: + # timestamp, from_jid_s, to_jid_s, message, mess_type, extra = line + # message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format) + # if previous_day != message_day: + # self.printInfo("* " + message_day) + # previous_day = message_day + # self.printMessage(jid.JID(from_jid_s), message, extra, timestamp) + # self.host.bridge.call('getHistory', getHistoryCB, self.host.whoami.bare, self.target.bare, size, True) + + def printInfo(self, msg, type_='normal', extra=None, link_cb=None): + """Print general info + @param msg: message to print + @param type_: one of: + "normal": general info like "toto has joined the room" (will be sanitized) + "link": general info that is clickable like "click here to join the main room" (no sanitize done) + "me": "/me" information like "/me clenches his fist" ==> "toto clenches his fist" (will stay on one line) + @param extra (dict): message data + @param link_cb: method to call when the info is clicked, ignored if type_ is not 'link' + """ + if extra is None: + extra = {} + if type_ == 'normal': + _wid = HTML(strings.addURLToText(html_tools.XHTML2Text(msg))) + _wid.setStyleName('chatTextInfo') + elif type_ == 'link': + _wid = HTML(msg) + _wid.setStyleName('chatTextInfo-link') + if link_cb: + _wid.addClickListener(link_cb) + elif type_ == 'me': + _wid = Label(msg) + _wid.setStyleName('chatTextMe') + else: + raise ValueError("Unknown printInfo type %s" % type_) + self.content.add(_wid) + self.content_scroll.scrollToBottom() + + def printMessage(self, from_jid, msg, extra=None, profile=C.PROF_KEY_NONE): + if extra is None: + extra = {} + try: + nick, mymess = QuickChat.printMessage(self, from_jid, msg, extra, profile) + except TypeError: + # None is returned, the message is managed + return + self.content.add(ChatText(nick, mymess, msg, extra)) + self.content_scroll.scrollToBottom() + + def setState(self, state, nick=None): + """Set the chat state (XEP-0085) of the contact. Leave nick to None + to set the state for a one2one conversation, or give a nickname or + C.ALL_OCCUPANTS to set the state of a participant within a MUC. + @param state: the new chat state + @param nick: ignored for one2one, otherwise the MUC user nick or C.ALL_OCCUPANTS + """ + return # FIXME + if self.type == C.CHAT_GROUP: + assert(nick) + if nick == C.ALL_OCCUPANTS: + occupants = self.occupants_panel.occupants_panel.keys() + else: + occupants = [nick] if nick in self.occupants_panel.occupants_panel else [] + for occupant in occupants: + self.occupants_panel.occupants_panel[occupant].setState(state) + else: + self._state = state + self.refreshTitle() + self.state_machine.started = not not state # start to send "composing" state from now + + def refreshTitle(self): + """Refresh the title of this Chat dialog""" + title = unicode(self.target.bare) + if self._state: + title += " (%s)".format(self._state) + self.setTitle(title) + + def setConnected(self, jid_s, resource, availability, priority, statuses): + """Set connection status + @param jid_s (unicode): JID userhost as unicode + """ + raise Exception("should not be there") # FIXME + assert(jid_s == self.target.bare) + if self.type != C.CHAT_GROUP: + return + box = self.occupants_panel.getOccupantBox(resource) + if box: + contact_list.setPresenceStyle(box, availability) + + def updateChatState(self, from_jid, state): + #TODO + pass + + def addGamePanel(self, widget): + """Insert a game panel to this Chat dialog. + + @param widget (Widget): the game panel + """ + self.vpanel.insert(widget, 0) + self.vpanel.setCellHeight(widget, widget.getHeight()) + + def removeGamePanel(self, widget): + """Remove the game panel from this Chat dialog. + + @param widget (Widget): the game panel + """ + self.vpanel.remove(widget) + + +quick_widgets.register(QuickChat, Chat) +quick_widgets.register(quick_games.Tarot, game_tarot.TarotPanel) +quick_widgets.register(quick_games.Radiocol, game_radiocol.RadioColPanel) +libervia_widget.LiberviaWidget.addDropKey("CONTACT", lambda host, item: host.displayWidget(Chat, jid.JID(item), dropped=True)) +quick_menus.QuickMenusManager.addDataCollector(C.MENU_ROOM, {'room_jid': 'target'}) +quick_menus.QuickMenusManager.addDataCollector(C.MENU_SINGLE, {'jid': 'target'}) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/constants.py --- a/src/browser/sat_browser/constants.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/constants.py Wed Mar 18 16:15:18 2015 +0100 @@ -35,5 +35,7 @@ ('General', C.SHOW_EMPTY_GROUPS), ] - # Empty avatar - EMPTY_AVATAR = "/media/misc/empty_avatar" + WEB_PANEL_DEFAULT_URL = "http://salut-a-toi.org" + WEB_PANEL_SCHEMES = {'http', 'https', 'ftp', 'file'} + + CONTACT_DEFAULT_DISPLAY=('bare', 'nick') diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/contact.py --- a/src/browser/sat_browser/contact.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,538 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pyjd # this is dummy in pyjs -from sat.core.log import getLogger -log = getLogger(__name__) -from pyjamas.ui.SimplePanel import SimplePanel -from pyjamas.ui.ScrollPanel import ScrollPanel -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas.ui.Label import Label -from pyjamas.ui.HTML import HTML -from pyjamas.ui.Image import Image -from pyjamas import Window -from pyjamas import DOM -from __pyjamas__ import doc - -from constants import Const as C -from jid import JID -import base_widget -import panels -import html_tools - - -def buildPresenceStyle(presence, base_style=None): - """Return the CSS classname to be used for displaying the given presence information. - @param presence (str): presence is a value in ('', 'chat', 'away', 'dnd', 'xa') - @param base_style (str): base classname - @return: str - """ - if not base_style: - base_style = "contactLabel" - return '%s-%s' % (base_style, presence or 'connected') - - -def setPresenceStyle(widget, presence, base_style=None): - """ - Set the CSS style of a contact's element according to its presence. - - @param widget (Widget): the UI element of the contact - @param presence (str): a value in ("", "chat", "away", "dnd", "xa"). - @param base_style (str): the base name of the style to apply - """ - if not hasattr(widget, 'presence_style'): - widget.presence_style = None - style = buildPresenceStyle(presence, base_style) - if style == widget.presence_style: - return - if widget.presence_style is not None: - widget.removeStyleName(widget.presence_style) - widget.addStyleName(style) - widget.presence_style = style - - -class GroupLabel(base_widget.DragLabel, Label, ClickHandler): - def __init__(self, host, group): - self.group = group - self.host = host - Label.__init__(self, group) # , Element=DOM.createElement('div') - self.setStyleName('group') - base_widget.DragLabel.__init__(self, group, "GROUP") - ClickHandler.__init__(self) - self.addClickListener(self) - - def onClick(self, sender): - self.host.getOrCreateLiberviaWidget(panels.MicroblogPanel, {'item': self.group}) - - -class ContactLabel(HTML): - def __init__(self, jid, name=None): - HTML.__init__(self) - self.name = name or jid - self.waiting = False - self.refresh() - self.setStyleName('contactLabel') - - def refresh(self): - if self.waiting: - wait_html = "(*) " - self.setHTML("%(wait)s%(name)s" % {'wait': wait_html, - 'name': html_tools.html_sanitize(self.name)}) - - def setMessageWaiting(self, waiting): - """Show a visual indicator if message are waiting - - @param waiting: True if message are waiting""" - self.waiting = waiting - self.refresh() - - -class ContactMenuBar(base_widget.WidgetMenuBar): - - def onBrowserEvent(self, event): - base_widget.WidgetMenuBar.onBrowserEvent(self, event) - event.stopPropagation() # prevent opening the chat dialog - - @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): - return '' % C.DEFAULT_AVATAR - - def setUrl(self, url): - """Set the URL of the contact avatar.""" - self.items[0].setHTML('' % url) - - -class ContactBox(VerticalPanel, ClickHandler, base_widget.DragLabel): - - def __init__(self, host, jid, name=None, click_listener=None, handle_menu=None): - VerticalPanel.__init__(self, StyleName='contactBox', VerticalAlignment='middle') - base_widget.DragLabel.__init__(self, jid, "CONTACT") - self.host = host - self.jid = jid - self.label = ContactLabel(jid, name) - self.avatar = ContactMenuBar(self, host) if handle_menu else Image() - self.updateAvatar(host.getAvatar(jid)) - self.add(self.avatar) - self.add(self.label) - if click_listener: - ClickHandler.__init__(self) - self.addClickListener(self) - self.click_listener = click_listener - - def addMenus(self, menu_bar): - menu_bar.addCachedMenus(C.MENU_ROSTER_JID_CONTEXT, {'jid': self.jid}) - menu_bar.addCachedMenus(C.MENU_JID_CONTEXT, {'jid': self.jid}) - - def setMessageWaiting(self, waiting): - """Show a visual indicator if message are waiting - - @param waiting: True if message are waiting""" - self.label.setMessageWaiting(waiting) - - def updateAvatar(self, url): - """Update the avatar. - - @param url (str): image url - """ - self.avatar.setUrl(url) - - def onClick(self, sender): - self.click_listener(self.jid) - - -class GroupList(VerticalPanel): - - def __init__(self, parent): - VerticalPanel.__init__(self) - self.setStyleName('groupList') - self._parent = parent - - def add(self, group): - _item = GroupLabel(self._parent.host, group) - _item.addMouseListener(self._parent) - DOM.setStyleAttribute(_item.getElement(), "cursor", "pointer") - index = 0 - for group_ in [child.group for child in self.getChildren()]: - if group_ > group: - break - index += 1 - VerticalPanel.insert(self, _item, index) - - def remove(self, group): - for wid in self: - if isinstance(wid, GroupLabel) and wid.group == group: - VerticalPanel.remove(self, wid) - return - - def getGroupBox(self, group): - """get the widget of a group - - @param group (str): the group - @return: GroupLabel instance if present, else None""" - for wid in self: - if isinstance(wid, GroupLabel) and wid.group == group: - return wid - return None - - -class GenericContactList(VerticalPanel): - """Class that can be used to represent a contact list, but not necessarily - the one that is displayed on the left side. Special features like popup menu - panel or changing the contact states must be done in a sub-class.""" - - def __init__(self, host, handle_click=False, handle_menu=False): - VerticalPanel.__init__(self) - self.host = host - self.contacts = [] - self.click_listener = None - self.handle_menu = handle_menu - - if handle_click: - def cb(contact_jid): - self.host.getOrCreateLiberviaWidget(panels.ChatPanel, {'item': contact_jid}) - self.click_listener = cb - - def add(self, jid_s, name=None): - """Add a contact to the list. - - @param jid (str): JID of the contact - @param name (str): optional name of the contact - """ - assert(isinstance(jid_s, str)) - if jid_s in self.contacts: - return - index = 0 - for contact_ in self.contacts: - if contact_ > jid_s: - break - index += 1 - self.contacts.insert(index, jid_s) - box = ContactBox(self.host, jid_s, name, self.click_listener, self.handle_menu) - VerticalPanel.insert(self, box, index) - - def remove(self, jid_s): - box = self.getContactBox(jid_s) - if not box: - return - VerticalPanel.remove(self, box) - self.contacts.remove(jid_s) - - def isContactPresent(self, contact_jid): - """Return True if a contact is present in the panel""" - return contact_jid in self.contacts - - def getContacts(self): - return self.contacts - - def getContactBox(self, contact_jid_s): - """get the widget of a contact - - @param contact_jid_s (str): the contact - @return: ContactBox instance if present, else None""" - for wid in self: - if isinstance(wid, ContactBox) and wid.jid == contact_jid_s: - return wid - return None - - def updateAvatar(self, jid_s, url): - """Update the avatar of the given contact - - @param jid_s (str): contact jid - @param url (str): image url - """ - try: - self.getContactBox(jid_s).updateAvatar(url) - except TypeError: - pass - - -class ContactList(GenericContactList): - """The contact list that is displayed on the left side.""" - - def __init__(self, host): - GenericContactList.__init__(self, host, handle_click=True, handle_menu=True) - - def setState(self, jid, type_, state): - """Change the appearance of the contact, according to the state - @param jid: jid which need to change state - @param type_: one of availability, messageWaiting - @param state: - - for messageWaiting type: - True if message are waiting - - for availability type: - 'unavailable' if not connected, else presence like RFC6121 #4.7.2.1""" - contact_box = self.getContactBox(jid) - if contact_box: - if type_ == 'availability': - setPresenceStyle(contact_box.label, state) - elif type_ == 'messageWaiting': - contact_box.setMessageWaiting(state) - - -class ContactTitleLabel(base_widget.DragLabel, Label, ClickHandler): - def __init__(self, host, text): - Label.__init__(self, text) # , Element=DOM.createElement('div') - self.host = host - self.setStyleName('contactTitle') - base_widget.DragLabel.__init__(self, text, "CONTACT_TITLE") - ClickHandler.__init__(self) - self.addClickListener(self) - - def onClick(self, sender): - self.host.getOrCreateLiberviaWidget(panels.MicroblogPanel, {'item': None}) - - -class ContactPanel(SimplePanel): - """Manage the contacts and groups""" - - def __init__(self, host): - SimplePanel.__init__(self) - - self.scroll_panel = ScrollPanel() - - self.host = host - self.groups = {} - self.connected = {} # jid connected as key and their status - - self.vPanel = VerticalPanel() - _title = ContactTitleLabel(host, 'Contacts') - DOM.setStyleAttribute(_title.getElement(), "cursor", "pointer") - - self._contact_list = ContactList(host) - self._contact_list.setStyleName('contactList') - self._groupList = GroupList(self) - self._groupList.setStyleName('groupList') - - self.vPanel.add(_title) - self.vPanel.add(self._groupList) - self.vPanel.add(self._contact_list) - self.scroll_panel.add(self.vPanel) - self.add(self.scroll_panel) - self.setStyleName('contactPanel') - Window.addWindowResizeListener(self) - - def onWindowResized(self, width, height): - contact_panel_elt = self.getElement() - classname = 'widgetsPanel' if isinstance(self.getParent().getParent(), panels.UniBoxPanel) else'gwt-TabBar' - _elts = doc().getElementsByClassName(classname) - if not _elts.length: - log.error("no element of class %s found, it should exist !" % classname) - tab_bar_h = height - else: - tab_bar_h = DOM.getAbsoluteTop(_elts.item(0)) or height # getAbsoluteTop can be 0 if tabBar is hidden - - ideal_height = tab_bar_h - DOM.getAbsoluteTop(contact_panel_elt) - 5 - self.scroll_panel.setHeight("%s%s" % (ideal_height, "px")) - - def updateContact(self, jid_s, attributes, groups): - """Add a contact to the panel if it doesn't exist, update it else - @param jid_s: jid userhost as unicode - @param attributes: cf SàT Bridge API's newContact - @param groups: list of groups""" - _current_groups = self.getContactGroups(jid_s) - _new_groups = set(groups) - _key = "@%s: " - - for group in _current_groups.difference(_new_groups): - # We remove the contact from the groups where he isn't anymore - self.groups[group].remove(jid_s) - if not self.groups[group]: - # The group is now empty, we must remove it - del self.groups[group] - self._groupList.remove(group) - if self.host.uni_box: - self.host.uni_box.removeKey(_key % group) - - for group in _new_groups.difference(_current_groups): - # We add the contact to the groups he joined - if group not in self.groups.keys(): - self.groups[group] = set() - self._groupList.add(group) - if self.host.uni_box: - self.host.uni_box.addKey(_key % group) - self.groups[group].add(jid_s) - - # We add the contact to contact list, it will check if contact already exists - self._contact_list.add(jid_s) - self.updateVisibility([jid_s], self.getContactGroups(jid_s)) - - def removeContact(self, jid): - """Remove contacts from groups where he is and contact list""" - self.updateContact(jid, {}, []) # we remove contact from every group - self._contact_list.remove(jid) - - def setConnected(self, jid_s, resource, availability, priority, statuses): - """Set connection status - @param jid_s (str): JID userhost as unicode - """ - if availability == 'unavailable': - if jid_s in self.connected: - if resource in self.connected[jid_s]: - del self.connected[jid_s][resource] - if not self.connected[jid_s]: - del self.connected[jid_s] - else: - if jid_s not in self.connected: - self.connected[jid_s] = {} - self.connected[jid_s][resource] = (availability, priority, statuses) - - # check if the contact is connected with another resource, use the one with highest priority - if jid_s in self.connected: - max_resource = max_priority = None - for tmp_resource in self.connected[jid_s]: - if max_priority is None or self.connected[jid_s][tmp_resource][1] >= max_priority: - max_resource = tmp_resource - max_priority = self.connected[jid_s][tmp_resource][1] - if availability == "unavailable": # do not check the priority here, because 'unavailable' has a dummy one - priority = max_priority - availability = self.connected[jid_s][max_resource][0] - if jid_s not in self.connected or priority >= max_priority: - # case 1: jid not in self.connected means all resources are disconnected, update with 'unavailable' - # case 2: update (or confirm) with the values of the resource which takes precedence - self._contact_list.setState(jid_s, "availability", availability) - - # update the connected contacts chooser live - if hasattr(self.host, "room_contacts_chooser") and self.host.room_contacts_chooser is not None: - self.host.room_contacts_chooser.resetContacts() - - self.updateVisibility([jid_s], self.getContactGroups(jid_s)) - - def setContactMessageWaiting(self, jid, waiting): - """Show an visual indicator that contact has send a message - @param jid: jid of the contact - @param waiting: True if message are waiting""" - self._contact_list.setState(jid, "messageWaiting", waiting) - - def getConnected(self, filter_muc=False): - """return a list of all jid (bare jid) connected - @param filter_muc: if True, remove the groups from the list - """ - contacts = self.connected.keys() - contacts.sort() - return contacts if not filter_muc else list(set(contacts).intersection(set(self.getContacts()))) - - def getContactGroups(self, contact_jid_s): - """Get groups where contact is - @param group: string of single group, or list of string - @param contact_jid_s: jid to test, as unicode - """ - result = set() - for group in self.groups: - if self.isContactInGroup(group, contact_jid_s): - result.add(group) - return result - - def isContactInGroup(self, group, contact_jid): - """Test if the contact_jid is in the group - @param group: string of single group, or list of string - @param contact_jid: jid to test - @return: True if contact_jid is in on of the groups""" - if group in self.groups and contact_jid in self.groups[group]: - return True - return False - - def isContactInRoster(self, contact_jid): - """Test if the contact is in our roster list""" - for contact_box in self._contact_list: - if contact_jid == contact_box.jid: - return True - return False - - def getContacts(self): - return self._contact_list.getContacts() - - def getGroups(self): - return self.groups.keys() - - def onMouseMove(self, sender, x, y): - pass - - def onMouseDown(self, sender, x, y): - pass - - def onMouseUp(self, sender, x, y): - pass - - def onMouseEnter(self, sender): - if isinstance(sender, GroupLabel): - for contact in self._contact_list: - if contact.jid in self.groups[sender.group]: - contact.label.addStyleName("selected") - - def onMouseLeave(self, sender): - if isinstance(sender, GroupLabel): - for contact in self._contact_list: - if contact.jid in self.groups[sender.group]: - contact.label.removeStyleName("selected") - - def updateAvatar(self, jid_s, url): - """Update the avatar of the given contact - - @param jid_s (str): contact jid - @param url (str): image url - """ - self._contact_list.updateAvatar(jid_s, url) - - def hasVisibleMembers(self, group): - """Tell if the given group actually has visible members - - @param group (str): the group to check - @return: boolean - """ - for jid in self.groups[group]: - if self._contact_list.getContactBox(jid).isVisible(): - return True - return False - - def offlineContactsToShow(self): - """Tell if offline contacts should be visible according to the user settings - - @return: boolean - """ - return self.host.getCachedParam('General', C.SHOW_OFFLINE_CONTACTS) == 'true' - - def emtyGroupsToShow(self): - """Tell if empty groups should be visible according to the user settings - - @return: boolean - """ - return self.host.getCachedParam('General', C.SHOW_EMPTY_GROUPS) == 'true' - - def updateVisibility(self, jids, groups): - """Set the widgets visibility for the given contacts and groups - - @param jids (list[str]): list of JID - @param groups (list[str]): list of groups - """ - for jid_s in jids: - try: - self._contact_list.getContactBox(jid_s).setVisible(jid_s in self.connected or self.offlineContactsToShow()) - except TypeError: - log.warning('No box for contact %s: this code line should not be reached' % jid_s) - for group in groups: - try: - self._groupList.getGroupBox(group).setVisible(self.hasVisibleMembers(group) or self.emtyGroupsToShow()) - except TypeError: - log.warning('No box for group %s: this code line should not be reached' % group) - - def refresh(self): - """Show or hide disconnected contacts and empty groups""" - self.updateVisibility(self._contact_list.contacts, self.groups.keys()) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/contact_group.py --- a/src/browser/sat_browser/contact_group.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/contact_group.py Wed Mar 18 16:15:18 2015 +0100 @@ -28,14 +28,28 @@ import dialog import list_manager -import contact +import contact_panel +import contact_list + + +unicode = str # FIXME: pyjamas workaround class ContactGroupManager(list_manager.ListManager): - """A manager for sub-panels to assign contacts to each group.""" - def __init__(self, parent, keys_dict, contact_list, offsets, style): - list_manager.ListManager.__init__(self, parent, keys_dict, contact_list, offsets, style) + def __init__(self, container, keys, contacts, offsets, style): + """ + @param container (FlexTable): FlexTable parent widget + @param keys (dict{unicode: dict{unicode: unicode}}): dict binding items + keys to their display config data. + @param contacts (list): list of contacts + @param offsets (dict): define widgets positions offsets within container: + - "x_first": the x offset for the first widget's row on the grid + - "x": the x offset for all widgets rows, except the first one if "x_first" is defined + - "y": the y offset for all widgets columns on the grid + @param style (dict): define CSS styles + """ + list_manager.ListManager.__init__(self, container, keys, contacts, offsets, style) self.registerPopupMenuPanel(entries={"Remove group": {}}, callback=lambda sender, key: Timer(5, lambda timer: self.removeContactKey(sender, key))) @@ -44,43 +58,51 @@ def confirm_cb(answer): if answer: - list_manager.ListManager.removeContactKey(self, key) - self._parent.removeKeyFromAddGroupPanel(key) + list_manager.ListManager.removeItemKey(self, key) + self.container.removeKeyFromAddGroupPanel(key) _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to delete the group '%s'?" % key) _dialog.show() def removeFromRemainingList(self, contacts): list_manager.ListManager.removeFromRemainingList(self, contacts) - self._parent.updateContactList(contacts=contacts) + self.container.updateContactList(contacts) def addToRemainingList(self, contacts, ignore_key=None): list_manager.ListManager.addToRemainingList(self, contacts, ignore_key) - self._parent.updateContactList(contacts=contacts) + self.container.updateContactList(contacts) class ContactGroupEditor(DockPanel): - """Panel for the contact groups manager.""" + """A big panel including a ContactGroupManager and other UI stuff.""" + + def __init__(self, host, container=None, onCloseCallback=None): + """ - def __init__(self, host, parent=None, onCloseCallback=None): + @param host (SatWebFrontend) + @param container (PanelBase): parent panel or None to display in a popup + @param onCloseCallback (callable) + """ DockPanel.__init__(self) self.host = host # eventually display in a popup - if parent is None: - parent = DialogBox(autoHide=False, centered=True) - parent.setHTML("Manage contact groups") - self._parent = parent + if container is None: + container = DialogBox(autoHide=False, centered=True) + container.setHTML("Manage contact groups") + self.container = container self._on_close_callback = onCloseCallback - self.all_contacts = self.host.contact_panel.getContacts() - groups_list = self.host.contact_panel.groups.keys() - groups_list.sort() + self.all_contacts = contact_list.JIDList(self.host.contact_list.roster_entities) + roster_entities_by_group = self.host.contact_list.roster_entities_by_group + del roster_entities_by_group[None] # remove the empty group + roster_groups = roster_entities_by_group.keys() + roster_groups.sort() - self.add_group_panel = self.getAddGroupPanel(groups_list) - south_panel = self.getCloseSaveButtons() - center_panel = self.getContactGroupManager(groups_list) - east_panel = self.getContactList() + self.add_group_panel = self.initAddGroupPanel(roster_groups) + south_panel = self.initCloseSaveButtons() + center_panel = self.initContactGroupManager(roster_groups) + east_panel = self.initContactList() self.add(self.add_group_panel, DockPanel.CENTER) self.add(east_panel, DockPanel.EAST) @@ -97,108 +119,123 @@ self.setCellHorizontalAlignment(south_panel, HasAlignment.ALIGN_CENTER) # need to be done after the contact list has been initialized - self.groups.setContacts(self.host.contact_panel.groups) + self.groups.resetItems(roster_entities_by_group) self.toggleContacts(showAll=True) # Hide the contacts list from the main panel to not confuse the user self.restore_contact_panel = False - if self.host.contact_panel.getVisible(): + clist = self.host.contact_list + if clist.getVisible(): self.restore_contact_panel = True self.host.panel._contactsSwitch() - parent.add(self) - parent.setVisible(True) - if isinstance(parent, DialogBox): - parent.center() + container.add(self) + container.setVisible(True) + if isinstance(container, DialogBox): + container.center() - def getContactGroupManager(self, groups_list): - """Set the list manager for the groups""" - flex_table = FlexTable(len(groups_list), 2) + def initContactGroupManager(self, groups): + """Initialise the contact group manager. + + @param groups (list[unicode]): contact groups + """ + flex_table = FlexTable() flex_table.addStyleName('contactGroupEditor') + # overwrite the default style which has been set for rich text editor - style = { - "keyItem": "group", - "popupMenuItem": "popupMenuItem", - "removeButton": "contactGroupRemoveButton", - "buttonCell": "contactGroupButtonCell", - "keyPanel": "contactGroupPanel" - } - self.groups = ContactGroupManager(flex_table, groups_list, self.all_contacts, style=style) - self.groups.createWidgets() # widgets are automatically added to FlexTable + style = {"keyItem": "group", + "popupMenuItem": "popupMenuItem", + "removeButton": "contactGroupRemoveButton", + "buttonCell": "contactGroupButtonCell", + "keyPanel": "contactGroupPanel" + } + + groups = {group: {} for group in groups} + self.groups = ContactGroupManager(flex_table, groups, self.all_contacts, style=style) + self.groups.createWidgets() # widgets are automatically added to the FlexTable + # FIXME: clean that part which is dangerous flex_table.updateContactList = self.updateContactList flex_table.removeKeyFromAddGroupPanel = self.add_group_panel.groups.remove + return flex_table - def getAddGroupPanel(self, groups_list): - """Add the 'Add group' panel to the FlexTable""" + def initAddGroupPanel(self, groups): + """Initialise the 'Add group' panel. - def add_group_cb(text): - self.groups.addContactKey(text) + @param groups (list[unicode]): contact groups + """ + + def add_group_cb(key): + self.groups.addItemKey(key) self.add_group_panel.textbox.setFocus(True) - add_group_panel = dialog.AddGroupPanel(groups_list, add_group_cb) + add_group_panel = dialog.AddGroupPanel(groups, add_group_cb) add_group_panel.addStyleName("addContactGroupPanel") return add_group_panel - def getCloseSaveButtons(self): - """Add the buttons to close the dialog / save the groups""" + def initCloseSaveButtons(self): + """Add the buttons to close the dialog and save the groups.""" buttons = HorizontalPanel() buttons.addStyleName("marginAuto") buttons.add(Button("Save", listener=self.closeAndSave)) buttons.add(Button("Cancel", listener=self.cancelWithoutSaving)) return buttons - def getContactList(self): - """Add the contact list to the DockPanel""" + def initContactList(self): + """Add the contact list to the DockPanel.""" self.toggle = Button("", self.toggleContacts) self.toggle.addStyleName("toggleAssignedContacts") - self.contacts = contact.GenericContactList(self.host) - for contact_ in self.all_contacts: - self.contacts.add(contact_) - contact_panel = VerticalPanel() - contact_panel.add(self.toggle) - contact_panel.add(self.contacts) - return contact_panel + self.contacts = contact_panel.ContactsPanel(self.host) + for contact in self.all_contacts: + self.contacts.add(contact) + panel = VerticalPanel() + panel.add(self.toggle) + panel.add(self.contacts) + return panel def toggleContacts(self, sender=None, showAll=None): - """Callback for the toggle button""" - if sender is None: - sender = self.toggle - sender.showAll = showAll if showAll is not None else not sender.showAll - if sender.showAll: - sender.setText("Hide assigned") - else: - sender.setText("Show assigned") - self.updateContactList(sender) + """Toggle the button to show contacts and the contact list. - def updateContactList(self, sender=None, contacts=None): - """Update the contact list regarding the toggle button""" + @param sender (Button) + @param showAll (bool): if set, initialise with True to show all contacts + or with False to show only the ones that are not assigned yet. + """ + self.toggle.showAll = (not self.toggle.showAll) if showAll is None else showAll + self.toggle.setText("Hide assigned" if self.toggle.showAll else "Show assigned") + self.updateContactList() + + def updateContactList(self, contacts=None): + """Update the contact list's items visibility, depending of the toggle + button and the "contacts" attribute. + + @param contacts (list): contacts to be updated, or None to update all. + """ if not hasattr(self, "toggle") or not hasattr(self.toggle, "showAll"): return - sender = self.toggle if contacts is not None: - if not isinstance(contacts, list): - contacts = [contacts] - for contact_ in contacts: - if contact_ not in self.all_contacts: - contacts.remove(contact_) + to_remove = set() + for contact in contacts: + if contact not in self.all_contacts: + to_remove.add(contact) + for contact in to_remove: + contacts.remove(contact) else: contacts = self.all_contacts - for contact_ in contacts: - if sender.showAll: - self.contacts.getContactBox(contact_).setVisible(True) + for contact in contacts: + if self.toggle.showAll: + self.contacts.getContactBox(contact).setVisible(True) else: - if contact_ in self.groups.remaining_list: - self.contacts.getContactBox(contact_).setVisible(True) + if contact in self.groups.items_remaining: + self.contacts.getContactBox(contact).setVisible(True) else: - self.contacts.getContactBox(contact_).setVisible(False) + self.contacts.getContactBox(contact).setVisible(False) def __close(self): """Remove the widget from parent or close the popup.""" - if isinstance(self._parent, DialogBox): - self._parent.hide() - self._parent.remove(self) + if isinstance(self.container, DialogBox): + self.container.hide() + self.container.remove(self) if self._on_close_callback is not None: self._on_close_callback() if self.restore_contact_panel: @@ -215,22 +252,21 @@ def closeAndSave(self): """Call bridge methods to save the changes and close the dialog""" - map_ = {} - for contact_ in self.all_contacts: - map_[contact_] = set() - contacts = self.groups.getContacts() - for group in contacts.keys(): - for contact_ in contacts[group]: - try: - map_[contact_].add(group) - except KeyError: - dialog.InfoDialog("Invalid contact", - "The contact '%s' is not your contact list but it has been assigned to the group '%s'." % (contact_, group) + - "Your changes could not be saved: please check your assignments and save again.", Width="400px").center() - return - for contact_ in map_.keys(): - groups = map_[contact_] - current_groups = self.host.contact_panel.getContactGroups(contact_) - if groups != current_groups: - self.host.bridge.call('updateContact', None, contact_, '', list(groups)) + old_groups_by_entity = contact_list.JIDDict(self.host.contact_list.roster_groups_by_entity) + old_entities = old_groups_by_entity.keys() + groups_by_entity = contact_list.JIDDict(self.groups.getKeysByItem()) + entities = groups_by_entity.keys() + + for invalid in entities.difference(self.all_contacts): + dialog.InfoDialog("Invalid contact(s)", + "The contact '%s' is not in your contact list but has been assigned to: '%s'." % (invalid, "', '".join(groups_by_entity[invalid])) + + "Your changes could not be saved: please check your assignments and save again.", Width="400px").center() + return + + for entity in old_entities.difference(entities): + self.host.bridge.call('updateContact', None, unicode(entity), '', []) + + for entity, groups in groups_by_entity.iteritems(): + if entity not in old_groups_by_entity or groups != old_groups_by_entity[entity]: + self.host.bridge.call('updateContact', None, unicode(entity), '', list(groups)) self.__close() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/contact_list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/contact_list.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,476 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) +from sat_frontends.quick_frontend.quick_contact_list import QuickContactList +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.ScrollPanel import ScrollPanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.Label import Label +from pyjamas import Window +from pyjamas import DOM + +from constants import Const as C +import libervia_widget +import contact_panel +import blog +import chat + +unicode = str # XXX: pyjama doesn't manage unicode + + +def buildPresenceStyle(presence, base_style=None): + """Return the CSS classname to be used for displaying the given presence information. + + @param presence (unicode): presence is a value in ('', 'chat', 'away', 'dnd', 'xa') + @param base_style (unicode): base classname + @return: unicode + """ + if not base_style: + base_style = "contactLabel" + return '%s-%s' % (base_style, presence or 'connected') + + +def setPresenceStyle(widget, presence, base_style=None): + """ + Set the CSS style of a contact's element according to its presence. + + @param widget (Widget): the UI element of the contact + @param presence (unicode): a value in ("", "chat", "away", "dnd", "xa"). + @param base_style (unicode): the base name of the style to apply + """ + if not hasattr(widget, 'presence_style'): + widget.presence_style = None + style = buildPresenceStyle(presence, base_style) + if style == widget.presence_style: + return + if widget.presence_style is not None: + widget.removeStyleName(widget.presence_style) + widget.addStyleName(style) + widget.presence_style = style + + +class GroupLabel(libervia_widget.DragLabel, Label, ClickHandler): + def __init__(self, host, group): + """ + + @param host (SatWebFrontend) + @param group (unicode): group name + """ + self.group = group + Label.__init__(self, group) # , Element=DOM.createElement('div') + self.setStyleName('group') + libervia_widget.DragLabel.__init__(self, group, "GROUP", host) + ClickHandler.__init__(self) + self.addClickListener(self) + + def onClick(self, sender): + self.host.displayWidget(blog.MicroblogPanel, (self.group,)) + + +class GroupPanel(VerticalPanel): + + def __init__(self, parent): + VerticalPanel.__init__(self) + self.setStyleName('groupPanel') + self._parent = parent + self._groups = set() + + def add(self, group): + if group in self._groups: + log.warning("trying to add an already existing group") + return + _item = GroupLabel(self._parent.host, group) + _item.addMouseListener(self._parent) + DOM.setStyleAttribute(_item.getElement(), "cursor", "pointer") + index = 0 + for group_ in [child.group for child in self.getChildren()]: + if group_ > group: + break + index += 1 + VerticalPanel.insert(self, _item, index) + self._groups.add(group) + + def remove(self, group): + for wid in self: + if isinstance(wid, GroupLabel) and wid.group == group: + VerticalPanel.remove(self, wid) + self._groups.remove(group) + return + log.warning("Trying to remove a non existent group") + + def getGroupBox(self, group): + """get the widget of a group + + @param group (unicode): the group + @return: GroupLabel instance if present, else None""" + for wid in self: + if isinstance(wid, GroupLabel) and wid.group == group: + return wid + return None + + def getGroups(self): + return self._groups + + +class ContactsPanel(contact_panel.ContactsPanel): + """The contact list that is displayed on the left side.""" + + def __init__(self, host): + + def on_click(contact_jid): + self.host.displayWidget(chat.Chat, contact_jid, type_=C.CHAT_ONE2ONE) + + contact_panel.ContactsPanel.__init__(self, host, contacts_click=on_click, + contacts_menus=(C.MENU_JID_CONTEXT, C.MENU_ROSTER_JID_CONTEXT)) + + def setState(self, jid_, type_, state): + """Change the appearance of the contact, according to the state + + @param jid_ (jid.JID): jid.JID which need to change state + @param type_ (unicode): one of "availability", "messageWaiting" + @param state: + - for messageWaiting type: + True if message are waiting + - for availability type: + C.PRESENCE_UNAVAILABLE or None if not connected, else presence like RFC6121 #4.7.2.1""" + assert type_ in ('availability', 'messageWaiting') + contact_box = self.getContactBox(jid_) + if type_ == 'availability': + if state is None: + state = C.PRESENCE_UNAVAILABLE + setPresenceStyle(contact_box.label, state) + elif type_ == 'messageWaiting': + contact_box.setAlert(state) + + +class ContactTitleLabel(libervia_widget.DragLabel, Label, ClickHandler): + + def __init__(self, host, text): + Label.__init__(self, text) # , Element=DOM.createElement('div') + self.setStyleName('contactTitle') + libervia_widget.DragLabel.__init__(self, text, "CONTACT_TITLE", host) + ClickHandler.__init__(self) + self.addClickListener(self) + + def onClick(self, sender): + self.host.displayWidget(blog.MicroblogPanel, ()) + + +class ContactList(SimplePanel, QuickContactList): + """Manage the contacts and groups""" + + def __init__(self, host): + QuickContactList.__init__(self, host, C.PROF_KEY_NONE) + SimplePanel.__init__(self) + self.host = host + self.scroll_panel = ScrollPanel() + self.vPanel = VerticalPanel() + _title = ContactTitleLabel(host, 'Contacts') + DOM.setStyleAttribute(_title.getElement(), "cursor", "pointer") + self._contacts_panel = ContactsPanel(host) + self._contacts_panel.setStyleName('contactPanel') # FIXME: style doesn't exists ! + self._group_panel = GroupPanel(self) + + self.vPanel.add(_title) + self.vPanel.add(self._group_panel) + self.vPanel.add(self._contacts_panel) + self.scroll_panel.add(self.vPanel) + self.add(self.scroll_panel) + self.setStyleName('contactList') + Window.addWindowResizeListener(self) + + # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) + self.avatarListener = self.onAvatarUpdate + host.addListener('avatar', self.avatarListener, [C.PROF_KEY_NONE]) + + @property + def profile(self): + return C.PROF_KEY_NONE + + def onDelete(self): + QuickContactList.onDelete(self) + self.host.removeListener('avatar', self.avatarListener) + + def update(self): + ### GROUPS ### + _keys = self._groups.keys() + try: + # XXX: Pyjamas doesn't do the set casting if None is present + _keys.remove(None) + except (KeyError, ValueError): # XXX: error raised depend on pyjama's compilation options + pass + current_groups = set(_keys) + shown_groups = self._group_panel.getGroups() + new_groups = current_groups.difference(shown_groups) + removed_groups = shown_groups.difference(current_groups) + for group in new_groups: + self._group_panel.add(group) + for group in removed_groups: + self._group_panel.remove(group) + + ### JIDS ### + to_show = [jid_ for jid_ in self.roster_entities if self.entityToShow(jid_) and jid_ != self.whoami.bare] + to_show.sort() + + self._contacts_panel.setList(to_show) + + for jid_ in self._alerts: + self._contacts_panel.setState(jid_, "messageWaiting", True) + + def remove(self, entity): + # FIXME: SimplePanel and QuickContactList both have a 'remove' method + QuickContactList.remove(self, entity) + + def onWindowResized(self, width, height): + ideal_height = height - DOM.getAbsoluteTop(self.getElement()) - 5 + tab_panel = self.host.panel.tab_panel + if tab_panel.getWidgetCount() > 1: + ideal_height -= tab_panel.getTabBar().getOffsetHeight() + self.scroll_panel.setHeight("%s%s" % (ideal_height, "px")) + + # def updateContact(self, jid_s, attributes, groups): + # """Add a contact to the panel if it doesn't exist, update it else + + # @param jid_s: jid userhost as unicode + # @param attributes: cf SàT Bridge API's newContact + # @param groups: list of groups""" + # _current_groups = self.getContactGroups(jid_s) + # _new_groups = set(groups) + # _key = "@%s: " + + # for group in _current_groups.difference(_new_groups): + # # We remove the contact from the groups where he isn't anymore + # self.groups[group].remove(jid_s) + # if not self.groups[group]: + # # The group is now empty, we must remove it + # del self.groups[group] + # self._group_panel.remove(group) + # if self.host.uni_box: + # self.host.uni_box.removeKey(_key % group) + + # for group in _new_groups.difference(_current_groups): + # # We add the contact to the groups he joined + # if group not in self.groups.keys(): + # self.groups[group] = set() + # self._group_panel.add(group) + # if self.host.uni_box: + # self.host.uni_box.addKey(_key % group) + # self.groups[group].add(jid_s) + + # # We add the contact to contact list, it will check if contact already exists + # self._contacts_panel.add(jid_s) + # self.updateVisibility([jid_s], self.getContactGroups(jid_s)) + + # def removeContact(self, jid): + # """Remove contacts from groups where he is and contact list""" + # self.updateContact(jid, {}, []) # we remove contact from every group + # self._contacts_panel.remove(jid) + + # def setConnected(self, jid_s, resource, availability, priority, statuses): + # """Set connection status + # @param jid_s (unicode): JID userhost as unicode + # """ + # if availability == 'unavailable': + # if jid_s in self.connected: + # if resource in self.connected[jid_s]: + # del self.connected[jid_s][resource] + # if not self.connected[jid_s]: + # del self.connected[jid_s] + # else: + # if jid_s not in self.connected: + # self.connected[jid_s] = {} + # self.connected[jid_s][resource] = (availability, priority, statuses) + + # # check if the contact is connected with another resource, use the one with highest priority + # if jid_s in self.connected: + # max_resource = max_priority = None + # for tmp_resource in self.connected[jid_s]: + # if max_priority is None or self.connected[jid_s][tmp_resource][1] >= max_priority: + # max_resource = tmp_resource + # max_priority = self.connected[jid_s][tmp_resource][1] + # if availability == "unavailable": # do not check the priority here, because 'unavailable' has a dummy one + # priority = max_priority + # availability = self.connected[jid_s][max_resource][0] + # if jid_s not in self.connected or priority >= max_priority: + # # case 1: jid not in self.connected means all resources are disconnected, update with 'unavailable' + # # case 2: update (or confirm) with the values of the resource which takes precedence + # self._contacts_panel.setState(jid_s, "availability", availability) + + # self.updateVisibility([jid_s], self.getContactGroups(jid_s)) + + def setContactMessageWaiting(self, jid, waiting): + """Show a visual indicator that contact has send a message + + @param jid: jid of the contact + @param waiting: True if message are waiting""" + raise Exception("Should not be there") + # self._contacts_panel.setState(jid, "messageWaiting", waiting) + + # def getConnected(self, filter_muc=False): + # """return a list of all jid (bare jid) connected + # @param filter_muc: if True, remove the groups from the list + # """ + # contacts = self.connected.keys() + # contacts.sort() + # return contacts if not filter_muc else list(set(contacts).intersection(set(self.getContacts()))) + + # def getContactGroups(self, contact_jid_s): + # """Get groups where contact is + # @param group: string of single group, or list of string + # @param contact_jid_s: jid to test, as unicode + # """ + # result = set() + # for group in self.groups: + # if self.isContactInGroup(group, contact_jid_s): + # result.add(group) + # return result + + # def isContactInGroup(self, group, contact_jid): + # """Test if the contact_jid is in the group + # @param group: string of single group, or list of string + # @param contact_jid: jid to test + # @return: True if contact_jid is in on of the groups""" + # if group in self.groups and contact_jid in self.groups[group]: + # return True + # return False + + def isContactInRoster(self, contact_jid): + """Test if the contact is in our roster list""" + for contact_box in self._contacts_panel: + if contact_jid == contact_box.jid: + return True + return False + + # def getContacts(self): + # return self._contacts_panel.getContacts() + + def getGroups(self): + return self.groups.keys() + + def onMouseMove(self, sender, x, y): + pass + + def onMouseDown(self, sender, x, y): + pass + + def onMouseUp(self, sender, x, y): + pass + + def onMouseEnter(self, sender): + if isinstance(sender, GroupLabel): + jids = self.getGroupData(sender.group, "jids") + for contact in self._contacts_panel: + if contact.jid in jids: + contact.label.addStyleName("selected") + + def onMouseLeave(self, sender): + if isinstance(sender, GroupLabel): + jids = self.getGroupData(sender.group, "jids") + for contact in self._contacts_panel: + if contact.jid in jids: + contact.label.removeStyleName("selected") + + def onAvatarUpdate(self, jid_, hash_, profile): + """Called on avatar update events + + @param jid_: jid of the entity with updated avatar + @param hash_: hash of the avatar + @param profile: %(doc_profile)s + """ + self._contacts_panel.updateAvatar(jid_, self.host.getAvatarURL(jid_)) + + def onNickUpdate(self, jid_, new_nick, profile): + self._contacts_panel.updateNick(jid_, new_nick) + + def hasVisibleMembers(self, group): + """Tell if the given group actually has visible members + + @param group (unicode): the group to check + @return: boolean + """ + raise Exception # FIXME: remove this method + for jid_ in self.groups[group]: + if self._contacts_panel.getContactBox(jid_).isVisible(): + return True + return False + + def offlineContactsToShow(self): + """Tell if offline contacts should be visible according to the user settings + + @return: boolean + """ + return C.bool(self.host.getCachedParam('General', C.SHOW_OFFLINE_CONTACTS)) + + def emtyGroupsToShow(self): + """Tell if empty groups should be visible according to the user settings + + @return: boolean + """ + return C.bool(self.host.getCachedParam('General', C.SHOW_EMPTY_GROUPS)) + + def onPresenceUpdate(self, entity, show, priority, statuses, profile): + QuickContactList.onPresenceUpdate(self, entity, show, priority, statuses, profile) + entity_bare = entity.bare + show = self.getCache(entity_bare, C.PRESENCE_SHOW) # we use cache to have the show nformation of main resource only + self._contacts_panel.setState(entity_bare, "availability", show) + self.update() # FIXME: should update the list without rebuilding it all + + # def updateVisibility(self, jids, groups): + # """Set the widgets visibility for the given contacts and groups + + # @param jids (list[unicode]): list of JID + # @param groups (list[unicode]): list of groups + # """ + # for jid_s in jids: + # try: + # self._contacts_panel.getContactBox(jid_s).setVisible(jid_s in self.connected or self.offlineContactsToShow()) + # except TypeError: + # log.warning('No box for contact %s: this code line should not be reached' % jid_s) + # for group in groups: + # try: + # self._group_panel.getGroupBox(group).setVisible(self.hasVisibleMembers(group) or self.emtyGroupsToShow()) + # except TypeError: + # log.warning('No box for group %s: this code line should not be reached' % group) + + # def refresh(self): + # """Show or hide disconnected contacts and empty groups""" + # self.updateVisibility(self._contacts_panel.contacts, self.groups.keys()) + + +class JIDList(list): + """JID-friendly list implementation for Pyjamas""" + + def __contains__(self, item): + """Tells if the list contains the given item. + + @param item (object): element to check + @return: bool + """ + # Since our JID doesn't inherit from str/unicode, without this method + # the test would return True only when the objects references are the + # same. Tests have shown that the other iterable "set" and "dict" don't + # need this hack to reproduce the Twisted's behavior. + for other in self: + if other == item: + return True + return False diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/contact_panel.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/contact_panel.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,247 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" Contacts / jids related panels """ + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) +from sat_frontends.tools import jid + +from pyjamas.ui.AbsolutePanel import AbsolutePanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HTML import HTML + +import html_tools +import contact_widget +from constants import Const as C + + +# FIXME: must be removed +class Occupant(HTML): + """Occupant of a MUC room""" + + def __init__(self, nick, state=None, special=""): + """ + @param nick: the user nickname + @param state: the user chate state (XEP-0085) + @param special: a string of symbols (e.g: for activities) + """ + HTML.__init__(self, StyleName="occupant") + self.nick = nick + self._state = state + self.special = special + self._refresh() + + def __str__(self): + return self.nick + + def setState(self, state): + self._state = state + self._refresh() + + def addSpecial(self, special): + """@param special: unicode""" + if special not in self.special: + self.special += special + self._refresh() + + def removeSpecials(self, special): + """@param special: unicode or list""" + if not isinstance(special, list): + special = [special] + for symbol in special: + self.special = self.special.replace(symbol, "") + self._refresh() + + def _refresh(self): + state = (' %s' % C.MUC_USER_STATES[self._state]) if self._state else '' + special = "" if len(self.special) == 0 else " %s" % self.special + self.setHTML("%s%s%s" % (html_tools.html_sanitize(self.nick), special, state)) + + +class ContactsPanel(VerticalPanel): + """ContactList graphic representation + + Special features like popup menu panel or changing the contact states must be done in a sub-class. + """ + + def __init__(self, host, merge_resources=True, contacts_click=None, + contacts_style=None, contacts_menus=True, + contacts_display=C.CONTACT_DEFAULT_DISPLAY): + """ + + @param host (SatWebFrontend): host instance + @param merge_resources (bool): if True, the entities sharing the same + bare JID will also share the same contact box. + @param contacts_click (callable): click callback for the contact boxes + @param contacts_style (unicode): CSS style name for the contact boxes + @param contacts_menus (tuple): define the menu types that fit this + contact panel, with values from the menus type constants. + @param contacts_display (tuple): prioritize the display methods of the + contact's label with values in ("jid", "nick", "bare", "resource") + """ + VerticalPanel.__init__(self) + self.host = host + self.merge_resources = merge_resources + self._contacts = {} # entity jid to ContactBox map + self.click_listener = None + + if contacts_click is not None: + self.onClick = contacts_click + + self.contacts_style = contacts_style + self.contacts_menus = contacts_menus + self.contacts_display = contacts_display + + def _key(self, contact_jid): + """Return internal key for this contact. + + @param contact_jid (jid.JID): contact JID + @return: jid.JID + """ + return contact_jid.bare if self.merge_resources else contact_jid + + def setList(self, jids): + """set all contacts in the list in one shot. + + @param jids (list[jid.JID]): jids to display (the order is kept) + @param name (unicode): optional name of the contact + """ + # FIXME: we do a full clear and add boxes after, we should only remove recently hidden boxes and add new ones, and re-order + current = [box.jid for box in self.children if isinstance(box, contact_widget.ContactBox)] + if current == jids: + # the display doesn't change + return + self.clear() + for contact_jid in jids: + assert isinstance(contact_jid, jid.JID) + self.addContact(contact_jid) + + def getContactBox(self, contact_jid): + """Get a contact box for a contact, add it if it doesn't exist yet. + + @param contact_jid (jid.JID): contact JID + @return: ContactBox + """ + try: + return self._contacts[self._key(contact_jid)] + except KeyError: + box = contact_widget.ContactBox(self.host, contact_jid, + style_name=self.contacts_style, + display=self.contacts_display, + plugin_menu_context=self.contacts_menus) + self._contacts[self._key(contact_jid)] = box + return box + + def addContact(self, contact_jid): + """Add a contact to the list. + + @param contact_jid (jid.JID): contact JID + """ + box = self.getContactBox(contact_jid) + if box not in self.children: + VerticalPanel.append(self, box) + + def removeContact(self, contact_jid): + """Remove a contact from the list. + + @param contact_jid (jid.JID): contact JID + """ + box = self._contacts.pop(self._key(contact_jid)) + VerticalPanel.remove(self, box) + + def updateAvatar(self, contact_jid, url): + """Update the avatar of the given contact + + @param contact_jid (jid.JID): contact JID + @param url (unicode): image url + """ + try: + self.getContactBox(contact_jid).updateAvatar(url) + except TypeError: + pass + + def updateNick(self, contact_jid, new_nick): + """Update the avatar of the given contact. + + @param contact_jid (jid.JID): contact JID + @param new_nick (unicode): new nick of the contact + """ + try: + self.getContactBox(contact_jid).updateNick(new_nick) + except TypeError: + pass + + + +# FIXME: must be removed and ContactsPanel must be used instead +class OccupantsList(AbsolutePanel): + """Panel user to show occupants of a room""" + + def __init__(self): + AbsolutePanel.__init__(self) + self.occupants_list = {} + self.setStyleName('occupantsList') + + def addOccupant(self, nick): + if nick in self.occupants_list: + return + _occupant = Occupant(nick) + self.occupants_list[nick] = _occupant + self.add(_occupant) + + def removeOccupant(self, nick): + try: + self.remove(self.occupants_list[nick]) + except KeyError: + log.error("trying to remove an unexisting nick") + + def getOccupantBox(self, nick): + """Get the widget element of the given nick. + + @return: Occupant + """ + try: + return self.occupants_list[nick] + except KeyError: + return None + + def clear(self): + self.occupants_list.clear() + AbsolutePanel.clear(self) + + def updateSpecials(self, occupants=[], html=""): + """Set the specified html "symbol" to the listed occupants, + and eventually remove it from the others (if they got it). + This is used for example to visualize who is playing a game. + @param occupants: list of the occupants that need the symbol + @param html: unicode symbol (actually one character or more) + or a list to assign different symbols of the same family. + """ + index = 0 + special = html + for occupant in self.occupants_list.keys(): + if occupant in occupants: + if isinstance(html, list): + special = html[index] + index = (index + 1) % len(html) + self.occupants_list[occupant].addSpecial(special) + else: + self.occupants_list[occupant].removeSpecials(html) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/contact_widget.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/contact_widget.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,172 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core import exceptions +from sat_frontends.quick_frontend import quick_menus +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HTML import HTML +from pyjamas.ui.Image import Image +from pyjamas.ui.ClickListener import ClickHandler +from constants import Const as C +import html_tools +import base_widget +import libervia_widget + +unicode = str # XXX: pyjama doesn't manage unicode + + +class ContactLabel(HTML): + """Display a contact in HTML, selecting best display (jid/nick/etc)""" + + def __init__(self, host, jid_, display=C.CONTACT_DEFAULT_DISPLAY): + """ + + @param host (SatWebFrontend): host instance + @param jid_ (jid.JID): contact JID + @param display (tuple): prioritize the display methods of the contact's + label with values in ("jid", "nick", "bare", "resource"). + """ + # TODO: add a listener for nick changes + HTML.__init__(self) + self.host = host + self.jid = jid_ + if "nick" in display: + self.nick = self.host.contact_lists[C.PROF_KEY_NONE].getCache(self.jid, "nick") + self.display = display + self.alert = False + self.refresh() + self.setStyleName('contactLabel') + + def refresh(self): + alert_html = "(*) " if self.alert else "" + contact_raw = None + for disp in self.display: + if disp == "jid": + contact_raw = unicode(self.jid) + elif disp == "nick": + contact_raw = self.nick + elif disp == "bare": + contact_raw = unicode(self.jid.bare) + elif disp == "resource": + contact_raw = self.jid.resource + else: + raise exceptions.InternalError(u"Unknown display argument [{}]".format(disp)) + if contact_raw: + break + if not contact_raw: + log.error(u"Counld not find a contact display for jid {jid} (display: {display})".format(jid=self.jid, display=self.display)) + contact_raw = "UNNAMED" + contact_html = html_tools.html_sanitize(contact_raw) + html = "%(alert)s%(contact)s" % {'alert': alert_html, + 'contact': contact_html} + self.setHTML(html) + + def updateNick(self, new_nick): + """Change the current nick + + @param new_nick(unicode): new nick to use + """ + self.nick = new_nick + self.refresh() + + def setAlert(self, alert): + """Show a visual indicator + + @param alert: True if alert must be shown + """ + self.alert = alert + self.refresh() + + +class ContactMenuBar(base_widget.WidgetMenuBar): + + def onBrowserEvent(self, event): + base_widget.WidgetMenuBar.onBrowserEvent(self, event) + event.stopPropagation() # prevent opening the chat dialog + + @classmethod + def getCategoryHTML(cls, menu_name_i18n, type_): + return '' % C.DEFAULT_AVATAR_URL + + def setUrl(self, url): + """Set the URL of the contact avatar.""" + self.items[0].setHTML('' % url) + + +class ContactBox(VerticalPanel, ClickHandler, libervia_widget.DragLabel): + + def __init__(self, host, jid_, style_name=None, display=C.CONTACT_DEFAULT_DISPLAY, plugin_menu_context=None): + """ + @param host (SatWebFrontend): host instance + @param jid_ (jid.JID): contact JID + @param style_name (unicode): CSS style name + @param contacts_display (tuple): prioritize the display methods of the + contact's label with values in ("jid", "nick", "bare", "resource"). + @param plugin_menu_context (iterable): contexts of menus to have (list of C.MENU_* constant) + + """ + self.plugin_menu_context = [] if plugin_menu_context is None else plugin_menu_context + VerticalPanel.__init__(self, StyleName=style_name or 'contactBox', VerticalAlignment='middle') + ClickHandler.__init__(self) + libervia_widget.DragLabel.__init__(self, jid_, "CONTACT", host) + self.jid = jid_ + self.label = ContactLabel(host, self.jid, display=display) + self.avatar = ContactMenuBar(self, host) if plugin_menu_context else Image() + try: # FIXME: dirty hack to force using an Image when the menu is actually empty + self.avatar.items[0] + except IndexError: + self.avatar = Image() + self.updateAvatar(host.getAvatarURL(self.jid.bare)) + self.add(self.avatar) + self.add(self.label) + self.addClickListener(self) + + def setAlert(self, alert): + """Show a visual indicator + + @param alert: True if alert indicator show be shown""" + self.label.setAlert(alert) + + def updateAvatar(self, url): + """Update the avatar. + + @param url (unicode): image url + """ + self.avatar.setUrl(url) + + def updateNick(self, new_nick): + """Update the nickname. + + @param new_nick (unicode): new nickname to use + """ + self.label.updateNick(new_nick) + + def onClick(self, sender): + try: + self.parent.onClick(self.jid.bare) + except (AttributeError, TypeError): + pass + else: + self.setAlert(False) + +quick_menus.QuickMenusManager.addDataCollector(C.MENU_JID_CONTEXT, lambda caller, dummy: {'jid': unicode(caller.jid.bare)}) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/dialog.py --- a/src/browser/sat_browser/dialog.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/dialog.py Wed Mar 18 16:15:18 2015 +0100 @@ -19,7 +19,10 @@ from sat.core.log import getLogger log = getLogger(__name__) + +from constants import Const as C from sat_frontends.tools.misc import DEFAULT_MUC +from sat_frontends.tools import jid from pyjamas.ui.VerticalPanel import VerticalPanel from pyjamas.ui.Grid import Grid @@ -37,19 +40,26 @@ from pyjamas.ui.MouseListener import MouseWheelHandler from pyjamas import Window -import base_panels +import base_panel # List here the patterns that are not allowed in contact group names FORBIDDEN_PATTERNS_IN_GROUP = () +unicode = str # XXX: pyjama doesn't manage unicode + + class RoomChooser(Grid): """Select a room from the rooms you already joined, or create a new one""" GENERATE_MUC = "" def __init__(self, host, default_room=DEFAULT_MUC): + """ + + @param host (SatWebFrontend) + """ Grid.__init__(self, 2, 2, Width='100%') self.host = host @@ -70,7 +80,19 @@ self.exist_radio.setVisible(False) self.rooms_list.setVisible(False) - self.setRooms() + self.refreshOptions() + + @property + def room(self): + """Get the room that has been selected or entered by the user + + @return: jid.JID or None to let the backend generate a new name + """ + if self.exist_radio.getChecked(): + values = self.rooms_list.getSelectedValues() + return jid.JID(values[0]) if values else None + value = self.box.getText() + return None if value in ('', self.GENERATE_MUC) else jid.JID(value) def onFocus(self, sender): if sender == self.rooms_list: @@ -85,21 +107,17 @@ if self.box.getText() == "": self.box.setText(self.GENERATE_MUC) - def setRooms(self): - for room in self.host.room_list: + def refreshOptions(self): + """Refresh the already joined room list""" + contact_list = self.host.contact_list + muc_rooms = contact_list.getSpecials(C.CONTACT_SPECIAL_GROUP) + for room in muc_rooms: self.rooms_list.addItem(room.bare) - if len(self.host.room_list) > 0: + if len(muc_rooms) > 0: self.exist_radio.setVisible(True) self.rooms_list.setVisible(True) self.exist_radio.setChecked(True) - def getRoom(self): - if self.exist_radio.getChecked(): - values = self.rooms_list.getSelectedValues() - return "" if values == [] else values[0] - value = self.box.getText() - return "" if value == self.GENERATE_MUC else value - class ContactsChooser(VerticalPanel): """Select one or several connected contacts""" @@ -120,9 +138,9 @@ elif nb_contact is not None: nb_contact = (nb_contact, nb_contact) if nb_contact is None: - log.warning("Need to select as many contacts as you want") + log.debug("Need to select as many contacts as you want") else: - log.warning("Need to select between %d and %d contacts" % nb_contact) + log.debug("Need to select between %d and %d contacts" % nb_contact) self.nb_contact = nb_contact self.ok_button = ok_button VerticalPanel.__init__(self, Width='100%') @@ -132,34 +150,41 @@ self.contacts_list.addStyleName('contactsChooser') self.contacts_list.addChangeListener(self.onChange) self.add(self.contacts_list) - self.setContacts() + self.refreshOptions() self.onChange() + @property + def contacts(self): + """Return the selected contacts. + + @return: list[jid.JID] + """ + return [jid.JID(contact) for contact in self.contacts_list.getSelectedValues(True)] + def onChange(self, sender=None): if self.ok_button is None: return if self.nb_contact: selected = len(self.contacts_list.getSelectedValues(True)) - if selected >= self.nb_contact[0] and selected <= self.nb_contact[1]: + if selected >= self.nb_contact[0] and selected <= self.nb_contact[1]: self.ok_button.setEnabled(True) else: self.ok_button.setEnabled(False) - def setContacts(self, selected=[]): - """Fill the list with the connected contacts - @param select: list of the contacts to select by default + def refreshOptions(self, keep_selected=False): + """Fill the list with the connected contacts. + + @param keep_selected (boolean): if True, keep the current selection """ + selection = self.contacts if keep_selected else [] self.contacts_list.clear() - contacts = self.host.contact_panel.getConnected(filter_muc=True) + contacts = self.host.contact_list.roster_entities_connected self.contacts_list.setVisibleItemCount(10 if len(contacts) > 5 else 5) self.contacts_list.addItem("") for contact in contacts: - if contact not in [room.bare for room in self.host.room_list]: - self.contacts_list.addItem(contact) - self.contacts_list.setItemTextSelection(selected) - - def getContacts(self): - return self.contacts_list.getSelectedValues(True) + self.contacts_list.addItem(contact) + if selection: + self.contacts_list.setItemTextSelection([unicode(contact) for contact in selection]) class RoomAndContactsChooser(DialogBox): @@ -182,7 +207,7 @@ self.room_panel = RoomChooser(host, "" if visible == (False, True) else DEFAULT_MUC) self.contact_panel = ContactsChooser(host, nb_contact, ok_button) - self.stack_panel = base_panels.ToggleStackPanel(Width="100%") + self.stack_panel = base_panel.ToggleStackPanel(Width="100%") self.stack_panel.add(self.room_panel, visible=visible[0]) self.stack_panel.add(self.contact_panel, visible=visible[1]) self.stack_panel.addStackChangeListener(self) @@ -198,60 +223,72 @@ self.setHTML(title) self.show() - # needed to update the contacts list when someone logged in/out - self.host.room_contacts_chooser = self + # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) + self.presenceListener = self.refreshContactList + # update the contacts list when someone logged in/out + self.host.addListener('presence', self.presenceListener, [C.PROF_KEY_NONE]) + + @property + def room(self): + """Get the room that has been selected or entered by the user - def getRoom(self, asSuffix=False): - room = self.room_panel.getRoom() - if asSuffix: - return room if room == "" else ": %s" % room - else: - return room + @return: jid.JID or None + """ + return self.room_panel.room - def getContacts(self, asSuffix=False): - contacts = self.contact_panel.getContacts() - if asSuffix: - return "" if contacts == [] else ": %s" % ", ".join(contacts) - else: - return contacts + @property + def contacts(self): + """Return the selected contacts. + + @return: list[jid.JID] + """ + return self.contact_panel.contacts def onStackChanged(self, sender, index, visible=None): if visible is None: visible = sender.getWidget(index).getVisible() if index == 0: - sender.setStackText(0, self.title_room + ("" if visible else self.getRoom(True))) + suffix = "" if (visible or not self.room) else ": %s" % self.room + sender.setStackText(0, self.title_room + suffix) elif index == 1: - sender.setStackText(1, self.title_invite + ("" if visible else self.getContacts(True))) + suffix = "" if (visible or not self.contacts) else ": %s" % ", ".join([unicode(contact) for contact in self.contacts]) + sender.setStackText(1, self.title_invite + suffix) - def resetContacts(self): - """Called when someone log in/out to update the list""" - self.contact_panel.setContacts(self.getContacts()) + def refreshContactList(self, *args, **kwargs): + """Called when someone log in/out to update the list. + + @param args: set by the event call but not used here + """ + self.contact_panel.refreshOptions(keep_selected=True) def onOK(self, sender): - room_jid = self.getRoom() - if room_jid != "" and "@" not in room_jid: + room = self.room # pyjamas issue: you need to use an intermediate variable to access a property's method + if room and not room.is_valid(): Window.alert('You must enter a room jid in the form room@chat.%s' % self.host._defaultDomain) return self.hide() - self.callback(room_jid, self.getContacts()) + self.callback(room, self.contacts) def onCancel(self, sender): self.hide() def hide(self): - self.host.room_contacts_chooser = None + self.host.removeListener('presence', self.presenceListener) DialogBox.hide(self, autoClosed=True) class GenericConfirmDialog(DialogBox): - def __init__(self, widgets, callback, title='Confirmation', prompt=None, **kwargs): + def __init__(self, widgets, callback, title='Confirmation', prompt_widgets=None, **kwargs): """ Dialog to confirm an action @param widgets (list[Widget]): widgets to attach - @param callback: method to call when a button is clicked + @param callback (callable): method to call when a button is pressed, + with the following arguments: + - result (bool): set to True if the dialog has been confirmed + - *args: a list of unicode (the values for the prompt_widgets) @param title: title of the dialog - @param prompt (TextBox, list[TextBox]): input widgets from which to retrieve + @param prompt_widgets (list[TextBox]): input widgets from which to retrieve the string value(s) to be passed to the callback when OK button is pressed. If None, OK button will return "True". Cancel button always returns "False". """ @@ -261,16 +298,14 @@ if added_style: self.addStyleName(added_style) - if prompt is None: - prompt = [] - elif isinstance(prompt, TextBox): - prompt = [prompt] + if prompt_widgets is None: + prompt_widgets = [] content = VerticalPanel() content.setWidth('100%') for wid in widgets: content.add(wid) - if wid in prompt: + if wid in prompt_widgets: wid.setWidth('100%') button_panel = HorizontalPanel() button_panel.addStyleName("marginAuto") @@ -281,11 +316,12 @@ content.add(button_panel) self.setHTML(title) self.setWidget(content) - self.prompt = prompt + self.prompt_widgets = prompt_widgets def onConfirm(self, sender): self.hide() - result = [box.getText() for box in self.prompt] if self.prompt else [True] + result = [True] + result.extend([box.getText() for box in self.prompt_widgets]) self.callback(*result) def onCancel(self, sender): @@ -294,8 +330,8 @@ def show(self): DialogBox.show(self) - if self.prompt: - self.prompt[0].setFocus(True) + if self.prompt_widgets: + self.prompt_widgets[0].setFocus(True) class ConfirmDialog(GenericConfirmDialog): @@ -328,7 +364,7 @@ _body.add(main_widget) _body.setCellWidth(main_widget, '100%') _body.setCellHeight(main_widget, '100%') - if not 'NO_CLOSE' in options: + if 'NO_CLOSE' not in options: _close_button = Button("Close", self.onClose) _body.add(_close_button) _body.setCellHorizontalAlignment(_close_button, HasAlignment.ALIGN_CENTER) @@ -357,19 +393,18 @@ def __init__(self, callback, textes=None, values=None, title='User input', **kwargs): """Prompt the user for one or more input(s). - @param callback (callable): method to call when clicking OK - @param textes (str, list[str]): HTML textes to display before the inputs - @param values (str, list[str]): default values for each input - @param title (str): dialog title + @param callback (callable): method to call when a button is pressed, + with the following arguments: + - result (bool): set to True if the dialog has been confirmed + - *args: a list of unicode (the values entered by the user) + @param textes (list[unicode]): HTML textes to display before the inputs + @param values (list[unicode]): default values for each input + @param title (unicode): dialog title """ if textes is None: textes = [''] # display a single input without any description - elif not isinstance(textes, list): - textes = [textes] # allow to pass a single string instead of a list if values is None: values = [] - elif not isinstance(values, list): - values = [values] # allow to pass a single string instead of a list all_widgets = [] prompt_widgets = [] for count in xrange(len(textes)): @@ -388,7 +423,7 @@ def onEventPreview(self, event): if event.type in ["keydown", "keypress", "keyup"] and event.keyCode == KEY_ESCAPE: - #needed to prevent request cancellation in Firefox + # needed to prevent request cancellation in Firefox event.preventDefault() return PopupPanel.onEventPreview(self, event) @@ -528,7 +563,7 @@ minus_button = Button("-", self.onMinus) self.box = WheelTextBox() self.box.setVisibleLength(visible_len) - self.box.setText(str(value)) + self.box.setText(unicode(value)) self.box.addInputListener(self) self.box.addMouseWheelListener(self) plus_button = Button("+", self.onPlus) @@ -551,19 +586,19 @@ def setValue(self, value): """Change the value and fire valueChange listeners""" self.value = value - self.box.setText(str(value)) + self.box.setText(unicode(value)) self._callListeners() def onMinus(self, sender, step=1): self.value = max(0, self.value - step) - self.box.setText(str(self.value)) + self.box.setText(unicode(self.value)) self._callListeners() def onPlus(self, sender, step=1): self.value += step if self.value_max: self.value = min(self.value, self.value_max) - self.box.setText(str(self.value)) + self.box.setText(unicode(self.value)) self._callListeners() def onInput(self, sender): @@ -574,7 +609,7 @@ pass if self.value_max: self.value = min(self.value, self.value_max) - self.box.setText(str(self.value)) + self.box.setText(unicode(self.value)) self._callListeners() def onMouseWheel(self, sender, velocity): diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/editor_widget.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/editor_widget.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,390 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +from sat.core.log import getLogger +log = getLogger(__name__) +from sat_frontends.tools import strings + +from pyjamas.ui.HTML import HTML +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.TextArea import TextArea +from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_SHIFT, KEY_UP, KEY_DOWN, KeyboardHandler +from pyjamas.ui.FocusListener import FocusHandler +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.Timer import Timer +from pyjamas import DOM + +import html_tools + + +class MessageBox(TextArea): + """A basic text area for entering messages""" + + def __init__(self, host): + TextArea.__init__(self) + self.host = host + self.size = (0, 0) + self.setStyleName('messageBox') + self.addKeyboardListener(self) + MouseHandler.__init__(self) + self.addMouseListener(self) + + def onBrowserEvent(self, event): + # XXX: woraroung a pyjamas bug: self.currentEvent is not set + # so the TextBox's cancelKey doens't work. This is a workaround + # FIXME: fix the bug upstream + self.currentEvent = event + TextArea.onBrowserEvent(self, event) + + def onKeyPress(self, sender, keycode, modifiers): + _txt = self.getText() + + def history_cb(text): + self.setText(text) + Timer(5, lambda timer: self.setCursorPos(len(text))) + + if keycode == KEY_ENTER: + if _txt: + self.host.selected_widget.onTextEntered(_txt) + self.host._updateInputHistory(_txt) # FIXME: why using a global variable ? + self.setText('') + sender.cancelKey() + elif keycode == KEY_UP: + self.host._updateInputHistory(_txt, -1, history_cb) + elif keycode == KEY_DOWN: + self.host._updateInputHistory(_txt, +1, history_cb) + else: + self._onComposing() + + def _onComposing(self): + """Callback when the user is composing a text.""" + self.host.selected_widget.state_machine._onEvent("composing") + + def onMouseUp(self, sender, x, y): + size = (self.getOffsetWidth(), self.getOffsetHeight()) + if size != self.size: + self.size = size + self.host.resize() + + def onSelectedChange(self, selected): + self._selected_cache = selected + + +class BaseTextEditor(object): + """Basic definition of a text editor. The method edit gets a boolean parameter which + should be set to True when you want to edit the text and False to only display it.""" + + def __init__(self, content=None, strproc=None, modifiedCb=None, afterEditCb=None): + """ + Remark when inheriting this class: since the setContent method could be + overwritten by the child class, you should consider calling this __init__ + after all the parameters affecting this setContent method have been set. + @param content: dict with at least a 'text' key + @param strproc: method to be applied on strings to clean the content + @param modifiedCb: method to be called when the text has been modified. + If this method returns: + - True: the modification will be saved and afterEditCb called; + - False: the modification won't be saved and afterEditCb called; + - None: the modification won't be saved and afterEditCb not called. + @param afterEditCb: method to be called when the edition is done + """ + if content is None: + content = {'text': ''} + assert('text' in content) + if strproc is None: + def strproc(text): + try: + return text.strip() + except (TypeError, AttributeError): + return text + self.strproc = strproc + self.__modifiedCb = modifiedCb + self._afterEditCb = afterEditCb + self.initialized = False + self.edit_listeners = [] + self.setContent(content) + + def setContent(self, content=None): + """Set the editable content. The displayed content, which is set from the child class, could differ. + @param content: dict with at least a 'text' key + """ + if content is None: + content = {'text': ''} + elif not isinstance(content, dict): + content = {'text': content} + assert('text' in content) + self._original_content = {} + for key in content: + self._original_content[key] = self.strproc(content[key]) + + def getContent(self): + """Get the current edited or editable content. + @return: dict with at least a 'text' key + """ + raise NotImplementedError + + def setOriginalContent(self, content): + """Use this method with care! Content initialization should normally be + done with self.setContent. This method exists to let you trick the editor, + e.g. for self.modified to return True also when nothing has been modified. + @param content: dict + """ + self._original_content = content + + def getOriginalContent(self): + """ + @return the original content before modification (dict) + """ + return self._original_content + + def modified(self, content=None): + """Check if the content has been modified. + Remark: we don't use the direct comparison because we want to ignore empty elements + @content: content to be check against the original content or None to use the current content + @return: True if the content has been modified. + """ + if content is None: + content = self.getContent() + # the following method returns True if one non empty element exists in a but not in b + diff1 = lambda a, b: [a[key] for key in set(a.keys()).difference(b.keys()) if a[key]] != [] + # the following method returns True if the values for the common keys are not equals + diff2 = lambda a, b: [1 for key in set(a.keys()).intersection(b.keys()) if a[key] != b[key]] != [] + # finally the combination of both to return True if a difference is found + diff = lambda a, b: diff1(a, b) or diff1(b, a) or diff2(a, b) + + return diff(content, self._original_content) + + def edit(self, edit, abort=False, sync=False): + """ + Remark: the editor must be visible before you call this method. + @param edit: set to True to edit the content or False to only display it + @param abort: set to True to cancel the edition and loose the changes. + If edit and abort are both True, self.abortEdition can be used to ask for a + confirmation. When edit is False and abort is True, abortion is actually done. + @param sync: set to True to cancel the edition after the content has been saved somewhere else + """ + if edit: + if not self.initialized: + self.syncToEditor() # e.g.: use the selected target and unibox content + self.setFocus(True) + if abort: + content = self.getContent() + if not self.modified(content) or self.abortEdition(content): # e.g: ask for confirmation + self.edit(False, True, sync) + return + if sync: + self.syncFromEditor(content) # e.g.: save the content to unibox + return + else: + if not self.initialized: + return + content = self.getContent() + if abort: + self._afterEditCb(content) + return + if self.__modifiedCb and self.modified(content): + result = self.__modifiedCb(content) # e.g.: send a message or update something + if result is not None: + if self._afterEditCb: + self._afterEditCb(content) # e.g.: restore the display mode + if result is True: + self.setContent(content) + elif self._afterEditCb: + self._afterEditCb(content) + + self.initialized = True + + def setFocus(self, focus): + """ + @param focus: set to True to focus the editor + """ + raise NotImplementedError + + def syncToEditor(self): + pass + + def syncFromEditor(self, content): + pass + + def abortEdition(self, content): + return True + + def addEditListener(self, listener): + """Add a method to be called whenever the text is edited. + @param listener: method taking two arguments: sender, keycode""" + self.edit_listeners.append(listener) + + +class SimpleTextEditor(BaseTextEditor, FocusHandler, KeyboardHandler, ClickHandler): + """Base class for manage a simple text editor.""" + + def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): + """ + @param content + @param modifiedCb + @param afterEditCb + @param options: dict with the following value: + - no_xhtml: set to True to clean any xhtml content. + - enhance_display: if True, the display text will be enhanced with strings.addURLToText + - listen_keyboard: set to True to terminate the edition with or . + - listen_focus: set to True to terminate the edition when the focus is lost. + - listen_click: set to True to start the edition when you click on the widget. + """ + self.options = {'no_xhtml': False, + 'enhance_display': True, + 'listen_keyboard': True, + 'listen_focus': False, + 'listen_click': False + } + if options: + self.options.update(options) + self.__shift_down = False + if self.options['listen_focus']: + FocusHandler.__init__(self) + if self.options['listen_click']: + ClickHandler.__init__(self) + KeyboardHandler.__init__(self) + strproc = lambda text: html_tools.html_sanitize(html_tools.html_strip(text)) if self.options['no_xhtml'] else html_tools.html_strip(text) + BaseTextEditor.__init__(self, content, strproc, modifiedCb, afterEditCb) + self.textarea = self.display = None + + def setContent(self, content=None): + BaseTextEditor.setContent(self, content) + + def getContent(self): + raise NotImplementedError + + def edit(self, edit, abort=False, sync=False): + BaseTextEditor.edit(self, edit) + if edit: + if self.options['listen_focus'] and self not in self.textarea._focusListeners: + self.textarea.addFocusListener(self) + if self.options['listen_click']: + self.display.clearClickListener() + if self not in self.textarea._keyboardListeners: + self.textarea.addKeyboardListener(self) + else: + self.setDisplayContent() + if self.options['listen_focus']: + try: + self.textarea.removeFocusListener(self) + except ValueError: + pass + if self.options['listen_click'] and self not in self.display._clickListeners: + self.display.addClickListener(self) + try: + self.textarea.removeKeyboardListener(self) + except ValueError: + pass + + def setDisplayContent(self): + text = self._original_content['text'] + if not self.options['no_xhtml']: + text = strings.addURLToImage(text) + if self.options['enhance_display']: + text = strings.addURLToText(text) + self.display.setHTML(html_tools.convertNewLinesToXHTML(text)) + + def setFocus(self, focus): + raise NotImplementedError + + def onKeyDown(self, sender, keycode, modifiers): + for listener in self.edit_listeners: + listener(self.textarea, keycode) + if not self.options['listen_keyboard']: + return + if keycode == KEY_SHIFT or self.__shift_down: # allow input a new line with + + self.__shift_down = True + return + if keycode == KEY_ENTER: # finish the edition + self.textarea.setFocus(False) + if not self.options['listen_focus']: + self.edit(False) + + def onKeyUp(self, sender, keycode, modifiers): + if keycode == KEY_SHIFT: + self.__shift_down = False + + def onLostFocus(self, sender): + """Finish the edition when focus is lost""" + if self.options['listen_focus']: + self.edit(False) + + def onClick(self, sender=None): + """Start the edition when the widget is clicked""" + if self.options['listen_click']: + self.edit(True) + + def onBrowserEvent(self, event): + if self.options['listen_focus']: + FocusHandler.onBrowserEvent(self, event) + if self.options['listen_click']: + ClickHandler.onBrowserEvent(self, event) + KeyboardHandler.onBrowserEvent(self, event) + + +class HTMLTextEditor(SimpleTextEditor, HTML, FocusHandler, KeyboardHandler): + """Manage a simple text editor with the HTML 5 "contenteditable" property.""" + + def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): + HTML.__init__(self) + SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options) + self.textarea = self.display = self + + def getContent(self): + text = DOM.getInnerHTML(self.getElement()) + return {'text': self.strproc(text) if text else ''} + + def edit(self, edit, abort=False, sync=False): + if edit: + self.textarea.setHTML(self._original_content['text']) + self.getElement().setAttribute('contenteditable', 'true' if edit else 'false') + SimpleTextEditor.edit(self, edit, abort, sync) + + def setFocus(self, focus): + if focus: + self.getElement().focus() + else: + self.getElement().blur() + + +class LightTextEditor(SimpleTextEditor, SimplePanel, FocusHandler, KeyboardHandler): + """Manage a simple text editor with a TextArea for editing, HTML for display.""" + + def __init__(self, content=None, modifiedCb=None, afterEditCb=None, options=None): + SimplePanel.__init__(self) + SimpleTextEditor.__init__(self, content, modifiedCb, afterEditCb, options) + self.textarea = TextArea() + self.display = HTML() + + def getContent(self): + text = self.textarea.getText() + return {'text': self.strproc(text) if text else ''} + + def edit(self, edit, abort=False, sync=False): + if edit: + self.textarea.setText(self._original_content['text']) + self.setWidget(self.textarea if edit else self.display) + SimpleTextEditor.edit(self, edit, abort, sync) + + def setFocus(self, focus): + if focus and self.isAttached(): + self.textarea.setCursorPos(len(self.textarea.getText())) + self.textarea.setFocus(focus) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/file_tools.py --- a/src/browser/sat_browser/file_tools.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/file_tools.py Wed Mar 18 16:15:18 2015 +0100 @@ -20,7 +20,7 @@ from sat.core.log import getLogger log = getLogger(__name__) from constants import Const as C -from sat.core.i18n import D_ +from sat.core.i18n import _, D_ from pyjamas.ui.FileUpload import FileUpload from pyjamas.ui.FormPanel import FormPanel from pyjamas import Window diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/game_radiocol.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/game_radiocol.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,343 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _, D_ +from sat_frontends.tools.misc import DEFAULT_MUC +from sat_frontends.tools import host_listener +from constants import Const as C + +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.FlexTable import FlexTable +from pyjamas.ui.FormPanel import FormPanel +from pyjamas.ui.Label import Label +from pyjamas.ui.Button import Button +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.Hidden import Hidden +from pyjamas.ui.CaptionPanel import CaptionPanel +from pyjamas.media.Audio import Audio +from pyjamas import Window +from pyjamas.Timer import Timer + +import html_tools +import file_tools +import dialog + + +class MetadataPanel(FlexTable): + + def __init__(self): + FlexTable.__init__(self) + title_lbl = Label("title:") + title_lbl.setStyleName('radiocol_metadata_lbl') + artist_lbl = Label("artist:") + artist_lbl.setStyleName('radiocol_metadata_lbl') + album_lbl = Label("album:") + album_lbl.setStyleName('radiocol_metadata_lbl') + self.title = Label("") + self.title.setStyleName('radiocol_metadata') + self.artist = Label("") + self.artist.setStyleName('radiocol_metadata') + self.album = Label("") + self.album.setStyleName('radiocol_metadata') + self.setWidget(0, 0, title_lbl) + self.setWidget(1, 0, artist_lbl) + self.setWidget(2, 0, album_lbl) + self.setWidget(0, 1, self.title) + self.setWidget(1, 1, self.artist) + self.setWidget(2, 1, self.album) + self.setStyleName("radiocol_metadata_pnl") + + def setTitle(self, title): + self.title.setText(title) + + def setArtist(self, artist): + self.artist.setText(artist) + + def setAlbum(self, album): + self.album.setText(album) + + +class ControlPanel(FormPanel): + """Panel used to show controls to add a song, or vote for the current one""" + + def __init__(self, parent): + FormPanel.__init__(self) + self.setEncoding(FormPanel.ENCODING_MULTIPART) + self.setMethod(FormPanel.METHOD_POST) + self.setAction("upload_radiocol") + self.timer_on = False + self._parent = parent + vPanel = VerticalPanel() + + types = [('audio/ogg', '*.ogg', 'Ogg Vorbis Audio'), + ('video/ogg', '*.ogv', 'Ogg Vorbis Video'), + ('application/ogg', '*.ogx', 'Ogg Vorbis Multiplex'), + ('audio/mpeg', '*.mp3', 'MPEG-Layer 3'), + ('audio/mp3', '*.mp3', 'MPEG-Layer 3'), + ] + self.file_upload = file_tools.FilterFileUpload("song", 10, types) + vPanel.add(self.file_upload) + + hPanel = HorizontalPanel() + self.upload_btn = Button("Upload song", getattr(self, "onBtnClick")) + hPanel.add(self.upload_btn) + self.status = Label() + self.updateStatus() + hPanel.add(self.status) + #We need to know the filename and the referee + self.filename_field = Hidden('filename', '') + hPanel.add(self.filename_field) + referee_field = Hidden('referee', self._parent.referee) + hPanel.add(self.filename_field) + hPanel.add(referee_field) + vPanel.add(hPanel) + + self.add(vPanel) + self.addFormHandler(self) + + def updateStatus(self): + if self.timer_on: + return + # TODO: the status should be different if a song is being played or not + queue = self._parent.getQueueSize() + queue_data = self._parent.queue_data + if queue < queue_data[0]: + left = queue_data[0] - queue + self.status.setText("[we need %d more song%s]" % (left, "s" if left > 1 else "")) + elif queue < queue_data[1]: + left = queue_data[1] - queue + self.status.setText("[%d available spot%s]" % (left, "s" if left > 1 else "")) + elif queue >= queue_data[1]: + self.status.setText("[The queue is currently full]") + self.status.setStyleName('radiocol_status') + + def onBtnClick(self): + if self.file_upload.check(): + self.status.setText('[Submitting, please wait...]') + self.filename_field.setValue(self.file_upload.getFilename()) + if self.file_upload.getFilename().lower().endswith('.mp3'): + self._parent._parent.host.showWarning('STATUS', 'For a better support, it is recommended to submit Ogg Vorbis file instead of MP3. You can convert your files easily, ask for help if needed!', 5000) + self.submit() + self.file_upload.setFilename("") + + def onSubmit(self, event): + pass + + def blockUpload(self): + self.file_upload.setVisible(False) + self.upload_btn.setEnabled(False) + + def unblockUpload(self): + self.file_upload.setVisible(True) + self.upload_btn.setEnabled(True) + + def setTemporaryStatus(self, text, style): + self.status.setText(text) + self.status.setStyleName('radiocol_upload_status_%s' % style) + self.timer_on = True + + def cb(timer): + self.timer_on = False + self.updateStatus() + + Timer(5000, cb) + + def onSubmitComplete(self, event): + result = event.getResults() + if result == C.UPLOAD_OK: + # the song can still be rejected (not readable, full queue...) + self.setTemporaryStatus('[Your song has been submitted to the radio]', "ok") + elif result == C.UPLOAD_KO: + self.setTemporaryStatus('[Something went wrong during your song upload]', "ko") + self._parent.radiocolSongRejectedHandler(_("The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted.")) + # TODO: would be great to re-use the original Exception class and message + # but it is lost in the middle of the traceback and encapsulated within + # a DBusException instance --> extract the data from the traceback? + else: + Window.alert(_('Submit error: %s' % result)) + self.status.setText('') + + +class Player(Audio): + + def __init__(self, player_id, metadata_panel): + Audio.__init__(self) + self._id = player_id + self.metadata = metadata_panel + self.timestamp = "" + self.title = "" + self.artist = "" + self.album = "" + self.filename = None + self.played = False # True when the song is playing/has played, becomes False on preload + self.setAutobuffer(True) + self.setAutoplay(False) + self.setVisible(False) + + def preload(self, timestamp, filename, title, artist, album): + """preload the song but doesn't play it""" + self.timestamp = timestamp + self.filename = filename + self.title = title + self.artist = artist + self.album = album + self.played = False + self.setSrc("radiocol/%s" % html_tools.html_sanitize(filename)) + log.debug("preloading %s in %s" % (title, self._id)) + + def play(self, play=True): + """Play or pause the song + @param play: set to True to play or to False to pause + """ + if play: + self.played = True + self.metadata.setTitle(self.title) + self.metadata.setArtist(self.artist) + self.metadata.setAlbum(self.album) + Audio.play(self) + else: + self.pause() + + +class RadioColPanel(HorizontalPanel, ClickHandler): + + def __init__(self, parent, referee, players, queue_data): + """ + @param parent + @param referee + @param players + @param queue_data: list of integers (queue to start, queue limit) + """ + # We need to set it here and not in the CSS :( + HorizontalPanel.__init__(self, Height="90px") + ClickHandler.__init__(self) + self._parent = parent + self.referee = referee + self.queue_data = queue_data + self.setStyleName("radiocolPanel") + + # Now we set up the layout + self.metadata_panel = MetadataPanel() + self.add(CaptionPanel("Now playing", self.metadata_panel)) + self.playlist_panel = VerticalPanel() + self.add(CaptionPanel("Songs queue", self.playlist_panel)) + self.control_panel = ControlPanel(self) + self.add(CaptionPanel("Controls", self.control_panel)) + + self.next_songs = [] + self.players = [Player("player_%d" % i, self.metadata_panel) for i in xrange(queue_data[1] + 1)] + self.current_player = None + for player in self.players: + self.add(player) + self.addClickListener(self) + + help_msg = """Accepted file formats: Ogg Vorbis (recommended), MP3.
+ Please do not submit files that are protected by copyright.
+ Click here if you need some support :)""" + link_cb = lambda: self._parent.host.bridge.call('joinMUC', None, DEFAULT_MUC, self._parent.nick) + self._parent.printInfo(help_msg, type_='link', link_cb=link_cb) + + def pushNextSong(self, title): + """Add a song to the left panel's next songs queue""" + next_song = Label(title) + next_song.setStyleName("radiocol_next_song") + self.next_songs.append(next_song) + self.playlist_panel.append(next_song) + self.control_panel.updateStatus() + + def popNextSong(self): + """Remove the first song of next songs list + should be called when the song is played""" + #FIXME: should check that the song we remove is the one we play + next_song = self.next_songs.pop(0) + self.playlist_panel.remove(next_song) + self.control_panel.updateStatus() + + def getQueueSize(self): + return len(self.playlist_panel.getChildren()) + + def radiocolCheckPreload(self, timestamp): + for player in self.players: + if player.timestamp == timestamp: + return False + return True + + def radiocolPreloadHandler(self, timestamp, filename, title, artist, album, sender): + if not self.radiocolCheckPreload(timestamp): + return # song already preloaded + preloaded = False + for player in self.players: + if not player.filename or \ + (player.played and player != self.current_player): + #if player has no file loaded, or it has already played its song + #we use it to preload the next one + player.preload(timestamp, filename, title, artist, album) + preloaded = True + break + if not preloaded: + log.warning("Can't preload song, we are getting too many songs to preload, we shouldn't have more than %d at once" % self.queue_data[1]) + else: + self.pushNextSong(title) + self._parent.printInfo(_('%(user)s uploaded %(artist)s - %(title)s') % {'user': sender, 'artist': artist, 'title': title}) + + def radiocolPlayHandler(self, filename): + found = False + for player in self.players: + if not found and player.filename == filename: + player.play() + self.popNextSong() + self.current_player = player + found = True + else: + player.play(False) # in case the previous player was not sync + if not found: + log.error("Song not found in queue, can't play it. This should not happen") + + def radiocolNoUploadHandler(self): + self.control_panel.blockUpload() + + def radiocolUploadOkHandler(self): + self.control_panel.unblockUpload() + + def radiocolSongRejectedHandler(self, reason): + Window.alert("Song rejected: %s" % reason) + + +## Menu + +def hostReady(host): + def onCollectiveRadio(self): + def callback(room_jid, contacts): + contacts = [unicode(contact) for contact in contacts] + room_jid_s = unicode(room_jid) if room_jid else '' + host.bridge.RadioCollective(contacts, room_jid_s, profile=C.PROF_KEY_NONE) + dialog.RoomAndContactsChooser(host, callback, ok_button="Choose", title="Collective Radio", visible=(False, True)) + + + def gotMenus(): + host.menus.addMenu(C.MENU_GLOBAL, (D_(u"Groups"), D_(u"Collective radio")), callback=onCollectiveRadio) + + host.addListener('gotMenus', gotMenus) + +host_listener.addListener(hostReady) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/game_tarot.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/game_tarot.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,408 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _, D_ +from sat_frontends.tools.games import TarotCard +from sat_frontends.tools import host_listener + +from pyjamas.ui.AbsolutePanel import AbsolutePanel +from pyjamas.ui.DockPanel import DockPanel +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.Image import Image +from pyjamas.ui.Label import Label +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui.MouseListener import MouseHandler +from pyjamas.ui import HasAlignment +from pyjamas import Window +from pyjamas import DOM +from constants import Const as C + +import dialog +import xmlui + + +CARD_WIDTH = 74 +CARD_HEIGHT = 136 +CARD_DELTA_Y = 30 +MIN_WIDTH = 950 # Minimum size of the panel +MIN_HEIGHT = 500 + + +class CardWidget(TarotCard, Image, MouseHandler): + """This class is used to represent a card, graphically and logically""" + + def __init__(self, parent, file_): + """@param file: path of the PNG file""" + self._parent = parent + Image.__init__(self, file_) + root_name = file_[file_.rfind("/") + 1:-4] + suit, value = root_name.split('_') + TarotCard.__init__(self, (suit, value)) + MouseHandler.__init__(self) + self.addMouseListener(self) + + def onMouseEnter(self, sender): + if self._parent.state == "ecart" or self._parent.state == "play": + DOM.setStyleAttribute(self.getElement(), "top", "0px") + + def onMouseLeave(self, sender): + if not self in self._parent.hand: + return + if not self in list(self._parent.selected): # FIXME: Workaround pyjs bug, must report it + DOM.setStyleAttribute(self.getElement(), "top", "%dpx" % CARD_DELTA_Y) + + def onMouseUp(self, sender, x, y): + if self._parent.state == "ecart": + if self not in list(self._parent.selected): + self._parent.addToSelection(self) + else: + self._parent.removeFromSelection(self) + elif self._parent.state == "play": + self._parent.playCard(self) + + +class TarotPanel(DockPanel, ClickHandler): + + def __init__(self, parent, referee, players): + DockPanel.__init__(self) + ClickHandler.__init__(self) + self._parent = parent + self._autoplay = None # XXX: use 0 to activate fake play, None else + self.referee = referee + self.players = players + self.player_nick = parent.nick + self.bottom_nick = self.player_nick + idx = self.players.index(self.player_nick) + idx = (idx + 1) % len(self.players) + self.right_nick = self.players[idx] + idx = (idx + 1) % len(self.players) + self.top_nick = self.players[idx] + idx = (idx + 1) % len(self.players) + self.left_nick = self.players[idx] + self.bottom_nick = self.player_nick + self.selected = set() # Card choosed by the player (e.g. during ecart) + self.hand_size = 13 # number of cards in a hand + self.hand = [] + self.to_show = [] + self.state = None + self.setSize("%dpx" % MIN_WIDTH, "%dpx" % MIN_HEIGHT) + self.setStyleName("cardPanel") + + # Now we set up the layout + _label = Label(self.top_nick) + _label.setStyleName('cardGamePlayerNick') + self.add(_label, DockPanel.NORTH) + self.setCellWidth(_label, '100%') + self.setCellHorizontalAlignment(_label, HasAlignment.ALIGN_CENTER) + + self.hand_panel = AbsolutePanel() + self.add(self.hand_panel, DockPanel.SOUTH) + self.setCellWidth(self.hand_panel, '100%') + self.setCellHorizontalAlignment(self.hand_panel, HasAlignment.ALIGN_CENTER) + + _label = Label(self.left_nick) + _label.setStyleName('cardGamePlayerNick') + self.add(_label, DockPanel.WEST) + self.setCellHeight(_label, '100%') + self.setCellVerticalAlignment(_label, HasAlignment.ALIGN_MIDDLE) + + _label = Label(self.right_nick) + _label.setStyleName('cardGamePlayerNick') + self.add(_label, DockPanel.EAST) + self.setCellHeight(_label, '100%') + self.setCellHorizontalAlignment(_label, HasAlignment.ALIGN_RIGHT) + self.setCellVerticalAlignment(_label, HasAlignment.ALIGN_MIDDLE) + + self.center_panel = DockPanel() + self.inner_left = SimplePanel() + self.inner_left.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) + self.center_panel.add(self.inner_left, DockPanel.WEST) + self.center_panel.setCellHeight(self.inner_left, '100%') + self.center_panel.setCellHorizontalAlignment(self.inner_left, HasAlignment.ALIGN_RIGHT) + self.center_panel.setCellVerticalAlignment(self.inner_left, HasAlignment.ALIGN_MIDDLE) + + self.inner_right = SimplePanel() + self.inner_right.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) + self.center_panel.add(self.inner_right, DockPanel.EAST) + self.center_panel.setCellHeight(self.inner_right, '100%') + self.center_panel.setCellVerticalAlignment(self.inner_right, HasAlignment.ALIGN_MIDDLE) + + self.inner_top = SimplePanel() + self.inner_top.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) + self.center_panel.add(self.inner_top, DockPanel.NORTH) + self.center_panel.setCellHorizontalAlignment(self.inner_top, HasAlignment.ALIGN_CENTER) + self.center_panel.setCellVerticalAlignment(self.inner_top, HasAlignment.ALIGN_BOTTOM) + + self.inner_bottom = SimplePanel() + self.inner_bottom.setSize("%dpx" % CARD_WIDTH, "%dpx" % CARD_HEIGHT) + self.center_panel.add(self.inner_bottom, DockPanel.SOUTH) + self.center_panel.setCellHorizontalAlignment(self.inner_bottom, HasAlignment.ALIGN_CENTER) + self.center_panel.setCellVerticalAlignment(self.inner_bottom, HasAlignment.ALIGN_TOP) + + self.inner_center = SimplePanel() + self.center_panel.add(self.inner_center, DockPanel.CENTER) + self.center_panel.setCellHorizontalAlignment(self.inner_center, HasAlignment.ALIGN_CENTER) + self.center_panel.setCellVerticalAlignment(self.inner_center, HasAlignment.ALIGN_MIDDLE) + + self.add(self.center_panel, DockPanel.CENTER) + self.setCellWidth(self.center_panel, '100%') + self.setCellHeight(self.center_panel, '100%') + self.setCellVerticalAlignment(self.center_panel, HasAlignment.ALIGN_MIDDLE) + self.setCellHorizontalAlignment(self.center_panel, HasAlignment.ALIGN_CENTER) + + self.loadCards() + self.mouse_over_card = None # contain the card to highlight + self.visible_size = CARD_WIDTH / 2 # number of pixels visible for cards + self.addClickListener(self) + + def loadCards(self): + """Load all the cards in memory""" + def _getTarotCardsPathsCb(paths): + log.debug("_getTarotCardsPathsCb") + for file_ in paths: + log.debug("path: %s" % file_) + card = CardWidget(self, file_) + log.debug("card: %s" % card) + self.cards[(card.suit, card.value)] = card + self.deck.append(card) + self._parent.host.bridge.call('tarotGameReady', None, self.player_nick, self.referee) + self.cards = {} + self.deck = [] + self.cards["atout"] = {} # As Tarot is a french game, it's more handy & logical to keep french names + self.cards["pique"] = {} # spade + self.cards["coeur"] = {} # heart + self.cards["carreau"] = {} # diamond + self.cards["trefle"] = {} # club + self._parent.host.bridge.call('getTarotCardsPaths', _getTarotCardsPathsCb) + + def onClick(self, sender): + if self.state == "chien": + self.to_show = [] + self.state = "wait" + self.updateToShow() + elif self.state == "wait_for_ecart": + self.state = "ecart" + self.hand.extend(self.to_show) + self.hand.sort() + self.to_show = [] + self.updateToShow() + self.updateHand() + + def tarotGameNewHandler(self, hand): + """Start a new game, with given hand""" + if hand is []: # reset the display after the scores have been showed + self.selected.clear() + del self.hand[:] + del self.to_show[:] + self.state = None + #empty hand + self.updateHand() + #nothing on the table + self.updateToShow() + for pos in ['top', 'left', 'bottom', 'right']: + getattr(self, "inner_%s" % pos).setWidget(None) + self._parent.host.bridge.call('tarotGameReady', None, self.player_nick, self.referee) + return + for suit, value in hand: + self.hand.append(self.cards[(suit, value)]) + self.hand.sort() + self.state = "init" + self.updateHand() + + def updateHand(self): + """Show the cards in the hand in the hand_panel (SOUTH panel)""" + self.hand_panel.clear() + self.hand_panel.setSize("%dpx" % (self.visible_size * (len(self.hand) + 1)), "%dpx" % (CARD_HEIGHT + CARD_DELTA_Y + 10)) + x_pos = 0 + y_pos = CARD_DELTA_Y + for card in self.hand: + self.hand_panel.add(card, x_pos, y_pos) + x_pos += self.visible_size + + def updateToShow(self): + """Show cards in the center panel""" + if not self.to_show: + _widget = self.inner_center.getWidget() + if _widget: + self.inner_center.remove(_widget) + return + panel = AbsolutePanel() + panel.setSize("%dpx" % ((CARD_WIDTH + 5) * len(self.to_show) - 5), "%dpx" % (CARD_HEIGHT)) + x_pos = 0 + y_pos = 0 + for card in self.to_show: + panel.add(card, x_pos, y_pos) + x_pos += CARD_WIDTH + 5 + self.inner_center.setWidget(panel) + + def _ecartConfirm(self, confirm): + if not confirm: + return + ecart = [] + for card in self.selected: + ecart.append((card.suit, card.value)) + self.hand.remove(card) + self.selected.clear() + self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, ecart) + self.state = "wait" + self.updateHand() + + def addToSelection(self, card): + self.selected.add(card) + if len(self.selected) == 6: + dialog.ConfirmDialog(self._ecartConfirm, "Put these cards into chien ?").show() + + def tarotGameInvalidCardsHandler(self, phase, played_cards, invalid_cards): + """Invalid cards have been played + @param phase: phase of the game + @param played_cards: all the cards played + @param invalid_cards: cards which are invalid""" + + if phase == "play": + self.state = "play" + elif phase == "ecart": + self.state = "ecart" + else: + log.error("INTERNAL ERROR: unmanaged game phase") # FIXME: raise an exception here + + for suit, value in played_cards: + self.hand.append(self.cards[(suit, value)]) + + self.hand.sort() + self.updateHand() + if self._autoplay == None: # No dialog if there is autoplay + Window.alert('Cards played are invalid !') + self.__fakePlay() + + def removeFromSelection(self, card): + self.selected.remove(card) + if len(self.selected) == 6: + dialog.ConfirmDialog(self._ecartConfirm, "Put these cards into chien ?").show() + + def tarotGameChooseContratHandler(self, xml_data): + """Called when the player has to select his contrat + @param xml_data: SàT xml representation of the form""" + body = xmlui.create(self._parent.host, xml_data, flags=['NO_CANCEL']) + _dialog = dialog.GenericDialog(_('Please choose your contrat'), body, options=['NO_CLOSE']) + body.setCloseCb(_dialog.close) + _dialog.show() + + def tarotGameShowCardsHandler(self, game_stage, cards, data): + """Display cards in the middle of the game (to show for e.g. chien ou poignée)""" + self.to_show = [] + for suit, value in cards: + self.to_show.append(self.cards[(suit, value)]) + self.updateToShow() + if game_stage == "chien" and data['attaquant'] == self.player_nick: + self.state = "wait_for_ecart" + else: + self.state = "chien" + + def getPlayerLocation(self, nick): + """return player location (top,bottom,left or right)""" + for location in ['top', 'left', 'bottom', 'right']: + if getattr(self, '%s_nick' % location) == nick: + return location + log.error("This line should not be reached") + + def tarotGameCardsPlayedHandler(self, player, cards): + """A card has been played by player""" + if not len(cards): + log.warning("cards should not be empty") + return + if len(cards) > 1: + log.error("can't manage several cards played") + if self.to_show: + self.to_show = [] + self.updateToShow() + suit, value = cards[0] + player_pos = self.getPlayerLocation(player) + player_panel = getattr(self, "inner_%s" % player_pos) + + if player_panel.getWidget() != None: + #We have already cards on the table, we remove them + for pos in ['top', 'left', 'bottom', 'right']: + getattr(self, "inner_%s" % pos).setWidget(None) + + card = self.cards[(suit, value)] + DOM.setElemAttribute(card.getElement(), "style", "") + player_panel.setWidget(card) + + def tarotGameYourTurnHandler(self): + """Called when we have to play :)""" + if self.state == "chien": + self.to_show = [] + self.updateToShow() + self.state = "play" + self.__fakePlay() + + def __fakePlay(self): + """Convenience method for stupid autoplay + /!\ don't forgot to comment any interactive dialog for invalid card""" + if self._autoplay == None: + return + if self._autoplay >= len(self.hand): + self._autoplay = 0 + card = self.hand[self._autoplay] + self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, [(card.suit, card.value)]) + del self.hand[self._autoplay] + self.state = "wait" + self._autoplay += 1 + + def playCard(self, card): + self.hand.remove(card) + self._parent.host.bridge.call('tarotGamePlayCards', None, self.player_nick, self.referee, [(card.suit, card.value)]) + self.state = "wait" + self.updateHand() + + def tarotGameScoreHandler(self, xml_data, winners, loosers): + """Show score at the end of a round""" + if not winners and not loosers: + title = "Draw game" + else: + if self.player_nick in winners: + title = "You win !" + else: + title = "You loose :(" + body = xmlui.create(self._parent.host, xml_data, title=title, flags=['NO_CANCEL']) + _dialog = dialog.GenericDialog(title, body, options=['NO_CLOSE']) + body.setCloseCb(_dialog.close) + _dialog.show() + + +## Menu + +def hostReady(host): + def onTarotGame(): + def onPlayersSelected(room_jid, other_players): + other_players = [unicode(contact) for contact in other_players] + room_jid_s = unicode(room_jid) if room_jid else '' + host.bridge.launchTarotGame(other_players, room_jid_s, profile=C.PROF_KEY_NONE) + dialog.RoomAndContactsChooser(host, onPlayersSelected, 3, title="Tarot", title_invite=_(u"Please select 3 other players"), visible=(False, True)) + + + def gotMenus(): + host.menus.addMenu(C.MENU_GLOBAL, (D_(u"Games"), D_(u"Tarot")), callback=onTarotGame) + host.addListener('gotMenus', gotMenus) + +host_listener.addListener(hostReady) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/jid.py --- a/src/browser/sat_browser/jid.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,118 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -# FIXME: Libervia should use sat_frontends.tools.jid but pyjamas doesn't -# know the unicode type and also experiences issues with __new__. - - -class JID(object): - """This class help manage JID (Node@Domaine/Resource)""" - - def __init__(self, jid): - assert(isinstance(jid, str)) - self.__raw = str(JID.__normalize(jid)) - self.__parse() - - @classmethod - def __normalize(cls, jid): - """Naive normalization before instantiating and parsing the JID""" - if not jid: - return jid - tokens = jid.split('/') - tokens[0] = tokens[0].lower() # force node and domain to lower-case - return '/'.join(tokens) - - @property - def node(self): - return self.__node - - @property - def domain(self): - return self.__domain - - @property - def resource(self): - return self.__resource - - @property - def bare(self): - return self.domain if not self.node else (self.node + "@" + self.domain) - - # XXX: Avoid property setters, Pyjamas doesn't seem to handle them well. It seems - # that it will just naively assign the value without actually calling the method. - # FIXME: find a way to raise an error if the (undefined!) setter is called. - def setNode(self, node): - self.__node = node - self.__build() - - def setDomain(self, domain): - self.__domain = domain - self.__build() - - def setResource(self, resource): - self.__resource = resource - self.__build() - - def setBare(self, bare): - self.__parseBare(bare) - self.__build() - - def __build(self): - """Build the JID string from the node, domain and resource""" - self.__raw = self.bare if not self.resource else (self.bare + '/' + self.resource) - - def __parse(self): - """Parse the JID string to extract the node, domain and resource""" - tokens = self.__raw.split('/') - bare, self.__resource = (tokens[0], tokens[1]) if len(tokens) > 1 else (self.__raw, '') - self.__parseBare(bare) - - def __parseBare(self, bare): - """Parse the given JID bare to extract the node and domain - - @param bare (str): JID bare to parse - """ - tokens = bare.split('@') - self.__node, self.__domain = (tokens[0], tokens[1]) if len(tokens) > 1 else ('', bare) - - def __str__(self): - try: - return self.__raw - except AttributeError: - raise AttributeError("Trying to output a JID which has not been parsed yet") - - def is_valid(self): - """ - @return: True if the JID is XMPP compliant - """ - return self.domain != '' - - def __eq__(self, other): - """Redefine equality operator to implement the naturally expected behavior""" - return self.__raw == other.__raw - - def __hash__(self): - """Redefine hash to implement the naturally expected behavior""" - return hash(self.__raw) - - def full(self): - return str(self) - - def bareJID(self): - return JID(self.bare) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/json.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/json.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,269 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + + +### logging configuration ### +from sat.core.log import getLogger +log = getLogger(__name__) +### + +from pyjamas.Timer import Timer +from pyjamas import Window +from pyjamas import JSONService + +from sat_browser.constants import Const as C + + +class LiberviaMethodProxy(object): + """This class manage calling for one method""" + + def __init__(self, parent, method): + self._parent = parent + self._method = method + + def call(self, *args, **kwargs): + """Method called when self._method attribue is used in JSON_PROXY_PARENT + + This method manage callback/errback in kwargs, and profile(_key) removing + @param *args: positional arguments of self._method + @param **kwargs: keyword arguments of self._method + """ + callback=kwargs.pop('callback', None) + errback=kwargs.pop('errback', None) + + # as profile is linked to browser session and managed server side, we remove them + profile_removed = False + try: + kwargs['profile'] # FIXME: workaround for pyjamas bug: KeyError is not raised with del + del kwargs['profile'] + profile_removed = True + except KeyError: + pass + + try: + kwargs['profile_key'] # FIXME: workaround for pyjamas bug: KeyError is not raised iwith del + del kwargs['profile_key'] + profile_removed = True + except KeyError: + pass + + if not profile_removed and args: + # if profile was not in kwargs, there is most probably one in args + args = list(args) + assert isinstance(args[-1], basestring) # Detect when we want to remove a callback (or something else) instead of the profile + del args[-1] + + if kwargs: + # kwargs should be empty here, we don't manage keyword arguments on bridge calls + log.error("kwargs is not empty after treatment on method call: kwargs={}".format(kwargs)) + + id_ = self._parent.callMethod(self._method, args) + + # callback or errback are managed in parent LiberviaJsonProxy with call id + if callback is not None: + self._parent.cb[id_] = callback + if errback is not None: + self._parent.eb[id_] = errback + + +class LiberviaJsonProxy(JSONService.JSONService): + + def __init__(self, url, methods): + self._serviceURL = url + self.methods = methods + JSONService.JSONService.__init__(self, url, self) + self.cb = {} + self.eb = {} + self._registerMethods(methods) + + def _registerMethods(self, methods): + if methods: + for method in methods: + log.debug("Registering JSON method call [{}]".format(method)) + setattr(self, + method, + getattr(LiberviaMethodProxy(self, method), 'call') + ) + + def callMethod(self, method, params, handler = None): + ret = super(LiberviaJsonProxy, self).callMethod(method, params, handler) + return ret + + def call(self, method, cb, *args): + # FIXME: deprecated call method, must be removed once it's not used anymore + id_ = self.callMethod(method, args) + log.debug("call: method={} [id={}], args={}".format(method, id_, args)) + if cb: + if isinstance(cb, tuple): + if len(cb) != 2: + log.error("tuple syntax for bridge.call is (callback, errback), aborting") + return + if cb[0] is not None: + self.cb[id_] = cb[0] + self.eb[id_] = cb[1] + else: + self.cb[id_] = cb + + def onRemoteResponse(self, response, request_info): + try: + _cb = self.cb[request_info.id] + except KeyError: + pass + else: + # if isinstance(_cb, tuple): + # #we have arguments attached to the callback + # #we send them after the answer + # callback, args = _cb + # callback(response, *args) + # else: + # #No additional argument, we call directly the callback + _cb(response) + del self.cb[request_info.id] + + try: + del self.eb[request_info.id] + except KeyError: + pass + + def onRemoteError(self, code, errobj, request_info): + """def dump(obj): + print "\n\nDUMPING %s\n\n" % obj + for i in dir(obj): + print "%s: %s" % (i, getattr(obj,i))""" + try: + _eb = self.eb[request_info.id] + except KeyError: + if code != 0: + log.error("Internal server error") + """for o in code, error, request_info: + dump(o)""" + else: + if isinstance(errobj['message'], dict): + log.error("Error %s: %s" % (errobj['message']['faultCode'], errobj['message']['faultString'])) + else: + log.error("%s" % errobj['message']) + else: + _eb((code, errobj)) + del self.eb[request_info.id] + + try: + del self.cb[request_info.id] + except KeyError: + pass + + +class RegisterCall(LiberviaJsonProxy): + def __init__(self): + LiberviaJsonProxy.__init__(self, "/register_api", + ["isRegistered", "isConnected", "asyncConnect", "registerParams", "getMenus"]) + + +class BridgeCall(LiberviaJsonProxy): + def __init__(self): + LiberviaJsonProxy.__init__(self, "/json_api", + ["getContacts", "addContact", "sendMessage", "sendMblog", "sendMblogComment", + "getMblogs", "getMassiveMblogs", "getMblogComments", + "getHistory", "getPresenceStatuses", "joinMUC", "mucLeave", "getRoomsJoined", + "getRoomsSubjects", "inviteMUC", "launchTarotGame", "getTarotCardsPaths", "tarotGameReady", + "tarotGamePlayCards", "launchRadioCollective", "getMblogs", "getMblogsWithComments", + "getWaitingSub", "subscription", "delContact", "updateContact", "getCard", + "getEntityData", "getParamsUI", "asyncGetParamA", "setParam", "launchAction", + "disconnect", "chatStateComposing", "getNewAccountDomain", "confirmationAnswer", + "syntaxConvert", "getAccountDialogUI", "getMainResource", "getWaitingConf", "getEntitiesData", + ]) + def __call__(self, *args, **kwargs): + return LiberviaJsonProxy.__call__(self, *args, **kwargs) + + def getConfig(self, dummy1, dummy2): # FIXME + log.warning("getConfig is not implemeted in Libervia yet") + return '' + + def isConnected(self, dummy): # FIXME + log.warning("isConnected is not implemeted in Libervia as for now profile is connected if session is opened") + return True + + def getAvatarFile(self, hash_, callback=None): + log.warning("getAvatarFile only return hash in Libervia") + if callback is not None: + callback(hash_) + return hash_ + + +class BridgeSignals(LiberviaJsonProxy): + RETRY_BASE_DELAY = 1000 + + def __init__(self, host): + self.host = host + self.retry_delay = self.RETRY_BASE_DELAY + LiberviaJsonProxy.__init__(self, "/json_signal_api", + ["getSignals"]) + self._signals = {} # key: signal name, value: callback + + def onRemoteResponse(self, response, request_info): + if self.retry_delay != self.RETRY_BASE_DELAY: + log.info("Connection with server restablished") + self.retry_delay = self.RETRY_BASE_DELAY + LiberviaJsonProxy.onRemoteResponse(self, response, request_info) + + def onRemoteError(self, code, errobj, request_info): + if errobj['message'] == 'Empty Response': + log.warning(u"Empty reponse bridgeSignal\ncode={}\nrequest_info: id={} method={} handler={}".format(code, request_info.id, request_info.method, request_info.handler)) + # FIXME: to check/replace by a proper session end on disconnected signal + # Window.getLocation().reload() # XXX: reset page in case of session ended. + # FIXME: Should be done more properly without hard reload + LiberviaJsonProxy.onRemoteError(self, code, errobj, request_info) + #we now try to reconnect + if isinstance(errobj['message'], dict) and errobj['message']['faultCode'] == 0: + Window.alert('You are not allowed to connect to server') + else: + def _timerCb(timer): + log.info("Trying to reconnect to server...") + self.getSignals(callback=self.signalHandler) + # TODO: display a notification to user + log.warning("Lost connection, trying to reconnect in {} s".format(self.retry_delay/1000)) + Timer(notify=_timerCb).schedule(self.retry_delay) + self.retry_delay *= 2 + + def register(self, name, callback, with_profile=True): + """Register a signal + + @param: name of the signal to register + @param callback: method to call + @param with_profile: True if the original bridge method need a profile + """ + log.debug("Registering signal {}".format(name)) + if name in self._signals: + log.error("Trying to register and already registered signal ({})".format(name)) + else: + self._signals[name] = (callback, with_profile) + + def signalHandler(self, signal_data): + self.getSignals(callback=self.signalHandler) + if len(signal_data) == 1: + signal_data.append([]) + log.debug("Got signal ==> name: %s, params: %s" % (signal_data[0], signal_data[1])) + name, args = signal_data + try: + callback, with_profile = self._signals[name] + except KeyError: + log.warning("Ignoring {} signal: no handler registered !".format(name)) + return + if with_profile: + args.append(C.PROF_KEY_NONE) + callback(*args) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/libervia_widget.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/libervia_widget.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,824 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +"""Libervia base widget""" + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _ +from sat.core import exceptions +from sat_frontends.quick_frontend import quick_widgets + +from pyjamas.ui.FlexTable import FlexTable +from pyjamas.ui.TabPanel import TabPanel +from pyjamas.ui.SimplePanel import SimplePanel +from pyjamas.ui.AbsolutePanel import AbsolutePanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.HTMLPanel import HTMLPanel +from pyjamas.ui.Label import Label +from pyjamas.ui.HTML import HTML +from pyjamas.ui.Button import Button +from pyjamas.ui.Widget import Widget +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.ui import HasAlignment +from pyjamas.ui.DragWidget import DragWidget +from pyjamas.ui.DropWidget import DropWidget +from pyjamas import DOM +from pyjamas import Window + +import dialog +import base_menu +import base_widget +import base_panel + + +unicode = str # FIXME: pyjamas workaround + + +# FIXME: we need to group several unrelated panels/widgets in this module because of isinstance tests and other references to classes (e.g. if we separate Drag n Drop classes in a separate module, we'll have cyclic import because of the references to LiberviaWidget in DropCell). +# TODO: use a more generic method (either use duck typing, or register classes in a generic way, without hard references), then split classes in separate modules + + +### Drag n Drop ### + + +class DragLabel(DragWidget): + + def __init__(self, text, type_, host=None): + """Base of Drag n Drop mecanism in Libervia + + @param text: data embedded with in drag n drop operation + @param type_: type of data that we are dragging + @param host: if not None, the host will be use to highlight BorderWidgets + """ + DragWidget.__init__(self) + self.host = host + self._text = text + self.type_ = type_ + + def onDragStart(self, event): + dt = event.dataTransfer + dt.setData('text/plain', "%s\n%s" % (self._text, self.type_)) + dt.setDragImage(self.getElement(), 15, 15) + if self.host is not None: + current_panel = self.host.tab_panel.getCurrentPanel() + for widget in current_panel.widgets: + if isinstance(widget, BorderWidget): + widget.addStyleName('borderWidgetOnDrag') + + def onDragEnd(self, event): + if self.host is not None: + current_panel = self.host.tab_panel.getCurrentPanel() + for widget in current_panel.widgets: + if isinstance(widget, BorderWidget): + widget.removeStyleName('borderWidgetOnDrag') + + +class LiberviaDragWidget(DragLabel): + """ A DragLabel which keep the widget being dragged as class value """ + current = None # widget currently dragged + + def __init__(self, text, type_, widget): + DragLabel.__init__(self, text, type_, widget.host) + self.widget = widget + + def onDragStart(self, event): + LiberviaDragWidget.current = self.widget + DragLabel.onDragStart(self, event) + + def onDragEnd(self, event): + DragLabel.onDragEnd(self, event) + LiberviaDragWidget.current = None + + +class DropCell(DropWidget): + """Cell in the middle grid which replace itself with the dropped widget on DnD""" + drop_keys = {} + + def __init__(self, host): + DropWidget.__init__(self) + self.host = host + self.setStyleName('dropCell') + + @classmethod + def addDropKey(cls, key, cb): + """Add a association between a key and a class to create on drop. + + @param key: key to be associated (e.g. "CONTACT", "CHAT") + @param cb: a callable (either a class or method) returning a + LiberviaWidget instance + """ + DropCell.drop_keys[key] = cb + + def onDragEnter(self, event): + if self == LiberviaDragWidget.current: + return + self.addStyleName('dragover') + DOM.eventPreventDefault(event) + + def onDragLeave(self, event): + if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop() or\ + event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1 or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1: + # We check that we are inside widget's box, and we don't remove the style in this case because + # if the mouse is over a widget inside the DropWidget, if will leave the DropWidget, and we + # don't want that + self.removeStyleName('dragover') + + def onDragOver(self, event): + DOM.eventPreventDefault(event) + + def _getCellAndRow(self, grid, event): + """Return cell and row index where the event is occuring""" + cell = grid.getEventTargetCell(event) + row = DOM.getParent(cell) + return (row.rowIndex, cell.cellIndex) + + def onDrop(self, event): + """ + @raise NoLiberviaWidgetException: something else than a LiberviaWidget + has been returned by the callback. + """ + self.removeStyleName('dragover') + DOM.eventPreventDefault(event) + dt = event.dataTransfer + # 'text', 'text/plain', and 'Text' are equivalent. + try: + item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed + if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and + item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report + # item_type = dt.getData("type") + log.debug("message: %s" % item) + log.debug("type: %s" % item_type) + except: + log.debug("no message found") + item = ' ' + item_type = None + if item_type == "WIDGET": + if not LiberviaDragWidget.current: + log.error("No widget registered in LiberviaDragWidget !") + return + _new_panel = LiberviaDragWidget.current + if self == _new_panel: # We can't drop on ourself + return + # we need to remove the widget from the panel as it will be inserted elsewhere + widgets_panel = _new_panel.getParent(WidgetsPanel, expect=True) + wid_row = widgets_panel.getWidgetCoords(_new_panel)[0] + row_wids = widgets_panel.getLiberviaRowWidgets(wid_row) + if len(row_wids) == 1 and wid_row == widgets_panel.getWidgetCoords(self)[0]: + # the dropped widget is the only one in the same row + # as the target widget (self), we don't do anything + return + widgets_panel.removeWidget(_new_panel) + elif item_type in self.drop_keys: + _new_panel = self.drop_keys[item_type](self.host, item) + if not isinstance(_new_panel, LiberviaWidget): + raise base_widget.NoLiberviaWidgetException + else: + log.warning("unmanaged item type") + return + if isinstance(self, LiberviaWidget): + # self.host.unregisterWidget(self) # FIXME + self.onQuit() + if not isinstance(_new_panel, LiberviaWidget): + log.warning("droping an object which is not a class of LiberviaWidget") + _flextable = self.getParent() + _widgetspanel = _flextable.getParent().getParent() + row_idx, cell_idx = self._getCellAndRow(_flextable, event) + if self.host.getSelected() == self: + self.host.setSelected(None) + _widgetspanel.changeWidget(row_idx, cell_idx, _new_panel) + """_unempty_panels = filter(lambda wid:not isinstance(wid,EmptyWidget),list(_flextable)) + _width = 90/float(len(_unempty_panels) or 1) + #now we resize all the cell of the column + for panel in _unempty_panels: + td_elt = panel.getElement().parentNode + DOM.setStyleAttribute(td_elt, "width", "%s%%" % _width)""" + if isinstance(self, quick_widgets.QuickWidget): + self.host.widgets.deleteWidget(self) + + +class EmptyWidget(DropCell, SimplePanel): + """Empty dropable panel""" + + def __init__(self, host): + SimplePanel.__init__(self) + DropCell.__init__(self, host) + #self.setWidget(HTML('')) + self.setSize('100%', '100%') + + +class BorderWidget(EmptyWidget): + def __init__(self, host): + EmptyWidget.__init__(self, host) + self.addStyleName('borderPanel') + + +class LeftBorderWidget(BorderWidget): + def __init__(self, host): + BorderWidget.__init__(self, host) + self.addStyleName('leftBorderWidget') + + +class RightBorderWidget(BorderWidget): + def __init__(self, host): + BorderWidget.__init__(self, host) + self.addStyleName('rightBorderWidget') + + +class BottomBorderWidget(BorderWidget): + def __init__(self, host): + BorderWidget.__init__(self, host) + self.addStyleName('bottomBorderWidget') + + +class DropTab(Label, DropWidget): + + def __init__(self, tab_panel, text): + Label.__init__(self, text) + DropWidget.__init__(self, tab_panel) + self.tab_panel = tab_panel + self.setStyleName('dropCell') + self.setWordWrap(False) + + def _getIndex(self): + """ get current index of the DropTab """ + # XXX: awful hack, but seems the only way to get index + return self.tab_panel.tabBar.panel.getWidgetIndex(self.getParent().getParent()) - 1 + + def onDragEnter(self, event): + #if self == LiberviaDragWidget.current: + # return + self.parent.addStyleName('dragover') + DOM.eventPreventDefault(event) + + def onDragLeave(self, event): + self.parent.removeStyleName('dragover') + + def onDragOver(self, event): + DOM.eventPreventDefault(event) + + def onDrop(self, event): + DOM.eventPreventDefault(event) + self.parent.removeStyleName('dragover') + if self._getIndex() == self.tab_panel.tabBar.getSelectedTab(): + # the widget comes from the same tab, so nothing to do, we let it there + return + + # FIXME: quite the same stuff as in DropCell, need some factorisation + dt = event.dataTransfer + # 'text', 'text/plain', and 'Text' are equivalent. + try: + item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed + if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and + item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report + # item_type = dt.getData("type") + log.debug("message: %s" % item) + log.debug("type: %s" % item_type) + except: + log.debug("no message found") + item = ' ' + item_type = None + + if item_type == "WIDGET": + if not LiberviaDragWidget.current: + log.error("No widget registered in LiberviaDragWidget !") + return + _new_panel = LiberviaDragWidget.current + elif item_type in DropCell.drop_keys: + pass # create the widget when we are sure there's a tab for it + else: + log.warning("unmanaged item type") + return + + # XXX: when needed, new tab creation must be done exactly here to not mess up with LiberviaDragWidget.onDragEnd + try: + widgets_panel = self.tab_panel.getWidget(self._getIndex()) + except IndexError: # widgets panel doesn't exist, e.g. user dropped in "+" tab + widgets_panel = self.tab_panel.addWidgetsTab(None) + if widgets_panel is None: # user cancelled + return + + if item_type == "WIDGET": + _new_panel.getParent(WidgetsPanel, expect=True).removeWidget(_new_panel) + else: + _new_panel = DropCell.drop_keys[item_type](self.tab_panel.host, item) + + widgets_panel.addWidget(_new_panel) + + +### Libervia Widget ### + + +class WidgetHeader(AbsolutePanel, LiberviaDragWidget): + + def __init__(self, parent, host, title, info=None): + """ + @param parent (LiberviaWidget): LiberWidget instance + @param host (SatWebFrontend): SatWebFrontend instance + @param title (Label, HTML): text widget instance + @param info (Widget): text widget instance + """ + AbsolutePanel.__init__(self) + self.add(title) + if info: + # FIXME: temporary design to display the info near the menu + button_group_wrapper = HorizontalPanel() + button_group_wrapper.add(info) + else: + button_group_wrapper = SimplePanel() + button_group_wrapper.setStyleName('widgetHeader_buttonsWrapper') + button_group = base_widget.WidgetMenuBar(parent, host) + button_group.addItem('', True, base_menu.SimpleCmd(parent.onSetting)) + button_group.addItem('', True, base_menu.SimpleCmd(parent.onClose)) + button_group_wrapper.add(button_group) + self.add(button_group_wrapper) + self.addStyleName('widgetHeader') + LiberviaDragWidget.__init__(self, "", "WIDGET", parent) + + +class LiberviaWidget(DropCell, VerticalPanel, ClickHandler): + """Libervia's widget which can replace itself with a dropped widget on DnD""" + + def __init__(self, host, title='', info=None, selectable=False, plugin_menu_context=None): + """Init the widget + + @param host (SatWebFrontend): SatWebFrontend instance + @param title (unicode): title shown in the header of the widget + @param info (unicode): info shown in the header of the widget + @param selectable (bool): True is widget can be selected by user + @param plugin_menu_context (iterable): contexts of menus to have (list of C.MENU_* constant) + """ + VerticalPanel.__init__(self) + DropCell.__init__(self, host) + ClickHandler.__init__(self) + self._selectable = selectable + self._plugin_menu_context = [] if plugin_menu_context is None else plugin_menu_context + self._title_id = HTMLPanel.createUniqueId() + self._setting_button_id = HTMLPanel.createUniqueId() + self._close_button_id = HTMLPanel.createUniqueId() + self._title = Label(title) + self._title.setStyleName('widgetHeader_title') + if info is not None: + self._info = HTML(info) + self._info.setStyleName('widgetHeader_info') + else: + self._info = None + header = WidgetHeader(self, host, self._title, self._info) + self.add(header) + self.setSize('100%', '100%') + self.addStyleName('widget') + if self._selectable: + self.addClickListener(self) + + # FIXME + # def onClose(sender): + # """Check dynamically if the unibox is enable or not""" + # if self.host.uni_box: + # self.host.uni_box.onWidgetClosed(sender) + + # self.addCloseListener(onClose) + # self.host.registerWidget(self) # FIXME + + @property + def plugin_menu_context(self): + return self._plugin_menu_context + + def getDebugName(self): + return "%s (%s)" % (self, self._title.getText()) + + def getParent(self, class_=None, expect=True): + """Return the closest ancestor of the specified class. + + Note: this method overrides pyjamas.ui.Widget.getParent + + @param class_: class of the ancestor to look for or None to return the first parent + @param expect: set to True if the parent is expected (raise an error if not found) + @return: the parent/ancestor or None if it has not been found + @raise exceptions.InternalError: expect is True and no parent is found + """ + current = Widget.getParent(self) + if class_ is None: + return current # this is the default behavior + while current is not None and not isinstance(current, class_): + current = Widget.getParent(current) + if current is None and expect: + raise exceptions.InternalError("Can't find parent %s for %s" % (class_, self)) + return current + + def onClick(self, sender): + self.host.setSelected(self) + + def onClose(self, sender): + """ Called when the close button is pushed """ + widgets_panel = self.getParent(WidgetsPanel, expect=True) + widgets_panel.removeWidget(self) + self.onQuit() + self.host.widgets.deleteWidget(self) + + def onQuit(self): + """ Called when the widget is actually ending """ + pass + + def refresh(self): + """This can be overwritten by a child class to refresh the display when, + instead of creating a new one, an existing widget is found and reused. + """ + pass + + def onSetting(self, sender): + widpanel = self.getParent(WidgetsPanel, expect=True) + row, col = widpanel.getIndex(self) + body = VerticalPanel() + + # colspan & rowspan + colspan = widpanel.getColSpan(row, col) + rowspan = widpanel.getRowSpan(row, col) + + def onColSpanChange(value): + widpanel.setColSpan(row, col, value) + + def onRowSpanChange(value): + widpanel.setRowSpan(row, col, value) + colspan_setter = dialog.IntSetter("Columns span", colspan) + colspan_setter.addValueChangeListener(onColSpanChange) + colspan_setter.setWidth('100%') + rowspan_setter = dialog.IntSetter("Rows span", rowspan) + rowspan_setter.addValueChangeListener(onRowSpanChange) + rowspan_setter.setWidth('100%') + body.add(colspan_setter) + body.add(rowspan_setter) + + # size + width_str = self.getWidth() + if width_str.endswith('px'): + width = int(width_str[:-2]) + else: + width = 0 + height_str = self.getHeight() + if height_str.endswith('px'): + height = int(height_str[:-2]) + else: + height = 0 + + def onWidthChange(value): + if not value: + self.setWidth('100%') + else: + self.setWidth('%dpx' % value) + + def onHeightChange(value): + if not value: + self.setHeight('100%') + else: + self.setHeight('%dpx' % value) + width_setter = dialog.IntSetter("width (0=auto)", width) + width_setter.addValueChangeListener(onWidthChange) + width_setter.setWidth('100%') + height_setter = dialog.IntSetter("height (0=auto)", height) + height_setter.addValueChangeListener(onHeightChange) + height_setter.setHeight('100%') + body.add(width_setter) + body.add(height_setter) + + # reset + def onReset(sender): + colspan_setter.setValue(1) + rowspan_setter.setValue(1) + width_setter.setValue(0) + height_setter.setValue(0) + + reset_bt = Button("Reset", onReset) + body.add(reset_bt) + body.setCellHorizontalAlignment(reset_bt, HasAlignment.ALIGN_CENTER) + + _dialog = dialog.GenericDialog("Widget setting", body) + _dialog.show() + + def setTitle(self, text): + """change the title in the header of the widget + @param text: text of the new title""" + self._title.setText(text) + + def setHeaderInfo(self, text): + """change the info in the header of the widget + @param text: text of the new title""" + try: + self._info.setHTML(text) + except TypeError: + log.error("LiberviaWidget.setInfo: info widget has not been initialized!") + + def isSelectable(self): + return self._selectable + + def setSelectable(self, selectable): + if not self._selectable: + try: + self.removeClickListener(self) + except ValueError: + pass + if self.selectable and not self in self._clickListeners: + self.addClickListener(self) + self._selectable = selectable + + def getWarningData(self): + """ Return exposition warning level when this widget is selected and something is sent to it + This method should be overriden by children + @return: tuple (warning level type/HTML msg). Type can be one of: + - PUBLIC + - GROUP + - ONE2ONE + - MISC + - NONE + """ + if not self._selectable: + log.error("getWarningLevel must not be called for an unselectable widget") + raise Exception + # TODO: cleaner warning types (more general constants) + return ("NONE", None) + + def setWidget(self, widget, scrollable=True): + """Set the widget that will be in the body of the LiberviaWidget + @param widget: widget to put in the body + @param scrollable: if true, the widget will be in a ScrollPanelWrapper""" + if scrollable: + _scrollpanelwrapper = base_panel.ScrollPanelWrapper() + _scrollpanelwrapper.setStyleName('widgetBody') + _scrollpanelwrapper.setWidget(widget) + body_wid = _scrollpanelwrapper + else: + body_wid = widget + self.add(body_wid) + self.setCellHeight(body_wid, '100%') + + def doDetachChildren(self): + # We need to force the use of a panel subclass method here, + # for the same reason as doAttachChildren + VerticalPanel.doDetachChildren(self) + + def doAttachChildren(self): + # We need to force the use of a panel subclass method here, else + # the event will not propagate to children + VerticalPanel.doAttachChildren(self) + + +# XXX: WidgetsPanel and MainTabPanel are both here to avoir cyclic import + + +class WidgetsPanel(base_panel.ScrollPanelWrapper): + """The panel wanaging the widgets indide a tab""" + + def __init__(self, host, locked=False): + """ + + @param host (SatWebFrontend): host instance + @param locked (bool): If True, the tab containing self will not be + removed when there are no more widget inside self. If False, the + tab will be removed with self's last widget. + """ + base_panel.ScrollPanelWrapper.__init__(self) + self.setSize('100%', '100%') + self.host = host + self.locked = locked + self.selected = None + self.flextable = FlexTable() + self.flextable.setSize('100%', '100%') + self.setWidget(self.flextable) + self.setStyleName('widgetsPanel') + _bottom = BottomBorderWidget(self.host) + self.flextable.setWidget(0, 0, _bottom) # There will be always an Empty widget on the last row, + # dropping a widget there will add a new row + td_elt = _bottom.getElement().parentNode + DOM.setStyleAttribute(td_elt, "height", "1px") # needed so the cell adapt to the size of the border (specially in webkit) + self._max_cols = 1 # give the maximum number of columns in a raw + + @property + def widgets(self): + return iter(self.flextable) + + def isLocked(self): + return self.locked + + def changeWidget(self, row, col, wid): + """Change the widget in the given location, add row or columns when necessary""" + log.debug("changing widget: %s %s %s" % (wid.getDebugName(), row, col)) + last_row = max(0, self.flextable.getRowCount() - 1) + # try: # FIXME: except without exception specified ! + prev_wid = self.flextable.getWidget(row, col) + # except: + # log.error("Trying to change an unexisting widget !") + # return + + cellFormatter = self.flextable.getFlexCellFormatter() + + if isinstance(prev_wid, BorderWidget): + # We are on a border, we must create a row and/or columns + prev_wid.removeStyleName('dragover') + + if isinstance(prev_wid, BottomBorderWidget): + # We are on the bottom border, we create a new row + self.flextable.insertRow(last_row) + self.flextable.setWidget(last_row, 0, LeftBorderWidget(self.host)) + self.flextable.setWidget(last_row, 1, wid) + self.flextable.setWidget(last_row, 2, RightBorderWidget(self.host)) + cellFormatter.setHorizontalAlignment(last_row, 2, HasAlignment.ALIGN_RIGHT) + row = last_row + + elif isinstance(prev_wid, LeftBorderWidget): + if col != 0: + log.error("LeftBorderWidget must be on the first column !") + return + self.flextable.insertCell(row, col + 1) + self.flextable.setWidget(row, 1, wid) + + elif isinstance(prev_wid, RightBorderWidget): + if col != self.flextable.getCellCount(row) - 1: + log.error("RightBorderWidget must be on the last column !") + return + self.flextable.insertCell(row, col) + self.flextable.setWidget(row, col, wid) + + else: + prev_wid.removeFromParent() + self.flextable.setWidget(row, col, wid) + + _max_cols = max(self._max_cols, self.flextable.getCellCount(row)) + if _max_cols != self._max_cols: + self._max_cols = _max_cols + self._sizesAdjust() + + def _sizesAdjust(self): + cellFormatter = self.flextable.getFlexCellFormatter() + width = 100.0 / max(1, self._max_cols - 2) # we don't count the borders + + for row_idx in xrange(self.flextable.getRowCount()): + for col_idx in xrange(self.flextable.getCellCount(row_idx)): + _widget = self.flextable.getWidget(row_idx, col_idx) + if _widget and not isinstance(_widget, BorderWidget): + td_elt = _widget.getElement().parentNode + DOM.setStyleAttribute(td_elt, "width", "%.2f%%" % width) + + last_row = max(0, self.flextable.getRowCount() - 1) + cellFormatter.setColSpan(last_row, 0, self._max_cols) + + def addWidget(self, wid): + """Add a widget to a new cell on the next to last row""" + last_row = max(0, self.flextable.getRowCount() - 1) + log.debug("putting widget %s at %d, %d" % (wid.getDebugName(), last_row, 0)) + self.changeWidget(last_row, 0, wid) + + def removeWidget(self, wid): + """Remove a widget and the cell where it is""" + _row, _col = self.flextable.getIndex(wid) + self.flextable.remove(wid) + self.flextable.removeCell(_row, _col) + if not self.getLiberviaRowWidgets(_row): # we have no more widgets, we remove the row + self.flextable.removeRow(_row) + _max_cols = 1 + for row_idx in xrange(self.flextable.getRowCount()): + _max_cols = max(_max_cols, self.flextable.getCellCount(row_idx)) + if _max_cols != self._max_cols: + self._max_cols = _max_cols + self._sizesAdjust() + current = self + + blank_page = self.getLiberviaWidgetsCount() == 0 # do we still have widgets on the page ? + + if blank_page and not self.isLocked(): + # we now notice the MainTabPanel that the WidgetsPanel is empty and need to be removed + while current is not None: + if isinstance(current, MainTabPanel): + current.onWidgetPanelRemove(self) + return + current = current.getParent() + log.error("no MainTabPanel found !") + + def getWidgetCoords(self, wid): + return self.flextable.getIndex(wid) + + def getLiberviaRowWidgets(self, row): + """ Return all the LiberviaWidget in the row """ + return [wid for wid in self.getRowWidgets(row) if isinstance(wid, LiberviaWidget)] + + def getRowWidgets(self, row): + """ Return all the widgets in the row """ + widgets = [] + cols = self.flextable.getCellCount(row) + for col in xrange(cols): + widgets.append(self.flextable.getWidget(row, col)) + return widgets + + def getLiberviaWidgetsCount(self): + """ Get count of contained widgets """ + return len([wid for wid in self.flextable if isinstance(wid, LiberviaWidget)]) + + def getIndex(self, wid): + return self.flextable.getIndex(wid) + + def getColSpan(self, row, col): + cellFormatter = self.flextable.getFlexCellFormatter() + return cellFormatter.getColSpan(row, col) + + def setColSpan(self, row, col, value): + cellFormatter = self.flextable.getFlexCellFormatter() + return cellFormatter.setColSpan(row, col, value) + + def getRowSpan(self, row, col): + cellFormatter = self.flextable.getFlexCellFormatter() + return cellFormatter.getRowSpan(row, col) + + def setRowSpan(self, row, col, value): + cellFormatter = self.flextable.getFlexCellFormatter() + return cellFormatter.setRowSpan(row, col, value) + + +class MainTabPanel(TabPanel, ClickHandler): + """The panel managing the tabs""" + + def __init__(self, host): + TabPanel.__init__(self, FloatingTab=True) + ClickHandler.__init__(self) + self.host = host + self.setStyleName('liberviaTabPanel') + self.tabBar.addTab(DropTab(self, u'✚'), asHTML=False) + self.tabBar.setVisible(False) # set to True when profile is logged + self.tabBar.addStyleDependentName('oneTab') + + def onTabSelected(self, sender, tabIndex): + if tabIndex < self.getWidgetCount(): + TabPanel.onTabSelected(self, sender, tabIndex) + return + # user clicked the "+" tab + self.addWidgetsTab(None, select=True) + + def getCurrentPanel(self): + """ Get the panel of the currently selected tab + + @return: WidgetsPanel + """ + return self.deck.visibleWidget + + def addTab(self, widget, label, select=False): + """Create a new tab for the given widget. + + @param widget (Widget): widget to associate to the tab + @param label (unicode): label of the tab + @param select (bool): True to select the added tab + """ + TabPanel.add(self, widget, DropTab(self, label), False) + if self.getWidgetCount() > 1: + self.tabBar.removeStyleDependentName('oneTab') + self.host.resize() + if select: + self.selectTab(self.getWidgetCount() - 1) + + def addWidgetsTab(self, label, select=False, locked=False): + """Create a new tab for containing LiberviaWidgets. + + @param label (unicode): label of the tab (None or '' for user prompt) + @param select (bool): True to select the added tab + @param locked (bool): If True, the tab will not be removed when there + are no more widget inside. If False, the tab will be removed with + the last widget. + @return: WidgetsPanel + """ + widgets_panel = WidgetsPanel(self.host, locked=locked) + + if not label: + default_label = _(u'new tab') + try: + label = Window.prompt(_(u'Name of the new tab'), default_label) + if not label: # empty label or user pressed "cancel" + return None + except: # this happens when the user prevents the page to open the prompt dialog + label = default_label + + self.addTab(widgets_panel, label, select) + return widgets_panel + + def onWidgetPanelRemove(self, panel): + """ Called when a child WidgetsPanel is empty and need to be removed """ + widget_index = self.getWidgetIndex(panel) + self.remove(panel) + widgets_count = self.getWidgetCount() + if widgets_count == 1: + self.tabBar.addStyleDependentName('oneTab') + self.host.resize() + self.selectTab(widget_index if widget_index < widgets_count else widgets_count - 1) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/list_manager.py --- a/src/browser/sat_browser/list_manager.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/list_manager.py Wed Mar 18 16:15:18 2015 +0100 @@ -19,253 +19,326 @@ from sat.core.log import getLogger log = getLogger(__name__) -from pyjamas.ui.Grid import Grid from pyjamas.ui.Button import Button -from pyjamas.ui.ListBox import ListBox from pyjamas.ui.FlowPanel import FlowPanel from pyjamas.ui.AutoComplete import AutoCompleteTextBox -from pyjamas.ui.Label import Label -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.DialogBox import DialogBox from pyjamas.ui.KeyboardListener import KEY_ENTER -from pyjamas.ui.MouseListener import MouseHandler -from pyjamas.ui.FocusListener import FocusHandler -from pyjamas.ui.DropWidget import DropWidget +from pyjamas.ui.DragWidget import DragWidget from pyjamas.Timer import Timer -from pyjamas import DOM + +import base_panel +import base_widget +import libervia_widget -import base_panels -import base_widget +from sat_frontends.tools import jid + + +unicode = str # FIXME: pyjamas workaround # HTML content for the removal button (image or text) -REMOVE_BUTTON = 'x' +REMOVE_BUTTON = 'x' + -# Item to be considered for an empty list box selection. -# Could be whatever which doesn't look like a JID or a group name. -EMPTY_SELECTION_ITEM = "" +# FIXME: dirty method and magic string to fix ASAP +def tryJID(obj): + return jid.JID(obj) if (isinstance(obj, unicode) and not obj.startswith('@')) else obj -class ListManager(): - """A manager for sub-panels to assign elements to lists.""" +class ListManager(object): + """A base class to manage one or several lists of items.""" - def __init__(self, parent, keys_dict={}, contact_list=[], offsets={}, style={}): - """ - @param parent: FlexTable parent widget for the manager - @param keys_dict: dict with the contact keys mapped to data - @param contact_list: list of string (the contact JID userhosts) - @param offsets: dict to set widget positions offset within parent - - "x_first": the x offset for the first widget's row on the grid - - "x": the x offset for all widgets rows, except the first one if "x_first" is defined - - "y": the y offset for all widgets columns on the grid + def __init__(self, container, keys=None, items=None, offsets=None, style=None): """ - self._parent = parent - if isinstance(keys_dict, set) or isinstance(keys_dict, list): - tmp = {} - for key in keys_dict: - tmp[key] = {} - keys_dict = tmp - self.__keys_dict = keys_dict - if isinstance(contact_list, set): - contact_list = list(contact_list) - self.__list = contact_list - self.__list.sort() - # store the list of contacts that are not assigned yet - self.__remaining_list = [] - self.__remaining_list.extend(self.__list) - # mark a change to sort the list before it's used - self.__remaining_list_sorted = True + @param container (FlexTable): FlexTable parent widget + @param keys (dict{unicode: dict{unicode: unicode}}): dict binding items + keys to their display config data. + @param items (list): list of items + @param offsets (dict): define widgets positions offsets within container: + - "x_first": the x offset for the first widget's row on the grid + - "x": the x offset for all widgets rows, except the first one if "x_first" is defined + - "y": the y offset for all widgets columns on the grid + @param style (dict): define CSS styles + """ + self.container = container + self.keys = {} if keys is None else keys + self.items = [] if items is None else items + self.items.sort() + + # store the list of items that are not assigned yet + self.items_remaining = [item for item in self.items] + self.items_remaining_sorted = True self.offsets = {"x_first": 0, "x": 0, "y": 0} - if "x" in offsets and not "x_first" in offsets: - offsets["x_first"] = offsets["x"] - self.offsets.update(offsets) + if offsets is not None: + if "x" in offsets and "x_first" not in offsets: + offsets["x_first"] = offsets["x"] + self.offsets.update(offsets) - self.style = { - "keyItem": "recipientTypeItem", - "popupMenuItem": "recipientTypeItem", - "buttonCell": "recipientButtonCell", - "dragoverPanel": "dragover-recipientPanel", - "keyPanel": "recipientPanel", - "textBox": "recipientTextBox", - "textBox-invalid": "recipientTextBox-invalid", - "removeButton": "recipientRemoveButton", - } - self.style.update(style) + self.style = {"keyItem": "itemKey", + "popupMenuItem": "itemKey", + "buttonCell": "itemButtonCell", + "keyPanel": "itemPanel", + "textBox": "itemTextBox", + "textBox-invalid": "itemTextBox-invalid", + "removeButton": "itemRemoveButton", + } + if style is not None: + self.style.update(style) def createWidgets(self, title_format="%s"): - """Fill the parent grid with all the widgets (some may be hidden during the initialization).""" - self.__children = {} - for key in self.__keys_dict: - self.addContactKey(key, title_format=title_format) + """Fill the container widget with one ListPanel per item key (some may be + hidden during the initialization). - def addContactKey(self, key, dict_={}, title_format="%s"): - if key not in self.__keys_dict: - self.__keys_dict[key] = dict_ - # copy the key to its associated sub-map - self.__keys_dict[key]["title"] = key - self._addChild(self.__keys_dict[key], title_format) + @param title_format (unicode): format string for the title + """ + self.children = {} + for key in self.keys: + self.addItemKey(key, title_format=title_format) + + def addItemKey(self, key, data=None, title_format="%s"): + """Add to the container a Button and ListPanel for a new item key. - def removeContactKey(self, key): - """Remove a list panel and all its associated data.""" - contacts = self.__children[key]["panel"].getContacts() - (y, x) = self._parent.getIndex(self.__children[key]["button"]) - self._parent.removeRow(y) - del self.__children[key] - del self.__keys_dict[key] - self.addToRemainingList(contacts) + @param key (unicode): item key + @param data (dict{unicode: unicode}): config data + """ + key_data = self.keys.setdefault(key, {}) + if data is not None: + key_data.update(data) + key_data["title"] = key # copy the key to its associated sub-map - def _addChild(self, entry, title_format): - """Add a button and FlowPanel for the corresponding map entry.""" - button = Button(title_format % entry["title"]) + button = Button(title_format % key) button.setStyleName(self.style["keyItem"]) - if hasattr(entry, "desc"): - button.setTitle(entry["desc"]) - if not "optional" in entry: - entry["optional"] = False - button.setVisible(not entry["optional"]) - y = len(self.__children) + self.offsets["y"] + if hasattr(key_data, "desc"): + button.setTitle(key_data["desc"]) + if "optional" not in key_data: + key_data["optional"] = False + button.setVisible(not key_data["optional"]) + y = len(self.children) + self.offsets["y"] x = self.offsets["x_first"] if y == self.offsets["y"] else self.offsets["x"] - self._parent.insertRow(y) - self._parent.setWidget(y, x, button) - self._parent.getCellFormatter().setStyleName(y, x, self.style["buttonCell"]) + self.container.insertRow(y) + self.container.setWidget(y, x, button) + self.container.getCellFormatter().setStyleName(y, x, self.style["buttonCell"]) - _child = ListPanel(self, entry, self.style) - self._parent.setWidget(y, x + 1, _child) + _child = ListPanel(self, key_data, self.style) + self.container.setWidget(y, x + 1, _child) - self.__children[entry["title"]] = {} - self.__children[entry["title"]]["button"] = button - self.__children[entry["title"]]["panel"] = _child + self.children[key] = {} + self.children[key]["button"] = button + self.children[key]["panel"] = _child if hasattr(self, "popup_menu"): - # this is done if self.registerPopupMenuPanel has been called yet + # self.registerPopupMenuPanel has been called yet self.popup_menu.registerClickSender(button) - def _refresh(self, visible=True): - """Set visible the sub-panels that are non optional or non empty, hide the rest.""" - for key in self.__children: - self.setContactPanelVisible(key, False) - if not visible: + def removeItemKey(self, key): + """Remove from the container a ListPanel representing an item key, and all + its associated data. + + @param key (unicode): item key + """ + items = self.children[key]["panel"].getItems() + (y, x) = self.container.getIndex(self.children[key]["button"]) + self.container.removeRow(y) + del self.children[key] + del self.keys[key] + self.addToRemainingList(items) + + def refresh(self, hide_everything=False): + """Set visible the sub-panels that are non optional or non empty, hide + the rest. Setting the attribute "hide_everything" to True you can also + hide everything. + + @param hide_everything (boolean): set to True to hide everything + """ + for key in self.children: + self.setItemPanelVisible(key, False) + if hide_everything: return - _map = self.getContacts() - for key in _map: - if len(_map[key]) > 0 or not self.__keys_dict[key]["optional"]: - self.setContactPanelVisible(key, True) + for key, items in self.getItemsByKey().iteritems(): + if len(items) > 0 or not self.keys[key]["optional"]: + self.setItemPanelVisible(key, True) def setVisible(self, visible): - self._refresh(visible) + self.refresh(not visible) + + def setItemPanelVisible(self, key, visible=True, sender=None): + """Set the item key's widgets visibility. - def setContactPanelVisible(self, key, visible=True, sender=None): - """Do not remove the "sender" param as it is needed for the context menu.""" - self.__children[key]["button"].setVisible(visible) - self.__children[key]["panel"].setVisible(visible) - - @property - def list(self): - """Return the full list of potential contacts.""" - return self.__list + @param key (unicode): item key + @param visible (bool): set to True to display the widgets + @param sender + """ + self.children[key]["button"].setVisible(visible) + self.children[key]["panel"].setVisible(visible) @property - def keys(self): - return self.__keys_dict.keys() - - @property - def keys_dict(self): - return self.__keys_dict - - @property - def remaining_list(self): - """Return the contacts that have not been selected yet.""" - if not self.__remaining_list_sorted: - self.__remaining_list_sorted = True - self.__remaining_list.sort() - return self.__remaining_list + def items_remaining(self): + """Return the unused items.""" + if not self.items_remaining_sorted: + self.items_remaining.sort() + self.items_remaining_sorted = True + return self.items_remaining def setRemainingListUnsorted(self): - """Mark a change (deletion) so the list will be sorted before it's used.""" - self.__remaining_list_sorted = False + """Mark the list of unused items as being unsorted.""" + self.items_remaining_sorted = False + + def removeFromRemainingList(self, items): + """Remove some items from the list of unused items. - def removeFromRemainingList(self, contacts): - """Remove contacts after they have been added to a sub-panel.""" - if not isinstance(contacts, list): - contacts = [contacts] - for contact_ in contacts: - if contact_ in self.__remaining_list: - self.__remaining_list.remove(contact_) + @param items (list): items to be removed + """ + for item in items: + if item in self.items_remaining: + self.items_remaining.remove(item) - def addToRemainingList(self, contacts, ignore_key=None): - """Add contacts after they have been removed from a sub-panel.""" - if not isinstance(contacts, list): - contacts = [contacts] - assigned_contacts = set() - assigned_map = self.getContacts() - for key_ in assigned_map.keys(): - if ignore_key is not None and key_ == ignore_key: + def addToRemainingList(self, items, ignore_key=None): + """Add some items to the list of unused items. Check first if the + items are really not used in any ListPanel. + + @param items (list): items to be removed + @param ignore_key (unicode): item key to be ignored while checking + """ + items_assigned = set() + for key, current_items in self.getItemsByKey().iteritems(): + if ignore_key is not None and key == ignore_key: continue - assigned_contacts.update(assigned_map[key_]) - for contact_ in contacts: - if contact_ not in self.__list or contact_ in self.__remaining_list: + items_assigned.update(current_items) + for item in items: + if item not in self.items or item in self.items_remaining or item in items_assigned: continue - if contact_ in assigned_contacts: - continue # the contact is assigned somewhere else - self.__remaining_list.append(contact_) + self.items_remaining.append(item) self.setRemainingListUnsorted() - def setContacts(self, _map={}): - """Set the contacts for each contact key.""" - for key in self.__keys_dict: - if key in _map: - self.__children[key]["panel"].setContacts(_map[key]) + def resetItems(self, data={}): + """Repopulate all the lists (one per item key) with the given items. + + @param data (dict{unicode: list}): dict binding items keys to items. + """ + for key in self.keys: + if key in data: + self.children[key]["panel"].resetItems(data[key]) else: - self.__children[key]["panel"].setContacts([]) - self._refresh() + self.children[key]["panel"].resetItems([]) + self.refresh() + + def getItemsByKey(self): + """Get all the items by key. - def getContacts(self): - """Get the contacts for all the lists. - @return: a mapping between keys and contact lists.""" - _map = {} - for key in self.__children: - _map[key] = self.__children[key]["panel"].getContacts() - return _map + @return: dict{unicode: set} + """ + return {key: self.children[key]["panel"].getItems() for key in self.children} + + def getKeysByItem(self): + """Get all the keys by item. - @property - def target_drop_cell(self): - """@return: the panel where something has been dropped.""" - return self._target_drop_cell - - def setTargetDropCell(self, target_drop_cell): - """@param: target_drop_cell: the panel where something has been dropped.""" - self._target_drop_cell = target_drop_cell + @return: dict{object: set(unicode)} + """ + result = {} + for key in self.children: + for item in self.children[key]["panel"].getItems(): + result.setdefault(item, set()).add(key) + return result def registerPopupMenuPanel(self, entries, hide, callback): - "Register a popup menu panel that will be bound to all contact keys elements." - self.popup_menu = base_panels.PopupMenuPanel(entries=entries, hide=hide, callback=callback, style={"item": self.style["popupMenuItem"]}) + """Register a popup menu panel for the item keys buttons. + + @param entries (dict{unicode: dict{unicode: unicode}}): menu entries + @param hide (callable): method to call in order to know if a menu item + should be hidden from the menu. Takes in the button widget and the + item key and returns a boolean. + @param callback (callable): common callback for all menu items, takes in + the button widget and the item key. + """ + self.popup_menu = base_panel.PopupMenuPanel(entries, hide, callback, style={"item": self.style["popupMenuItem"]}) -class DragAutoCompleteTextBox(AutoCompleteTextBox, base_widget.DragLabel, MouseHandler, FocusHandler): - """A draggable AutoCompleteTextBox which is used for representing a contact. - This class is NOT generic because of the onDragEnd method which call methods - from ListPanel. It's probably not reusable for another scenario. - """ +class DragAutoCompleteTextBox(AutoCompleteTextBox, DragWidget): + """A draggable AutoCompleteTextBox which is used for representing an item.""" + + def __init__(self, list_panel, event_cbs, style): + """ - def __init__(self, parent, event_cbs, style): + @param list_panel (ListPanel) + @param event_cbs (list[callable]) + @param style (dict) + """ AutoCompleteTextBox.__init__(self) - base_widget.DragLabel.__init__(self, '', 'CONTACT_TEXTBOX') # The group prefix "@" is already in text so we use only the "CONTACT_TEXTBOX" type - self._parent = parent + DragWidget.__init__(self) + self.list_panel = list_panel self.event_cbs = event_cbs self.style = style + self.addStyleName(style["textBox"]) + self.reset() + + # Parent classes already init self as an handler for these events self.addMouseListener(self) self.addFocusListener(self) self.addChangeListener(self) - self.addStyleName(style["textBox"]) - self.reset() + + def onDragStart(self, event): + """The user starts dragging the text box.""" + self.list_panel.manager.target_drop_cell = None + self.setSelectionRange(len(self.getText()), 0) + + dt = event.dataTransfer + dt.setData('text/plain', "%s\n%s" % (self.getText(), "CONTACT_TEXTBOX")) + dt.setDragImage(self.getElement(), 15, 15) + + def onDragEnd(self, event): + """The user dropped the text box.""" + target = self.list_panel.manager.target_drop_cell # parent or another ListPanel + if self.getText() == "" or target is None: + return + self.event_cbs["drop"](self, target) + + def onClick(self, sender): + """The choices list is clicked""" + assert sender == self.choices + AutoCompleteTextBox.onClick(self, sender) + self.validate() + + def onChange(self, sender): + """The list selection or the text has been changed""" + assert sender == self.choices or sender == self + if sender == self.choices: + AutoCompleteTextBox.onChange(self, sender) + self.validate() + + def onKeyUp(self, sender, keycode, modifiers): + """Listen for key stroke""" + assert sender == self + AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers) + if keycode == KEY_ENTER: + self.validate() + + def onMouseMove(self, sender): + """Mouse enters the area of a DragAutoCompleteTextBox.""" + assert sender == self + if hasattr(sender, "remove_btn"): + sender.remove_btn.setVisible(True) + + def onMouseLeave(self, sender): + """Mouse leaves the area of a DragAutoCompleteTextBox.""" + assert sender == self + if hasattr(sender, "remove_btn"): + Timer(1500, lambda timer: sender.remove_btn.setVisible(False)) + + def onFocus(self, sender): + """The DragAutoCompleteTextBox has the focus.""" + assert sender == self + # FIXME: this raises runtime JS error "Permission denied to access property..." when you drag the object + #sender.setSelectionRange(0, len(sender.getText())) + sender.event_cbs["focus"](sender) def reset(self): + """Reset the text box""" self.setText("") self.setValid() def setValid(self, valid=True): + """Change the style according to the text validity.""" if self.getText() == "": valid = True if valid: @@ -274,138 +347,82 @@ self.addStyleName(self.style["textBox-invalid"]) self.valid = valid - def onDragStart(self, event): - self._text = self.getText() - base_widget.DragLabel.onDragStart(self, event) - self._parent.setTargetDropCell(None) + def validate(self): + """Check if the text is valid, update the style.""" self.setSelectionRange(len(self.getText()), 0) - - def onDragEnd(self, event): - target = self._parent.target_drop_cell # parent or another ListPanel - if self.getText() == "" or target is None: - return - self.event_cbs["drop"](self, target) + self.event_cbs["validate"](self) def setRemoveButton(self): + """Add the remove button after the text box.""" def remove_cb(sender): - """Callback for the button to remove this contact.""" - self._parent.remove(self) - self._parent.remove(self.remove_btn) + """Callback for the button to remove this item.""" + self.list_panel.remove(self) + self.list_panel.remove(self.remove_btn) self.event_cbs["remove"](self) self.remove_btn = Button(REMOVE_BUTTON, remove_cb, Visible=False) self.remove_btn.setStyleName(self.style["removeButton"]) - self._parent.add(self.remove_btn) + self.list_panel.add(self.remove_btn) def removeOrReset(self): + """Remove the text box if the remove button exists, or reset the text box.""" if hasattr(self, "remove_btn"): self.remove_btn.click() else: self.reset() - def onMouseMove(self, sender): - """Mouse enters the area of a DragAutoCompleteTextBox.""" - if hasattr(sender, "remove_btn"): - sender.remove_btn.setVisible(True) - - def onMouseLeave(self, sender): - """Mouse leaves the area of a DragAutoCompleteTextBox.""" - if hasattr(sender, "remove_btn"): - Timer(1500, lambda timer: sender.remove_btn.setVisible(False)) - - def onFocus(self, sender): - sender.setSelectionRange(0, len(self.getText())) - self.event_cbs["focus"](sender) - - def validate(self): - self.setSelectionRange(len(self.getText()), 0) - self.event_cbs["validate"](self) - - def onChange(self, sender): - """The textbox or list selection is changed""" - if isinstance(sender, ListBox): - AutoCompleteTextBox.onChange(self, sender) - self.validate() - - def onClick(self, sender): - """The list is clicked""" - AutoCompleteTextBox.onClick(self, sender) - self.validate() - - def onKeyUp(self, sender, keycode, modifiers): - """Listen for ENTER key stroke""" - AutoCompleteTextBox.onKeyUp(self, sender, keycode, modifiers) - if keycode == KEY_ENTER: - self.validate() - - -class DropCell(DropWidget): - """A cell where you can drop widgets. This class is NOT generic because of - onDrop which uses methods from ListPanel. It has been created to - separate the drag and drop methods from the others and add a bit of - lisibility, but it's probably not reusable for another scenario. - """ - - def __init__(self, drop_cbs): - DropWidget.__init__(self) - self.drop_cbs = drop_cbs - - def onDragEnter(self, event): - self.addStyleName(self.style["dragoverPanel"]) - DOM.eventPreventDefault(event) - - def onDragLeave(self, event): - if event.clientX <= self.getAbsoluteLeft() or event.clientY <= self.getAbsoluteTop()\ - or event.clientX >= self.getAbsoluteLeft() + self.getOffsetWidth() - 1\ - or event.clientY >= self.getAbsoluteTop() + self.getOffsetHeight() - 1: - # We check that we are inside widget's box, and we don't remove the style in this case because - # if the mouse is over a widget inside the DropWidget, we don't want the style to be removed - self.removeStyleName(self.style["dragoverPanel"]) - - def onDragOver(self, event): - DOM.eventPreventDefault(event) - - def onDrop(self, event): - DOM.eventPreventDefault(event) - dt = event.dataTransfer - # 'text', 'text/plain', and 'Text' are equivalent. - item, item_type = dt.getData("text/plain").split('\n') # Workaround for webkit, only text/plain seems to be managed - if item_type and item_type[-1] == '\0': # Workaround for what looks like a pyjamas bug: the \0 should not be there, and - item_type = item_type[:-1] # .strip('\0') and .replace('\0','') don't work. TODO: check this and fill a bug report - if item_type in self.drop_cbs.keys(): - self.drop_cbs[item_type](self, item) - self.removeStyleName(self.style["dragoverPanel"]) - VALID = 1 INVALID = 2 DELETE = 3 -class ListPanel(FlowPanel, DropCell): - """Sub-panel used for each contact key. Beware that pyjamas.ui.FlowPanel - is not fully implemented yet and can not be used with pyjamas.ui.Label.""" +class ListPanel(FlowPanel, libervia_widget.DropCell): + """Panel used for listing items sharing the same key. The key is showed as + a Button to which you can bind a popup menu and the items are represented + with a sequence of DragAutoCompleteTextBox.""" + # XXX: beware that pyjamas.ui.FlowPanel is not fully implemented yet and can not be used with pyjamas.ui.Label + + def __init__(self, manager, data, style={}): + """Initialization with a button and a DragAutoCompleteTextBox. - def __init__(self, parent, entry, style={}): - """Initialization with a button and a DragAutoCompleteTextBox.""" - FlowPanel.__init__(self, Visible=(False if entry["optional"] else True)) - drop_cbs = {"GROUP": lambda panel, item: self.addContact("@%s" % item), - "CONTACT": lambda panel, item: self.addContact(item), - "CONTACT_TITLE": lambda panel, item: self.addContact('@@'), - "CONTACT_TEXTBOX": lambda panel, item: self.setTargetDropCell(panel) + @param manager (ListManager) + @param data (dict{unicode: unicode}) + @param style (dict{unicode: unicode}) + """ + FlowPanel.__init__(self, Visible=(False if data["optional"] else True)) + + def setTargetDropCell(host, item): + self.manager.target_drop_cell = self + + # FIXME: dirty magic strings '@' and '@@' + drop_cbs = {"GROUP": lambda host, item: self.addItem("@%s" % item), + "CONTACT": lambda host, item: self.addItem(tryJID(item)), + "CONTACT_TITLE": lambda host, item: self.addItem('@@'), + "CONTACT_TEXTBOX": setTargetDropCell } - DropCell.__init__(self, drop_cbs) + libervia_widget.DropCell.__init__(self, None) + self.drop_keys = drop_cbs self.style = style self.addStyleName(self.style["keyPanel"]) - self._parent = parent - self.key = entry["title"] + self.manager = manager + self.key = data["title"] self._addTextBox() + def onDrop(self, event): + try: + libervia_widget.DropCell.onDrop(self, event) + except base_widget.NoLiberviaWidgetException: + pass + def _addTextBox(self, switchPrevious=False): - """Add a text box to the last position. If switchPrevious is True, simulate - an insertion before the current last textbox by copying the text and valid state. - @return: the created textbox or the previous one if switchPrevious is True. + """Add an empty text box to the last position. + + @param switchPrevious (bool): if True, simulate an insertion before the + current last textbox by switching the texts and valid states + @return: an DragAutoCompleteTextBox, the created text box or the + previous one if switchPrevious is True. """ if hasattr(self, "_last_textbox"): if self._last_textbox.getText() == "": @@ -417,24 +434,29 @@ def focus_cb(sender): if sender != self._last_textbox: # save the current value before it's being modified - self._parent.addToRemainingList(sender.getText(), ignore_key=self.key) - sender.setCompletionItems(self._parent.remaining_list) + self.manager.addToRemainingList([tryJID(sender.getText())], ignore_key=self.key) + + items = [unicode(item) for item in self.manager.items_remaining] + sender.setCompletionItems(items) + + def add_cb(sender): + self.addItem(tryJID(sender.getText()), sender) def remove_cb(sender): - """Callback for the button to remove this contact.""" - self._parent.addToRemainingList(sender.getText()) - self._parent.setRemainingListUnsorted() + """Callback for the button to remove this item.""" + self.manager.addToRemainingList([tryJID(sender.getText())]) + self.manager.setRemainingListUnsorted() self._last_textbox.setFocus(True) def drop_cb(sender, target): """Callback when the textbox is drag-n-dropped.""" - parent = sender._parent - if target != parent and target.addContact(sender.getText()): + list_panel = sender.list_panel + if target != list_panel and target.addItem(tryJID(sender.getText())): sender.removeOrReset() else: - parent._parent.removeFromRemainingList(sender.getText()) + list_panel.manager.removeFromRemainingList([tryJID(sender.getText())]) - events_cbs = {"focus": focus_cb, "validate": self.addContact, "remove": remove_cb, "drop": drop_cb} + events_cbs = {"focus": focus_cb, "validate": add_cb, "remove": remove_cb, "drop": drop_cb} textbox = DragAutoCompleteTextBox(self, events_cbs, self.style) self.add(textbox) if switchPrevious: @@ -445,45 +467,42 @@ self._last_textbox = textbox return previous if switchPrevious else textbox - def _checkContact(self, contact, modify): - """ - @param contact: the contact to check - @param modify: True if the contact is being modified - @return: - - VALID if the contact is valid - - INVALID if the contact is not valid but can be displayed - - DELETE if the contact should not be displayed at all + def _checkItem(self, item, modify): """ - def countItemInList(list_, item): - """For some reason the built-in count function doesn't work...""" - count = 0 - for elem in list_: - if elem == item: - count += 1 - return count - if contact is None or contact == "": + @param item (object): the item to check + @param modify (bool): True if the item is being modified + @return: int value defined by one of these constants: + - VALID if the item is valid + - INVALID if the item is not valid but can be displayed + - DELETE if the item should not be displayed at all + """ + def count(list_, item): + # XXX: list.count in not implemented by pyjamas + return len([elt for elt in list_ if elt == item]) + + if not item: return DELETE - if countItemInList(self.getContacts(), contact) > (1 if modify else 0): + if count(self.getItems(), item) > (1 if modify else 0): return DELETE - return VALID if contact in self._parent.list else INVALID + return VALID if item in self.manager.items else INVALID + + def addItem(self, item, sender=None): + """Try to add an item. It will be added if it's a valid one. - def addContact(self, contact, sender=None): - """The first parameter type is checked, so it is also possible to call addContact(sender). - If contact is not defined, sender.getText() is used. If sender is not defined, contact will - be written to the last textbox and a new textbox is added afterward. - @param contact: unicode - @param sender: DragAutoCompleteTextBox instance + @param item (object): item to be added + @param (DragAutoCompleteTextBox): widget triggering the event + @param sender: if True, the item will be "written" to the last textbox + and a new text box will be added afterward. + @return: True if the item has been added. """ - if isinstance(contact, DragAutoCompleteTextBox): - sender = contact - contact = sender.getText() - valid = self._checkContact(contact, sender is not None) + valid = self._checkItem(item, sender is not None) + item_s = unicode(item) if sender is None: - # method has been called to modify but to add a contact + # method has been called not to modify but to add an item if valid == VALID: # eventually insert before the last textbox if it's not empty sender = self._addTextBox(True) if self._last_textbox.getText() != "" else self._last_textbox - sender.setText(contact) + sender.setText(item_s) else: sender.setValid(valid == VALID) if valid != VALID: @@ -492,117 +511,35 @@ return False if sender == self._last_textbox: self._addTextBox() - try: - sender.setVisibleLength(len(contact)) - except: - # IndexSizeError: Index or size is negative or greater than the allowed amount - log.warning("FIXME: len(%s) returns %d... javascript bug?" % (contact, len(contact))) - self._parent.removeFromRemainingList(contact) + sender.setVisibleLength(len(item_s)) + self.manager.removeFromRemainingList([item]) self._last_textbox.setFocus(True) return True - def emptyContacts(self): - """Empty the list of contacts.""" + def emptyItems(self): + """Empty the list of items.""" for child in self.getChildren(): if hasattr(child, "remove_btn"): child.remove_btn.click() - def setContacts(self, tab): - """Set the contacts.""" - self.emptyContacts() - if isinstance(tab, set): - tab = list(tab) - tab.sort() - for contact in tab: - self.addContact(contact) - - def getContacts(self): - """Get the contacts - @return: an array of string""" - tab = [] - for widget in self.getChildren(): - if isinstance(widget, DragAutoCompleteTextBox): - # not to be mixed with EMPTY_SELECTION_ITEM - if widget.getText() != "": - tab.append(widget.getText()) - return tab - - @property - def target_drop_cell(self): - """@return: the panel where something has been dropped.""" - return self._parent.target_drop_cell - - def setTargetDropCell(self, target_drop_cell): - """ - XXX: Property setter here would not make it, you need a proper method! - @param target_drop_cell: the panel where something has been dropped.""" - self._parent.setTargetDropCell(target_drop_cell) - - -class ContactChooserPanel(DialogBox): - """Display the contacts chooser dialog. This has been implemented while - prototyping and is currently not used. Left for an eventual later use. - Replaced by the popup menu which allows to add a panel for Cc or Bcc. - """ - - def __init__(self, manager, **kwargs): - """Display a listbox for each contact key""" - DialogBox.__init__(self, autoHide=False, centered=True, **kwargs) - self.setHTML("Select contacts") - self.manager = manager - self.listboxes = {} - self.contacts = manager.getContacts() - - container = VerticalPanel(Visible=True) - container.addStyleName("marginAuto") + def resetItems(self, items): + """Repopulate the items. - grid = Grid(2, len(self.manager.keys_dict)) - index = -1 - for key in self.manager.keys_dict: - index += 1 - grid.add(Label("%s:" % self.manager.keys_dict[key]["desc"]), 0, index) - listbox = ListBox() - listbox.setMultipleSelect(True) - listbox.setVisibleItemCount(15) - listbox.addItem(EMPTY_SELECTION_ITEM) - for element in manager.list: - listbox.addItem(element) - self.listboxes[key] = listbox - grid.add(listbox, 1, index) - self._reset() - - buttons = HorizontalPanel() - buttons.addStyleName("marginAuto") - btn_close = Button("Cancel", self.hide) - buttons.add(btn_close) - btn_reset = Button("Reset", self._reset) - buttons.add(btn_reset) - btn_ok = Button("OK", self._validate) - buttons.add(btn_ok) + @param items (list): the items to be listed. + """ + self.emptyItems() + if isinstance(items, set): + items = list(items) + items.sort() + for item in items: + self.addItem(item) - container.add(grid) - container.add(buttons) - - self.add(container) - self.center() + def getItems(self): + """Get the listed items. - def _reset(self): - """Reset the selections.""" - for key in self.manager.keys_dict: - listbox = self.listboxes[key] - for i in xrange(0, listbox.getItemCount()): - if listbox.getItemText(i) in self.contacts[key]: - listbox.setItemSelected(i, "selected") - else: - listbox.setItemSelected(i, "") - - def _validate(self): - """Sets back the selected contacts to the good sub-panels.""" - _map = {} - for key in self.manager.keys_dict: - selections = self.listboxes[key].getSelectedItemText() - if EMPTY_SELECTION_ITEM in selections: - selections.remove(EMPTY_SELECTION_ITEM) - _map[key] = selections - self.manager.setContacts(_map) - self.hide() + @return: set""" + items = set() + for widget in self.getChildren(): + if isinstance(widget, DragAutoCompleteTextBox) and widget.getText() != "": + items.add(tryJID(widget.getText())) + return items diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/main_panel.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/main_panel.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,297 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +"""Panels used as main basis""" + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import _ +from sat_frontends.tools.strings import addURLToText + +from pyjamas.ui.DockPanel import DockPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.Button import Button +from pyjamas.ui.HTML import HTML +from pyjamas.ui.ClickListener import ClickHandler +from pyjamas.Timer import Timer +from pyjamas.ui import HasVerticalAlignment + + +import menu +import dialog +import base_widget +import base_menu +import libervia_widget +import editor_widget +import contact_list +from constants import Const as C + + +### Warning notification (visibility of message, and other warning data) ### + + +class WarningPopup(): + + def __init__(self): + self._popup = None + self._timer = Timer(notify=self._timeCb) + + def showWarning(self, type_=None, msg=None, duration=2000): + """Display a popup information message, e.g. to notify the recipient of a message being composed. + If type_ is None, a popup being currently displayed will be hidden. + @type_: a type determining the CSS style to be applied (see _showWarning) + @msg: message to be displayed + """ + if type_ is None: + self.__removeWarning() + return + if not self._popup: + self._showWarning(type_, msg) + elif (type_, msg) != self._popup.target_data: + self._timeCb(None) # we remove the popup + self._showWarning(type_, msg) + + self._timer.schedule(duration) + + def _showWarning(self, type_, msg): + """Display a popup information message, e.g. to notify the recipient of a message being composed. + @type_: a type determining the CSS style to be applied. For now the defined styles are + "NONE" (will do nothing), "PUBLIC", "GROUP", "STATUS" and "ONE2ONE". + @msg: message to be displayed + """ + if type_ == "NONE": + return + if not msg: + log.warning("no msg set uniBox warning") + return + if type_ == "PUBLIC": + style = "targetPublic" + elif type_ == "GROUP": + style = "targetGroup" + elif type_ == "STATUS": + style = "targetStatus" + elif type_ == "ONE2ONE": + style = "targetOne2One" + else: + log.error("unknown message type") + return + contents = HTML(msg) + + self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False) + self._popup.target_data = (type_, msg) + self._popup.add(contents) + self._popup.setStyleName("warningPopup") + if style: + self._popup.addStyleName(style) + + left = 0 + top = 0 # max(0, self.getAbsoluteTop() - contents.getOffsetHeight() - 2) + self._popup.setPopupPosition(left, top) + self._popup.show() + + def _timeCb(self, timer): + if self._popup: + self._popup.hide() + del self._popup + self._popup = None + + def __removeWarning(self): + """Remove the popup""" + self._timeCb(None) + + +### Status ### + + +class StatusPanel(editor_widget.HTMLTextEditor): + + EMPTY_STATUS = '<click to set a status>' + + def __init__(self, host, status=''): + self.host = host + modifiedCb = lambda content: self.host.bridge.call('setStatus', None, self.host.presence_status_panel.presence, content['text']) or True + editor_widget.HTMLTextEditor.__init__(self, {'text': status}, modifiedCb, options={'no_xhtml': True, 'listen_focus': True, 'listen_click': True}) + self.edit(False) + self.setStyleName('marginAuto') + + @property + def status(self): + return self._original_content['text'] + + def __cleanContent(self, content): + status = content['text'] + if status == self.EMPTY_STATUS or status in C.PRESENCE.values(): + content['text'] = '' + return content + + def getContent(self): + return self.__cleanContent(editor_widget.HTMLTextEditor.getContent(self)) + + def setContent(self, content): + content = self.__cleanContent(content) + editor_widget.BaseTextEditor.setContent(self, content) + + def setDisplayContent(self): + status = self._original_content['text'] + try: + presence = self.host.presence_status_panel.presence + except AttributeError: # during initialization + presence = None + if not status: + if presence and presence in C.PRESENCE: + status = C.PRESENCE[presence] + else: + status = self.EMPTY_STATUS + self.display.setHTML(addURLToText(status)) + + +class PresenceStatusMenuBar(base_widget.WidgetMenuBar): + def __init__(self, parent): + styles = {'menu_bar': 'presence-button'} + base_widget.WidgetMenuBar.__init__(self, parent, parent.host, styles=styles) + self.button = self.addCategory(u"◉") + presence_menu = self.button.getSubMenu() + for presence, presence_i18n in C.PRESENCE.items(): + html = u' %s' % (contact_list.buildPresenceStyle(presence), presence_i18n) + presence_menu.addItem(html, True, base_menu.SimpleCmd(lambda presence=presence: self.changePresenceCb(presence))) + self.parent_panel = parent + + def changePresenceCb(self, presence=''): + """Callback to notice the backend of a new presence set by the user. + @param presence (unicode): the new presence is a value in ('', 'chat', 'away', 'dnd', 'xa') + """ + self.host.bridge.call('setStatus', None, presence, self.parent_panel.status_panel.status) + + @classmethod + def getCategoryHTML(cls, menu_name_i18n, type_): + return menu_name_i18n + + +class PresenceStatusPanel(HorizontalPanel, ClickHandler): + + def __init__(self, host, presence="", status=""): + self.host = host + self.plugin_menu_context = [] + HorizontalPanel.__init__(self, Width='100%') + self.presence_bar = PresenceStatusMenuBar(self) + self.status_panel = StatusPanel(host, status=status) + self.setPresence(presence) + + panel = HorizontalPanel() + panel.add(self.presence_bar) + panel.add(self.status_panel) + panel.setCellVerticalAlignment(self.presence_bar, 'baseline') + panel.setCellVerticalAlignment(self.status_panel, 'baseline') + panel.setStyleName("presenceStatusPanel") + self.add(panel) + + self.status_panel.edit(False) + + ClickHandler.__init__(self) + self.addClickListener(self) + + @property + def presence(self): + return self._presence + + @property + def status(self): + return self.status_panel._original_content['text'] + + def setPresence(self, presence): + self._presence = presence + contact_list.setPresenceStyle(self.presence_bar.button, self._presence) + + def setStatus(self, status): + self.status_panel.setContent({'text': status}) + self.status_panel.setDisplayContent() + + def onClick(self, sender): + # As status is the default target of uniBar, we don't want to select anything if click on it + self.host.setSelected(None) + + +### Panels managing the main area ### + + +class MainPanel(DockPanel): + """The panel which take the whole screen""" + + def __init__(self, host): + self.host = host + DockPanel.__init__(self, StyleName="mainPanel liberviaTabPanel") + + # menu and status panel + self.header = VerticalPanel(StyleName="header") + self.menu = menu.MainMenuBar(host) + self.header.add(self.menu) + + # contacts + self.contacts_switch = Button(u'«', self._contactsSwitch) + self.contacts_switch.addStyleName('contactsSwitch') + + # tab panel + self.tab_panel = libervia_widget.MainTabPanel(host) + self.tab_panel.addWidgetsTab(_(u"Discussions"), select=True, locked=True) + + # XXX: widget's addition order is important! + self.add(self.header, DockPanel.NORTH) + self.add(self.tab_panel, DockPanel.CENTER) + self.setCellWidth(self.tab_panel, '100%') + self.setCellHeight(self.tab_panel, '100%') + self.add(self.tab_panel.getTabBar(), DockPanel.SOUTH) + + def addContactList(self, contact_list): + self.add(self.contacts_switch, DockPanel.WEST) + self.add(contact_list, DockPanel.WEST) + + def addPresenceStatusPanel(self, panel): + self.header.add(panel) + self.header.setCellHeight(panel, '100%') + self.header.setCellVerticalAlignment(panel, HasVerticalAlignment.ALIGN_BOTTOM) + + def _contactsSwitch(self, btn=None): + """ (Un)hide contacts panel """ + if btn is None: + btn = self.contacts_switch + clist = self.host.contact_list + clist.setVisible(not clist.getVisible()) + btn.setText(u"«" if clist.getVisible() else u"»") + self.host.resize() + + def _contactsMove(self, parent): + """Move the contacts container (containing the contact list and + the "hide/show" button) to another parent, but always as the + first child position (insert at index 0). + """ + if self._contacts.getParent(): + if self._contacts.getParent() == parent: + return + self._contacts.removeFromParent() + parent.insert(self._contacts, 0) + + def refresh(self): + """Refresh the main panel""" + self.unibox_panel.refresh() + self.host.contact_panel.refresh() + + diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/menu.py --- a/src/browser/sat_browser/menu.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/menu.py Wed Mar 18 16:15:18 2015 +0100 @@ -21,22 +21,20 @@ from sat.core.log import getLogger log = getLogger(__name__) -from sat.core.i18n import _ - -from pyjamas.ui.SimplePanel import SimplePanel from pyjamas.ui.HTML import HTML from pyjamas.ui.Frame import Frame -from pyjamas import Window from constants import Const as C -import jid import file_tools import xmlui -import panels +import chat import dialog import contact_group import base_menu -from base_menu import MenuCmd +from sat_browser import html_tools + + +unicode = str # FIXME: pyjamas workaround class MainMenuBar(base_menu.GenericMenuBar): @@ -49,92 +47,23 @@ base_menu.GenericMenuBar.__init__(self, host, vertical=False, styles=styles) @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): - return cls.ITEM_TPL % (type_, menu_name_i18n) - - -class MainMenuPanel(SimplePanel): - """Container for the main menu bar""" - - def __init__(self, host): - self.host = host - SimplePanel.__init__(self) - self.setStyleName('menuContainer') - self.menu_bar = MainMenuBar(self.host) - - def addMenuItem(self, *args): - self.menu_bar.addMenuItem(*args) - - def addCategory(self, *args): - self.menu_bar.addCategory(*args) + def getCategoryHTML(cls, category): + name = html_tools.html_sanitize(category.name) + return cls.ITEM_TPL % (category.icon, name) if category.icon is not None else name - def createMenus(self): - self.addMenuItem("General", [_("General"), _("Web widget")], 'home', MenuCmd(self, "onWebWidget")) - self.addMenuItem("General", [_("General"), _("Disconnect")], 'home', MenuCmd(self, "onDisconnect")) - self.addCategory("Contacts", _("Contacts"), 'social') # save the position for this category - self.addMenuItem("Groups", [_("Groups"), _("Discussion")], 'social', MenuCmd(self, "onJoinRoom")) - self.addMenuItem("Groups", [_("Groups"), _("Collective radio")], 'social', MenuCmd(self, "onCollectiveRadio")) - self.addMenuItem("Games", [_("Games"), _("Tarot")], 'games', MenuCmd(self, "onTarotGame")) - self.addMenuItem("Games", [_("Games"), _("Xiangqi")], 'games', MenuCmd(self, "onXiangqiGame")) - - # additional menus - self.menu_bar.addCachedMenus(C.MENU_GLOBAL) - - # menu items that should be displayed after the automatically added ones - self.addMenuItem("Contacts", [_("Contacts"), _("Manage groups")], 'social', MenuCmd(self, "onManageContactGroups")) - - self.menu_bar.addSeparator() - - self.addMenuItem("Help", [_("Help"), _("Social contract")], 'help', MenuCmd(self, "onSocialContract")) - self.addMenuItem("Help", [_("Help"), _("About")], 'help', MenuCmd(self, "onAbout")) - self.addMenuItem("Settings", [_("Settings"), _("Account")], 'settings', MenuCmd(self, "onAccount")) - self.addMenuItem("Settings", [_("Settings"), _("Parameters")], 'settings', MenuCmd(self, "onParameters")) - - # XXX: temporary, will change when a full profile will be managed in SàT - self.addMenuItem("Settings", [_("Settings"), _("Upload avatar")], 'settings', MenuCmd(self, "onAvatarUpload")) - - self.add(self.menu_bar) + ## callbacks # General menu - def onWebWidget(self): - web_panel = panels.WebPanel(self.host, "http://www.goffi.org") - self.host.addWidget(web_panel) - self.host.setSelected(web_panel) def onDisconnect(self): def confirm_cb(answer): if answer: - # FIXME: are we sure the triggers finished their jobs when the backend disconnect? - # FIXME: disconnection on timeout is not handled yet... - for plugin in self.host.plugins.values(): - if hasattr(plugin, 'profileDisconnected'): - plugin.profileDisconnected() - log.info("disconnection") - self.host.bridge.call('disconnect', None) + self.host.disconnect(C.PROF_KEY_NONE) _dialog = dialog.ConfirmDialog(confirm_cb, text="Do you really want to disconnect ?") _dialog.show() - def onSocialContract(self): - _frame = Frame('contrat_social.html') - _frame.setStyleName('infoFrame') - _dialog = dialog.GenericDialog("Contrat Social", _frame) - _dialog.setSize('80%', '80%') - _dialog.show() + #Contact menu - def onAbout(self): - _about = HTML("""Libervia, a Salut à Toi project
-
-You can contact the author at goffi@goffi.org
-Blog available (mainly in french) at http://www.goffi.org
-Project page: http://sat.goffi.org
-
-Any help welcome :) -

This project is dedicated to Roger Poisson

-""") - _dialog = dialog.GenericDialog("About", _about) - _dialog.show() - - #Contact menu def onManageContactGroups(self): """Open the contact groups manager.""" @@ -148,32 +77,41 @@ def invite(room_jid, contacts): for contact in contacts: - self.host.bridge.call('inviteMUC', None, contact, room_jid) - + self.host.bridge.call('inviteMUC', None, unicode(contact), unicode(room_jid)) def join(room_jid, contacts): if self.host.whoami: nick = self.host.whoami.node - if room_jid not in [room.bare for room in self.host.room_list]: - self.host.bridge.call('joinMUC', lambda room_jid: invite(room_jid, contacts), room_jid, nick) + contact_list = self.host.contact_list + if room_jid is None or room_jid not in contact_list.getSpecials(C.CONTACT_SPECIAL_GROUP): + room_jid_s = unicode(room_jid) if room_jid else '' + self.host.bridge.call('joinMUC', lambda room_jid: invite(room_jid, contacts), room_jid_s, nick) else: - self.host.getOrCreateLiberviaWidget(panels.ChatPanel, {'item': room_jid, 'type_': "group"}, True, jid.JID(room_jid).bare) + self.host.displayWidget(chat.Chat, room_jid, type_="group", new_tab=room_jid) invite(room_jid, contacts) dialog.RoomAndContactsChooser(self.host, join, ok_button="Join", visible=(True, False)) - def onCollectiveRadio(self): - def callback(room_jid, contacts): - self.host.bridge.call('launchRadioCollective', None, contacts, room_jid) - dialog.RoomAndContactsChooser(self.host, callback, ok_button="Choose", title="Collective Radio", visible=(False, True)) + # Help menu + + def onSocialContract(self): + _frame = Frame('contrat_social.html') + _frame.setStyleName('infoFrame') + _dialog = dialog.GenericDialog("Contrat Social", _frame) + _dialog.setSize('80%', '80%') + _dialog.show() - #Game menu - def onTarotGame(self): - def onPlayersSelected(room_jid, other_players): - self.host.bridge.call('launchTarotGame', None, other_players, room_jid) - dialog.RoomAndContactsChooser(self.host, onPlayersSelected, 3, title="Tarot", title_invite="Please select 3 other players", visible=(False, True)) - - def onXiangqiGame(self): - Window.alert("A Xiangqi game is planed, but not available yet") + def onAbout(self): + _about = HTML("""Libervia, a Salut à Toi project
+
+You can contact the authors at contact@salut-a-toi.org
+Blog available (mainly in french) at http://www.goffi.org
+Project page: http://salut-a-toi.org
+
+Any help welcome :) +

This project is dedicated to Roger Poisson

+""") + _dialog = dialog.GenericDialog("About", _about) + _dialog.show() #Settings menu diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/nativedom.py --- a/src/browser/sat_browser/nativedom.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/nativedom.py Wed Mar 18 16:15:18 2015 +0100 @@ -34,7 +34,7 @@ ret = [] for i in range(len(js_nodes_list)): #ret.append(Element(js_nodes_list.item(i))) - ret.append(self.__class__(js_nodes_list.item(i))) # XXX: Ugly, but used to word around a Pyjamas's bug + ret.append(self.__class__(js_nodes_list.item(i))) # XXX: Ugly, but used to work around a Pyjamas's bug return ret def __getattr__(self, name): @@ -42,7 +42,7 @@ return getattr(self._node, name) return object.__getattribute__(self, name) - @property + @property # XXX: doesn't work in --strict mode in pyjs def childNodes(self): return self._jsNodesList2List(self._node.childNodes) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/notification.py --- a/src/browser/sat_browser/notification.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/notification.py Wed Mar 18 16:15:18 2015 +0100 @@ -89,11 +89,11 @@ """) wnd().onclick = self._old_click - def onFocus(self): + def onFocus(self, event=None): Window.setTitle(self._orig_title) self._notif_count = 0 - # def onBlur(self): + # def onBlur(self, event=None): # pass def isHidden(self): diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/panels.py --- a/src/browser/sat_browser/panels.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1588 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pyjd # this is dummy in pyjs -from sat.core.log import getLogger -log = getLogger(__name__) - -from sat_frontends.tools.strings import addURLToText -from sat_frontends.tools.games import SYMBOLS -from sat.core.i18n import _, D_ - -from pyjamas.ui.SimplePanel import SimplePanel -from pyjamas.ui.AbsolutePanel import AbsolutePanel -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.HTMLPanel import HTMLPanel -from pyjamas.ui.Frame import Frame -from pyjamas.ui.TextArea import TextArea -from pyjamas.ui.Label import Label -from pyjamas.ui.Button import Button -from pyjamas.ui.HTML import HTML -from pyjamas.ui.Image import Image -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas.ui.FlowPanel import FlowPanel -from pyjamas.ui.KeyboardListener import KEY_ENTER, KEY_UP, KEY_DOWN, KeyboardHandler -from pyjamas.ui.MouseListener import MouseHandler -from pyjamas.ui.FocusListener import FocusHandler -from pyjamas.Timer import Timer -from pyjamas import DOM -from pyjamas import Window -from __pyjamas__ import doc - -from datetime import datetime -from time import time - -import jid -import html_tools -import base_panels -import base_menu -import card_game -import radiocol -import menu -import dialog -import base_widget -import richtext -import contact -from constants import Const as C -import plugin_xep_0085 - - -# TODO: at some point we should decide which behaviors to keep and remove these two constants -TOGGLE_EDITION_USE_ICON = False # set to True to use an icon inside the "toggle syntax" button -NEW_MESSAGE_USE_BUTTON = False # set to True to display the "New message" button instead of an empty entry - - -class UniBoxPanel(HorizontalPanel): - """Panel containing the UniBox""" - - def __init__(self, host): - HorizontalPanel.__init__(self) - self.host = host - self.setStyleName('uniBoxPanel') - self.unibox = None - - def refresh(self): - """Enable or disable this panel. Contained widgets are created when necessary.""" - enable = self.host.getCachedParam(C.COMPOSITION_KEY, C.ENABLE_UNIBOX_PARAM) == 'true' - self.setVisible(enable) - if enable and not self.unibox: - self.button = Button('') - self.button.setTitle('Open the rich text editor') - self.button.addStyleName('uniBoxButton') - self.add(self.button) - self.unibox = UniBox(self.host) - self.add(self.unibox) - self.setCellWidth(self.unibox, '100%') - self.button.addClickListener(self.openRichMessageEditor) - self.unibox.addKey("@@: ") - self.unibox.onSelectedChange(self.host.getSelected()) - - def openRichMessageEditor(self): - """Open the rich text editor.""" - self.button.setVisible(False) - self.unibox.setVisible(False) - self.setCellWidth(self.unibox, '0px') - self.host.panel._contactsMove(self) - - def afterEditCb(): - Window.removeWindowResizeListener(self) - self.host.panel._contactsMove(self.host.panel._hpanel) - self.setCellWidth(self.unibox, '100%') - self.button.setVisible(True) - self.unibox.setVisible(True) - self.host.resize() - - richtext.RichMessageEditor.getOrCreate(self.host, self, afterEditCb) - Window.addWindowResizeListener(self) - self.host.resize() - - def onWindowResized(self, width, height): - right = self.host.panel.menu.getAbsoluteLeft() + self.host.panel.menu.getOffsetWidth() - left = self.host.panel._contacts.getAbsoluteLeft() + self.host.panel._contacts.getOffsetWidth() - ideal_width = right - left - 40 - self.host.richtext.setWidth("%spx" % ideal_width) - - -class MessageBox(TextArea): - """A basic text area for entering messages""" - - def __init__(self, host): - TextArea.__init__(self) - self.host = host - self.__size = (0, 0) - self.setStyleName('messageBox') - self.addKeyboardListener(self) - MouseHandler.__init__(self) - self.addMouseListener(self) - self._selected_cache = None - - def onBrowserEvent(self, event): - # XXX: woraroung a pyjamas bug: self.currentEvent is not set - # so the TextBox's cancelKey doens't work. This is a workaround - # FIXME: fix the bug upstream - self.currentEvent = event - TextArea.onBrowserEvent(self, event) - - def onKeyPress(self, sender, keycode, modifiers): - _txt = self.getText() - - def history_cb(text): - self.setText(text) - Timer(5, lambda timer: self.setCursorPos(len(text))) - - if keycode == KEY_ENTER: - if _txt: - self._selected_cache.onTextEntered(_txt) - self.host._updateInputHistory(_txt) - self.setText('') - sender.cancelKey() - elif keycode == KEY_UP: - self.host._updateInputHistory(_txt, -1, history_cb) - elif keycode == KEY_DOWN: - self.host._updateInputHistory(_txt, +1, history_cb) - else: - self.__onComposing() - - def __onComposing(self): - """Callback when the user is composing a text.""" - if hasattr(self._selected_cache, "target"): - self._selected_cache.state_machine._onEvent("composing") - - def onMouseUp(self, sender, x, y): - size = (self.getOffsetWidth(), self.getOffsetHeight()) - if size != self.__size: - self.__size = size - self.host.resize() - - def onSelectedChange(self, selected): - self._selected_cache = selected - - -class UniBox(MessageBox, MouseHandler): # AutoCompleteTextBox): - """This text box is used as a main typing point, for message, microblog, etc""" - - def __init__(self, host): - MessageBox.__init__(self, host) - #AutoCompleteTextBox.__init__(self) - self.setStyleName('uniBox') - host.addSelectedListener(self.onSelectedChange) - - def addKey(self, key): - return - #self.getCompletionItems().completions.append(key) - - def removeKey(self, key): - return - # TODO: investigate why AutoCompleteTextBox doesn't work here, - # maybe it can work on a TextBox but no TextArea. Remove addKey - # and removeKey methods if they don't serve anymore. - try: - self.getCompletionItems().completions.remove(key) - except KeyError: - log.warning("trying to remove an unknown key") - - def _getTarget(self, txt): - """ Say who will receive the messsage - @return: a tuple (selected, target_type, target info) with: - - target_hook: None if we use the selected widget, (msg, data) if we have a hook (e.g. "@@: " for a public blog), where msg is the parsed message (i.e. without the "hook key: "@@: bla" become ("bla", None)) - - target_type: one of PUBLIC, GROUP, ONE2ONE, STATUS, MISC - - msg: HTML message which will appear in the privacy warning banner """ - target = self._selected_cache - - def getSelectedOrStatus(): - if target and target.isSelectable(): - _type, msg = target.getWarningData() - target_hook = None # we use the selected widget, not a hook - else: - _type, msg = "STATUS", "This will be your new status message" - target_hook = (txt, None) - return (target_hook, _type, msg) - - if not txt.startswith('@'): - target_hook, _type, msg = getSelectedOrStatus() - elif txt.startswith('@@: '): - _type = "PUBLIC" - msg = MicroblogPanel.warning_msg_public - target_hook = (txt[4:], None) - elif txt.startswith('@'): - _end = txt.find(': ') - if _end == -1: - target_hook, _type, msg = getSelectedOrStatus() - else: - group = txt[1:_end] # only one target group is managed for the moment - if not group or not group in self.host.contact_panel.getGroups(): - # the group doesn't exists, we ignore the key - group = None - target_hook, _type, msg = getSelectedOrStatus() - else: - _type = "GROUP" - msg = MicroblogPanel.warning_msg_group % group - target_hook = (txt[_end + 2:], group) - else: - log.error("Unknown target") - target_hook, _type, msg = getSelectedOrStatus() - - return (target_hook, _type, msg) - - def onKeyPress(self, sender, keycode, modifiers): - _txt = self.getText() - target_hook, type_, msg = self._getTarget(_txt) - - if keycode == KEY_ENTER: - if _txt: - if target_hook: - parsed_txt, data = target_hook - self.host.send([(type_, data)], parsed_txt) - self.host._updateInputHistory(_txt) - self.setText('') - self.host.showWarning(None, None) - else: - self.host.showWarning(type_, msg) - MessageBox.onKeyPress(self, sender, keycode, modifiers) - - def getTargetAndData(self): - """For external use, to get information about the (hypothetical) message - that would be sent if we press Enter right now in the unibox. - @return a tuple (target, data) with: - - data: what would be the content of the message (body) - - target: JID, group with the prefix "@" or the public entity "@@" - """ - _txt = self.getText() - target_hook, _type, _msg = self._getTarget(_txt) - if target_hook: - data, target = target_hook - if target is None: - return target_hook - return (data, "@%s" % (target if target != "" else "@")) - if isinstance(self._selected_cache, MicroblogPanel): - groups = self._selected_cache.accepted_groups - target = "@%s" % (groups[0] if len(groups) > 0 else "@") - if len(groups) > 1: - Window.alert("Sole the first group of the selected panel is taken in consideration: '%s'" % groups[0]) - elif isinstance(self._selected_cache, ChatPanel): - target = self._selected_cache.target - else: - target = None - return (_txt, target) - - def onWidgetClosed(self, lib_wid): - """Called when a libervia widget is closed""" - if self._selected_cache == lib_wid: - self.onSelectedChange(None) - - """def complete(self): - - #self.visible=False #XXX: self.visible is not unset in pyjamas when ENTER is pressed and a completion is done - #XXX: fixed directly on pyjamas, if the patch is accepted, no need to walk around this - return AutoCompleteTextBox.complete(self)""" - - -class WarningPopup(): - - def __init__(self): - self._popup = None - self._timer = Timer(notify=self._timeCb) - - def showWarning(self, type_=None, msg=None, duration=2000): - """Display a popup information message, e.g. to notify the recipient of a message being composed. - If type_ is None, a popup being currently displayed will be hidden. - @type_: a type determining the CSS style to be applied (see __showWarning) - @msg: message to be displayed - """ - if type_ is None: - self.__removeWarning() - return - if not self._popup: - self.__showWarning(type_, msg) - elif (type_, msg) != self._popup.target_data: - self._timeCb(None) # we remove the popup - self.__showWarning(type_, msg) - - self._timer.schedule(duration) - - def __showWarning(self, type_, msg): - """Display a popup information message, e.g. to notify the recipient of a message being composed. - @type_: a type determining the CSS style to be applied. For now the defined styles are - "NONE" (will do nothing), "PUBLIC", "GROUP", "STATUS" and "ONE2ONE". - @msg: message to be displayed - """ - if type_ == "NONE": - return - if not msg: - log.warning("no msg set uniBox warning") - return - if type_ == "PUBLIC": - style = "targetPublic" - elif type_ == "GROUP": - style = "targetGroup" - elif type_ == "STATUS": - style = "targetStatus" - elif type_ == "ONE2ONE": - style = "targetOne2One" - else: - log.error("unknown message type") - return - contents = HTML(msg) - - self._popup = dialog.PopupPanelWrapper(autoHide=False, modal=False) - self._popup.target_data = (type_, msg) - self._popup.add(contents) - self._popup.setStyleName("warningPopup") - if style: - self._popup.addStyleName(style) - - left = 0 - top = 0 # max(0, self.getAbsoluteTop() - contents.getOffsetHeight() - 2) - self._popup.setPopupPosition(left, top) - self._popup.show() - - def _timeCb(self, timer): - if self._popup: - self._popup.hide() - del self._popup - self._popup = None - - def __removeWarning(self): - """Remove the popup""" - self._timeCb(None) - - -class MicroblogItem(): - # XXX: should be moved in a separated module - - def __init__(self, data): - self.id = data['id'] - self.type = data.get('type', 'main_item') - self.empty = data.get('new', False) - self.title = data.get('title', '') - self.title_xhtml = data.get('title_xhtml', '') - self.content = data.get('content', '') - self.content_xhtml = data.get('content_xhtml', '') - self.author = data['author'] - self.updated = float(data.get('updated', 0)) # XXX: int doesn't work here - self.published = float(data.get('published', self.updated)) # XXX: int doesn't work here - self.service = data.get('service', '') - self.node = data.get('node', '') - self.comments = data.get('comments', False) - self.comments_service = data.get('comments_service', '') - self.comments_node = data.get('comments_node', '') - - -class MicroblogEntry(SimplePanel, ClickHandler, FocusHandler, KeyboardHandler): - - def __init__(self, blog_panel, data): - """ - @param blog_panel: the parent panel - @param data: dict containing the blog item data, or a MicroblogItem instance. - """ - self._base_item = data if isinstance(data, MicroblogItem) else MicroblogItem(data) - for attr in ['id', 'type', 'empty', 'title', 'title_xhtml', 'content', 'content_xhtml', - 'author', 'updated', 'published', 'comments', 'service', 'node', - 'comments_service', 'comments_node']: - getter = lambda attr: lambda inst: getattr(inst._base_item, attr) - setter = lambda attr: lambda inst, value: setattr(inst._base_item, attr, value) - setattr(MicroblogEntry, attr, property(getter(attr), setter(attr))) - - SimplePanel.__init__(self) - self._blog_panel = blog_panel - - self.panel = FlowPanel() - self.panel.setStyleName('mb_entry') - - self.header = HorizontalPanel(StyleName='mb_entry_header') - self.panel.add(self.header) - - self.entry_actions = VerticalPanel() - self.entry_actions.setStyleName('mb_entry_actions') - self.panel.add(self.entry_actions) - - entry_avatar = SimplePanel() - entry_avatar.setStyleName('mb_entry_avatar') - self.avatar = Image(self._blog_panel.host.getAvatar(self.author)) - entry_avatar.add(self.avatar) - self.panel.add(entry_avatar) - - if TOGGLE_EDITION_USE_ICON: - self.entry_dialog = HorizontalPanel() - else: - self.entry_dialog = VerticalPanel() - self.entry_dialog.setStyleName('mb_entry_dialog') - self.panel.add(self.entry_dialog) - - self.add(self.panel) - ClickHandler.__init__(self) - self.addClickListener(self) - - self.__pub_data = (self.service, self.node, self.id) - self.__setContent() - - def __setContent(self): - """Actually set the entry content (header, icons, bubble...)""" - self.delete_label = self.update_label = self.comment_label = None - self.bubble = self._current_comment = None - self.__setHeader() - self.__setBubble() - self.__setIcons() - - def __setHeader(self): - """Set the entry header.""" - if self.empty: - return - update_text = u" — ✍ " + "" % datetime.fromtimestamp(self.updated) - self.header.add(HTML(""" - on - %(updated)s - """ % {'author': html_tools.html_sanitize(self.author), - 'published': datetime.fromtimestamp(self.published), - 'updated': update_text if self.published != self.updated else '' - })) - if self.comments: - self.comments_count = self.hidden_count = 0 - self.show_comments_link = HTML('') - self.header.add(self.show_comments_link) - - def updateHeader(self, comments_count=None, hidden_count=None, inc=None): - """Update the header. - - @param comments_count (int): total number of comments. - @param hidden_count (int): number of hidden comments. - @param inc (int): number to increment the total number of comments with. - """ - if comments_count is not None: - self.comments_count = comments_count - if hidden_count is not None: - self.hidden_count = hidden_count - if inc is not None: - self.comments_count += inc - - if self.hidden_count > 0: - comments = D_('comments') if self.hidden_count > 1 else D_('comment') - text = D_("show %(count)d previous %(comments)s") % {'count': self.hidden_count, - 'comments': comments} - if self not in self.show_comments_link._clickListeners: - self.show_comments_link.addClickListener(self) - else: - if self.comments_count > 1: - text = "%(count)d %(comments)s" % {'count': self.comments_count, - 'comments': D_('comments')} - elif self.comments_count == 1: - text = D_('1 comment') - else: - text = '' - try: - self.show_comments_link.removeClickListener(self) - except ValueError: - pass - - self.show_comments_link.setHTML("""%(text)s""" % {'text': text}) - - def __setIcons(self): - """Set the entry icons (delete, update, comment)""" - if self.empty: - return - - def addIcon(label, title): - label = Label(label) - label.setTitle(title) - label.addClickListener(self) - self.entry_actions.add(label) - return label - - if self.comments: - self.comment_label = addIcon(u"↶", "Comment this message") - self.comment_label.setStyleName('mb_entry_action_larger') - is_publisher = self.author == self._blog_panel.host.whoami.bare - if is_publisher: - self.update_label = addIcon(u"✍", "Edit this message") - if is_publisher or str(self.node).endswith(self._blog_panel.host.whoami.bare): - self.delete_label = addIcon(u"✗", "Delete this message") - - def updateAvatar(self, new_avatar): - """Change the avatar of the entry - @param new_avatar: path to the new image""" - self.avatar.setUrl(new_avatar) - - def onClick(self, sender): - if sender == self: - self._blog_panel.setSelectedEntry(self) - elif sender == self.delete_label: - self._delete() - elif sender == self.update_label: - self.edit(True) - elif sender == self.comment_label: - self._comment() - elif sender == self.show_comments_link: - self._blog_panel.loadAllCommentsForEntry(self) - - def __modifiedCb(self, content): - """Send the new content to the backend - @return: False to restore the original content if a deletion has been cancelled - """ - if not content['text']: # previous content has been emptied - self._delete(True) - return False - extra = {'published': str(self.published)} - if isinstance(self.bubble, richtext.RichTextEditor): - # TODO: if the user change his parameters after the message edition started, - # the message syntax could be different then the current syntax: pass the - # message syntax in extra for the frontend to use it instead of current syntax. - extra.update({'content_rich': content['text'], 'title': content['title']}) - if self.empty: - if self.type == 'main_item': - self._blog_panel.host.bridge.call('sendMblog', None, None, self._blog_panel.accepted_groups, content['text'], extra) - else: - self._blog_panel.host.bridge.call('sendMblogComment', None, self._parent_entry.comments, content['text'], extra) - else: - self._blog_panel.host.bridge.call('updateMblog', None, self.__pub_data, self.comments, content['text'], extra) - return True - - def __afterEditCb(self, content): - """Remove the entry if it was an empty one (used for creating a new blog post). - Data for the actual new blog post will be received from the bridge""" - if self.empty: - self._blog_panel.removeEntry(self.type, self.id, update_header=False) - if self.type == 'main_item': # restore the "New message" button - self._blog_panel.refresh() - else: # allow to create a new comment - self._parent_entry._current_comment = None - self.entry_dialog.setWidth('auto') - try: - self.toggle_syntax_button.removeFromParent() - except TypeError: - pass - - def __setBubble(self, edit=False): - """Set the bubble displaying the initial content.""" - content = {'text': self.content_xhtml if self.content_xhtml else self.content, - 'title': self.title_xhtml if self.title_xhtml else self.title} - if self.content_xhtml: - content.update({'syntax': C.SYNTAX_XHTML}) - if self.author != self._blog_panel.host.whoami.bare: - options = ['read_only'] - else: - options = [] if self.empty else ['update_msg'] - self.bubble = richtext.RichTextEditor(self._blog_panel.host, content, self.__modifiedCb, self.__afterEditCb, options) - else: # assume raw text message have no title - self.bubble = base_panels.LightTextEditor(content, self.__modifiedCb, self.__afterEditCb, options={'no_xhtml': True}) - self.bubble.addStyleName("bubble") - try: - self.toggle_syntax_button.removeFromParent() - except TypeError: - pass - self.entry_dialog.add(self.bubble) - self.edit(edit) - self.bubble.addEditListener(self.__showWarning) - - def __showWarning(self, sender, keycode): - if keycode == KEY_ENTER: - self._blog_panel.host.showWarning(None, None) - else: - self._blog_panel.host.showWarning(*self._blog_panel.getWarningData(self.type == 'comment')) - - def _delete(self, empty=False): - """Ask confirmation for deletion. - @return: False if the deletion has been cancelled.""" - def confirm_cb(answer): - if answer: - self._blog_panel.host.bridge.call('deleteMblog', None, self.__pub_data, self.comments) - else: # restore the text if it has been emptied during the edition - self.bubble.setContent(self.bubble._original_content) - - if self.empty: - text = _("New ") + (_("message") if self.comments else _("comment")) + _(" without body has been cancelled.") - dialog.InfoDialog(_("Information"), text).show() - return - text = "" - if empty: - text = (_("Message") if self.comments else _("Comment")) + _(" body has been emptied.
") - target = _('message and all its comments') if self.comments else _('comment') - text += _("Do you really want to delete this %s?") % target - dialog.ConfirmDialog(confirm_cb, text=text).show() - - def _comment(self): - """Add an empty entry for a new comment""" - if self._current_comment: - self._current_comment.bubble.setFocus(True) - self._blog_panel.setSelectedEntry(self._current_comment, True) - return - data = {'id': str(time()), - 'new': True, - 'type': 'comment', - 'author': self._blog_panel.host.whoami.bare, - 'service': self.comments_service, - 'node': self.comments_node - } - entry = self._blog_panel.addEntry(data, update_header=False) - if entry is None: - log.info("The entry of id %s can not be commented" % self.id) - return - entry._parent_entry = self - self._current_comment = entry - self.edit(True, entry) - self._blog_panel.setSelectedEntry(entry, True) - - def edit(self, edit, entry=None): - """Toggle the bubble between display and edit mode - @edit: boolean value - @entry: MicroblogEntry instance, or None to use self - """ - if entry is None: - entry = self - try: - entry.toggle_syntax_button.removeFromParent() - except TypeError: - pass - entry.bubble.edit(edit) - if edit: - if isinstance(entry.bubble, richtext.RichTextEditor): - image = 'A' - html = 'raw text' - title = _('Switch to raw text edition') - else: - image = '' - html = 'rich text' - title = _('Switch to rich text edition') - if TOGGLE_EDITION_USE_ICON: - entry.entry_dialog.setWidth('80%') - entry.toggle_syntax_button = Button(image, entry.toggleContentSyntax) - entry.toggle_syntax_button.setTitle(title) - entry.entry_dialog.add(entry.toggle_syntax_button) - else: - entry.toggle_syntax_button = HTML(html) - entry.toggle_syntax_button.addClickListener(entry.toggleContentSyntax) - entry.toggle_syntax_button.addStyleName('mb_entry_toggle_syntax') - entry.entry_dialog.add(entry.toggle_syntax_button) - entry.toggle_syntax_button.setStyleAttribute('top', '-20px') # XXX: need to force CSS - entry.toggle_syntax_button.setStyleAttribute('left', '-20px') - - def toggleContentSyntax(self): - """Toggle the editor between raw and rich text""" - original_content = self.bubble.getOriginalContent() - rich = not isinstance(self.bubble, richtext.RichTextEditor) - if rich: - original_content['syntax'] = C.SYNTAX_XHTML - - def setBubble(text): - self.content = text - self.content_xhtml = text if rich else '' - self.content_title = self.content_title_xhtml = '' - self.bubble.removeFromParent() - self.__setBubble(True) - self.bubble.setOriginalContent(original_content) - if rich: - self.bubble.setDisplayContent() # needed in case the edition is aborted, to not end with an empty bubble - - text = self.bubble.getContent()['text'] - if not text: - setBubble(' ') # something different than empty string is needed to initialize the rich text editor - return - if not rich: - def confirm_cb(answer): - if answer: - self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_CURRENT, C.SYNTAX_TEXT) - dialog.ConfirmDialog(confirm_cb, text=_("Do you really want to lose the title and text formatting?")).show() - else: - self._blog_panel.host.bridge.call('syntaxConvert', setBubble, text, C.SYNTAX_TEXT, C.SYNTAX_XHTML) - - -class MicroblogPanel(base_widget.LiberviaWidget, MouseHandler): - warning_msg_public = "This message will be PUBLIC and everybody will be able to see it, even people you don't know" - warning_msg_group = "This message will be published for all the people of the group %s" - - def __init__(self, host, accepted_groups): - """Panel used to show microblog - @param accepted_groups: groups displayed in this panel, if empty, show all microblogs from all contacts - """ - base_widget.LiberviaWidget.__init__(self, host, ", ".join(accepted_groups), selectable=True) - MouseHandler.__init__(self) - self.setAcceptedGroup(accepted_groups) - self.host = host - self.entries = {} - self.comments = {} - self.selected_entry = None - self.vpanel = VerticalPanel() - self.vpanel.setStyleName('microblogPanel') - self.setWidget(self.vpanel) - self.footer = HTML('', StyleName='microblogPanel_footer') - self.footer.waiting = False - self.footer.addClickListener(self) - self.footer.addMouseListener(self) - self.vpanel.add(self.footer) - self.next_rsm_index = 0 - - def refresh(self): - """Refresh the display of this widget. If the unibox is disabled, - display the 'New message' button or an empty bubble on top of the panel""" - if hasattr(self, 'new_button'): - self.new_button.setVisible(self.host.uni_box is None) - return - if self.host.uni_box is None: - def addBox(): - if hasattr(self, 'new_button'): - self.new_button.setVisible(False) - data = {'id': str(time()), - 'new': True, - 'author': self.host.whoami.bare, - } - entry = self.addEntry(data, update_header=False) - entry.edit(True) - if NEW_MESSAGE_USE_BUTTON: - self.new_button = Button("New message", listener=addBox) - self.new_button.setStyleName("microblogNewButton") - self.vpanel.insert(self.new_button, 0) - elif not self.getNewMainEntry(): - addBox() - - def getNewMainEntry(self): - """Get the new entry being edited, or None if it doesn't exists. - - @return (MicroblogEntry): the new entry being edited. - """ - if len(self.vpanel.children) < 2: - return None # there's only the footer - first = self.vpanel.children[0] - assert(first.type == 'main_item') - return first if first.empty else None - - @classmethod - def registerClass(cls): - base_widget.LiberviaWidget.addDropKey("GROUP", cls.createPanel) - base_widget.LiberviaWidget.addDropKey("CONTACT_TITLE", cls.createMetaPanel) - - @classmethod - def createPanel(cls, host, item): - """Generic panel creation for one, several or all groups (meta). - @parem host: the SatWebFrontend instance - @param item: single group as a string, list of groups - (as an array) or None (for the meta group = "all groups") - @return: the created MicroblogPanel - """ - _items = item if isinstance(item, list) else ([] if item is None else [item]) - # XXX: pyjamas doesn't support use of cls directly - _new_panel = MicroblogPanel(host, _items) - _new_panel.loadMoreMainEntries() - host.setSelected(_new_panel) - _new_panel.refresh() - return _new_panel - - @classmethod - def createMetaPanel(cls, host, item): - """Needed for the drop keys to not be mixed between meta panel and panel for "Contacts" group""" - return MicroblogPanel.createPanel(host, None) - - @property - def accepted_groups(self): - return self._accepted_groups - - def loadAllCommentsForEntry(self, main_entry): - """Load all the comments for the given main entry. - - @param main_entry (MicroblogEntry): main entry having comments. - """ - index = str(main_entry.comments_count - main_entry.hidden_count) - rsm = {'max': str(main_entry.hidden_count), 'index': index} - self.host.bridge.call('getMblogComments', self.mblogsInsert, main_entry.comments_service, main_entry.comments_node, rsm) - - def loadMoreMainEntries(self): - if self.footer.waiting: - return - self.footer.waiting = True - self.footer.setHTML("loading...") - - self.host.loadOurMainEntries(self.next_rsm_index, self) - - type_ = 'ALL' if self.accepted_groups == [] else 'GROUP' - rsm = {'max': str(C.RSM_MAX_ITEMS), 'index': str(self.next_rsm_index)} - self.host.bridge.call('getMassiveMblogs', self.massiveInsert, type_, self.accepted_groups, rsm) - - def matchEntity(self, item): - """ - @param item: single group as a string, list of groups - (as an array) or None (for the meta group = "all groups") - @return: True if self matches the given entity - """ - groups = item if isinstance(item, list) else ([] if item is None else [item]) - groups.sort() # sort() do not return the sorted list: do it here, not on the "return" line - return self.accepted_groups == groups - - def getWarningData(self, comment=None): - """ - @param comment: True if the composed message is a comment. If None, consider we are - composing from the unibox and guess the message type from self.selected_entry - @return: a couple (type, msg) for calling self.host.showWarning""" - if comment is None: # composing from the unibox - if self.selected_entry and not self.selected_entry.comments: - log.error("an item without comment is selected") - return ("NONE", None) - comment = self.selected_entry is not None - if comment: - return ("PUBLIC", "This is a comment and keep the initial post visibility, so it is potentialy public") - elif not self._accepted_groups: - # we have a meta MicroblogPanel, we publish publicly - return ("PUBLIC", self.warning_msg_public) - else: - # we only accept one group at the moment - # FIXME: manage several groups - return ("GROUP", self.warning_msg_group % self._accepted_groups[0]) - - def onTextEntered(self, text): - if self.selected_entry: - # we are entering a comment - comments_url = self.selected_entry.comments - if not comments_url: - raise Exception("ERROR: the comments URL is empty") - target = ("COMMENT", comments_url) - elif not self._accepted_groups: - # we are entering a public microblog - target = ("PUBLIC", None) - else: - # we are entering a microblog restricted to a group - # FIXME: manage several groups - target = ("GROUP", self._accepted_groups[0]) - self.host.send([target], text) - - def accept_all(self): - return not self._accepted_groups # we accept every microblog only if we are not filtering by groups - - def getEntries(self): - """Ask all the entries for the currenly accepted groups, - and fill the panel""" - - def massiveInsert(self, mblogs): - """Insert several microblogs at once - @param mblogs (dict): dictionary mapping a publisher to microblogs data: - - key: publisher (str) - - value: couple (list[dict], dict) with: - - list of microblogs data - - RSM response data - """ - count_pub = len(mblogs) - count_msg = sum([len(value) for value in mblogs.values()]) - log.debug("massive insertion of {count_msg} blogs for {count_pub} contacts".format(count_msg=count_msg, count_pub=count_pub)) - for publisher in mblogs: - log.debug("adding {count} blogs for [{publisher}]".format(count=len(mblogs[publisher]), publisher=publisher)) - self.mblogsInsert(mblogs[publisher]) - self.next_rsm_index += C.RSM_MAX_ITEMS - self.footer.waiting = False - self.footer.setHTML('show older messages') - - def mblogsInsert(self, mblogs): - """ Insert several microblogs from the same node at once. - - @param mblogs (list): couple (list[dict], dict) with: - - list of microblogs data - - RSM response data - """ - mblogs, rsm = mblogs - - for mblog in mblogs: - if "content" not in mblog: - log.warning("No content found in microblog [%s]" % mblog) - continue - self.addEntry(mblog, update_header=False) - - hashes = set([(entry['service'], entry['node']) for entry in mblogs if entry['type'] == 'comment']) - assert(len(hashes) < 2) # ensure the blogs come from the same node - if len(hashes) == 1: - main_entry = self.comments[hashes.pop()] - count = int(rsm['count']) - hidden = count - (int(rsm['index']) + len(mblogs)) - main_entry.updateHeader(count, hidden) - - def _chronoInsert(self, vpanel, entry, reverse=True): - """ Insert an entry in chronological order - @param vpanel: VerticalPanel instance - @param entry: MicroblogEntry - @param reverse: more recent entry on top if True, chronological order else""" - # XXX: for now we can't use "published" timestamp because the entries - # are retrieved using the "updated" field. We don't want new items - # inserted with RSM to be inserted "randomly" in the panel, they - # should be added at the bottom of the list. - assert(isinstance(reverse, bool)) - if entry.empty: - entry.updated = time() - # we look for the right index to insert our entry: - # if reversed, we insert the entry above the first entry - # in the past - idx = 0 - - for child in vpanel.children[0:-1]: # ignore the footer - if not isinstance(child, MicroblogEntry): - idx += 1 - continue - condition_to_stop = child.empty or (child.updated > entry.updated) - if condition_to_stop != reverse: # != is XOR - break - idx += 1 - - vpanel.insert(entry, idx) - - def addEntry(self, data, update_header=True): - """Add an entry to the panel - - @param data (dict): dict containing the item data - @param update_header (bool): update or not the main comment header - @return: the added MicroblogEntry instance, or None - """ - _entry = MicroblogEntry(self, data) - if _entry.type == "comment": - comments_hash = (_entry.service, _entry.node) - if comments_hash not in self.comments: - # The comments node is not known in this panel - return None - parent = self.comments[comments_hash] - parent_idx = self.vpanel.getWidgetIndex(parent) - # we find or create the panel where the comment must be inserted - try: - sub_panel = self.vpanel.getWidget(parent_idx + 1) - except IndexError: - sub_panel = None - if not sub_panel or not isinstance(sub_panel, VerticalPanel): - sub_panel = VerticalPanel() - sub_panel.setStyleName('microblogPanel') - sub_panel.addStyleName('subPanel') - self.vpanel.insert(sub_panel, parent_idx + 1) - - for idx in xrange(0, len(sub_panel.getChildren())): - comment = sub_panel.getIndexedChild(idx) - if comment.id == _entry.id: - # update an existing comment - sub_panel.remove(comment) - sub_panel.insert(_entry, idx) - return _entry - # we want comments to be inserted in chronological order - self._chronoInsert(sub_panel, _entry, reverse=False) - if update_header: - parent.updateHeader(inc=+1) - return _entry - - if _entry.comments: - # entry has comments, we keep the comments service/node as a reference - comments_hash = (_entry.comments_service, _entry.comments_node) - self.comments[comments_hash] = _entry - - if _entry.id in self.entries: # update - old_entry = self.entries[_entry.id] - idx = self.vpanel.getWidgetIndex(old_entry) - counts = (old_entry.comments_count, old_entry.hidden_count) - self.vpanel.remove(old_entry) - self.vpanel.insert(_entry, idx) - _entry.updateHeader(*counts) - else: # new entry - self._chronoInsert(self.vpanel, _entry) - if _entry.comments: - self.host.bridge.call('getMblogComments', self.mblogsInsert, _entry.comments_service, _entry.comments_node) - - self.entries[_entry.id] = _entry - - return _entry - - def removeEntry(self, type_, id_, update_header=True): - """Remove an entry from the panel - - @param type_ (str): entry type ('main_item' or 'comment') - @param id_ (str): entry id - @param update_header (bool): update or not the main comment header - """ - for child in self.vpanel.getChildren(): - if isinstance(child, MicroblogEntry) and type_ == 'main_item': - if child.id == id_: - main_idx = self.vpanel.getWidgetIndex(child) - try: - sub_panel = self.vpanel.getWidget(main_idx + 1) - if isinstance(sub_panel, VerticalPanel): - sub_panel.removeFromParent() - except IndexError: - pass - child.removeFromParent() - self.selected_entry = None - break - elif isinstance(child, VerticalPanel) and type_ == 'comment': - for comment in child.getChildren(): - if comment.id == id_: - if update_header: - hash_ = (comment.service, comment.node) - self.comments[hash_].updateHeader(inc=-1) - comment.removeFromParent() - self.selected_entry = None - break - - def ensureVisible(self, entry): - """Scroll to an entry to ensure its visibility - - @param entry (MicroblogEntry): the entry - """ - try: - self.vpanel.getParent().ensureVisible(entry) # scroll to the clicked entry - except AttributeError: - log.warning("FIXME: MicroblogPanel.vpanel should be wrapped in a ScrollPanel!") - - def setSelectedEntry(self, entry, ensure_visible=False): - """Select an entry. - - @param entry (MicroblogEntry): the entry to select - @param ensure_visible (boolean): if True, also scroll to the entry - """ - if ensure_visible: - self.ensureVisible(entry) - - if not self.host.uni_box or not entry.comments: - entry.addStyleName('selected_entry') # blink the clicked entry - clicked_entry = entry # entry may be None when the timer is done - Timer(500, lambda timer: clicked_entry.removeStyleName('selected_entry')) - if not self.host.uni_box: - return # unibox is disabled - - # from here the previous behavior (toggle main item selection) is conserved - entry = entry if entry.comments else None - if self.selected_entry == entry: - entry = None - if self.selected_entry: - self.selected_entry.removeStyleName('selected_entry') - if entry: - log.debug("microblog entry selected (author=%s)" % entry.author) - entry.addStyleName('selected_entry') - self.selected_entry = entry - - def updateValue(self, type_, jid, value): - """Update a jid value in entries - @param type_: one of 'avatar', 'nick' - @param jid: jid concerned - @param value: new value""" - def updateVPanel(vpanel): - for child in vpanel.children: - if isinstance(child, MicroblogEntry) and child.author == jid: - child.updateAvatar(value) - elif isinstance(child, VerticalPanel): - updateVPanel(child) - if type_ == 'avatar': - updateVPanel(self.vpanel) - - def setAcceptedGroup(self, group): - """Add one or more group(s) which can be displayed in this panel. - Prevent from duplicate values and keep the list sorted. - @param group: string of the group, or list of string - """ - if not hasattr(self, "_accepted_groups"): - self._accepted_groups = [] - groups = group if isinstance(group, list) else [group] - for _group in groups: - if _group not in self._accepted_groups: - self._accepted_groups.append(_group) - self._accepted_groups.sort() - - def isJidAccepted(self, jid_s): - """Tell if a jid is actepted and shown in this panel - @param jid_s: jid - @return: True if the jid is accepted""" - if self.accept_all(): - return True - for group in self._accepted_groups: - if self.host.contact_panel.isContactInGroup(group, jid_s): - return True - return False - - def onClick(self, sender): - if sender == self.footer: - self.loadMoreMainEntries() - - def onMouseEnter(self, sender): - if sender == self.footer: - self.loadMoreMainEntries() - - -class StatusPanel(base_panels.HTMLTextEditor): - - EMPTY_STATUS = '<click to set a status>' - - def __init__(self, host, status=''): - self.host = host - modifiedCb = lambda content: self.host.bridge.call('setStatus', None, self.host.status_panel.presence, content['text']) or True - base_panels.HTMLTextEditor.__init__(self, {'text': status}, modifiedCb, options={'no_xhtml': True, 'listen_focus': True, 'listen_click': True}) - self.edit(False) - self.setStyleName('statusPanel') - - @property - def status(self): - return self._original_content['text'] - - def __cleanContent(self, content): - status = content['text'] - if status == self.EMPTY_STATUS or status in C.PRESENCE.values(): - content['text'] = '' - return content - - def getContent(self): - return self.__cleanContent(base_panels.HTMLTextEditor.getContent(self)) - - def setContent(self, content): - content = self.__cleanContent(content) - base_panels.BaseTextEditor.setContent(self, content) - - def setDisplayContent(self): - status = self._original_content['text'] - try: - presence = self.host.status_panel.presence - except AttributeError: # during initialization - presence = None - if not status: - if presence and presence in C.PRESENCE: - status = C.PRESENCE[presence] - else: - status = self.EMPTY_STATUS - self.display.setHTML(addURLToText(status)) - - -class PresenceStatusMenuBar(base_widget.WidgetMenuBar): - def __init__(self, parent): - styles = {'menu_bar': 'presence-button'} - base_widget.WidgetMenuBar.__init__(self, None, parent.host, styles=styles) - self.button = self.addCategory(u"◉", u"◉", '') - for presence, presence_i18n in C.PRESENCE.items(): - html = u' %s' % (contact.buildPresenceStyle(presence), presence_i18n) - self.addMenuItem([u"◉", presence], [u"◉", html], '', base_menu.MenuCmd(self, 'changePresenceCb', presence), asHTML=True) - self.parent_panel = parent - - def changePresenceCb(self, presence): - """Callback to notice the backend of a new presence set by the user. - @param presence (str): the new presence is a value in ('', 'chat', 'away', 'dnd', 'xa') - """ - self.host.bridge.call('setStatus', None, presence, self.parent_panel.status_panel.status) - - @classmethod - def getCategoryHTML(cls, menu_name_i18n, type_): - return menu_name_i18n - - -class PresenceStatusPanel(HorizontalPanel, ClickHandler): - - def __init__(self, host, presence="", status=""): - self.host = host - HorizontalPanel.__init__(self, Width='100%') - self.menu = PresenceStatusMenuBar(self) - self.status_panel = StatusPanel(host, status=status) - self.setPresence(presence) - - panel = HorizontalPanel() - panel.add(self.menu) - panel.add(self.status_panel) - panel.setCellVerticalAlignment(self.menu, 'baseline') - panel.setCellVerticalAlignment(self.status_panel, 'baseline') - panel.setStyleName("marginAuto") - self.add(panel) - - self.status_panel.edit(False) - - ClickHandler.__init__(self) - self.addClickListener(self) - - @property - def presence(self): - return self._presence - - @property - def status(self): - return self.status_panel._original_content['text'] - - def setPresence(self, presence): - self._presence = presence - contact.setPresenceStyle(self.menu.button, self._presence) - - def setStatus(self, status): - self.status_panel.setContent({'text': status}) - self.status_panel.setDisplayContent() - - def onClick(self, sender): - # As status is the default target of uniBar, we don't want to select anything if click on it - self.host.setSelected(None) - - -class ChatPanel(base_widget.LiberviaWidget, KeyboardHandler): - - def __init__(self, host, target, type_='one2one'): - """Panel used for conversation (one 2 one or group chat) - @param host: SatWebFrontend instance - @param target: entity (jid.JID) with who we have a conversation (contact's jid for one 2 one chat, or MUC room) - @param type: one2one for simple conversation, group for MUC""" - self.vpanel = VerticalPanel() - self.vpanel.setSize('100%', '100%') - self.nick = None - if not target: - log.error("Empty target !") - return - self.target = target - self.type = type_ - - # FIXME: temporary dirty initialization to display the OTR state - def header_info_cb(cb): - host.plugins['otr'].infoTextCallback(target, cb) - header_info = header_info_cb if (type_ == 'one2one' and 'otr' in host.plugins) else None - - base_widget.LiberviaWidget.__init__(self, host, title=target.bare, info=header_info, selectable=True) - self.__body = AbsolutePanel() - self.__body.setStyleName('chatPanel_body') - chat_area = HorizontalPanel() - chat_area.setStyleName('chatArea') - if type_ == 'group': - self.occupants_list = base_panels.OccupantsList() - self.occupants_initialised = False - chat_area.add(self.occupants_list) - self.__body.add(chat_area) - self.content = AbsolutePanel() - self.content.setStyleName('chatContent') - self.content_scroll = base_widget.ScrollPanelWrapper(self.content) - chat_area.add(self.content_scroll) - chat_area.setCellWidth(self.content_scroll, '100%') - self.vpanel.add(self.__body) - self.vpanel.setCellHeight(self.__body, '100%') - self.addStyleName('chatPanel') - self.setWidget(self.vpanel) - self.state_machine = plugin_xep_0085.ChatStateMachine(self.host, str(self.target)) - self._state = None - - @classmethod - def registerClass(cls): - base_widget.LiberviaWidget.addDropKey("CONTACT", cls.createPanel) - - @classmethod - def createPanel(cls, host, item, type_='one2one'): - assert(item) - _contact = item if isinstance(item, jid.JID) else jid.JID(item) - host.contact_panel.setContactMessageWaiting(_contact.bare, False) - _new_panel = ChatPanel(host, _contact, type_) # XXX: pyjamas doesn't seems to support creating with cls directly - _new_panel.historyPrint() - host.setSelected(_new_panel) - _new_panel.refresh() - return _new_panel - - def refresh(self): - """Refresh the display of this widget. If the unibox is disabled, - add a message box at the bottom of the panel""" - self.host.contact_panel.setContactMessageWaiting(self.target.bare, False) - self.content_scroll.scrollToBottom() - - enable_box = self.host.uni_box is None - if hasattr(self, 'message_box'): - self.message_box.setVisible(enable_box) - return - if enable_box: - self.message_box = MessageBox(self.host) - self.message_box.onSelectedChange(self) - self.message_box.addKeyboardListener(self) - self.vpanel.add(self.message_box) - - def onKeyDown(self, sender, keycode, modifiers): - if keycode == KEY_ENTER: - self.host.showWarning(None, None) - else: - self.host.showWarning(*self.getWarningData()) - - def matchEntity(self, item, type_=None): - """ - @param entity: target jid as a string or jid.JID instance. - @return: True if self matches the given entity - """ - if type_ is None: - type_ = self.type - entity = item if isinstance(item, jid.JID) else jid.JID(item) - try: - return self.target.bare == entity.bare and self.type == type_ - except AttributeError as e: - e.include_traceback() - return False - - def addMenus(self, menu_bar): - """Add cached menus to the header. - - @param menu_bar (GenericMenuBar): menu bar of the widget's header - """ - if self.type == 'group': - menu_bar.addCachedMenus(C.MENU_ROOM, {'room_jid': self.target.bare}) - elif self.type == 'one2one': - menu_bar.addCachedMenus(C.MENU_SINGLE, {'jid': self.target}) - - def getWarningData(self): - if self.type not in ["one2one", "group"]: - raise Exception("Unmanaged type !") - if self.type == "one2one": - msg = "This message will be sent to your contact %s" % self.target - elif self.type == "group": - msg = "This message will be sent to all the participants of the multi-user room %s" % self.target - return ("ONE2ONE" if self.type == "one2one" else "GROUP", msg) - - def onTextEntered(self, text): - self.host.send([("groupchat" if self.type == 'group' else "chat", str(self.target))], text) - self.state_machine._onEvent("active") - - def onQuit(self): - base_widget.LiberviaWidget.onQuit(self) - if self.type == 'group': - self.host.bridge.call('mucLeave', None, self.target.bare) - - def setUserNick(self, nick): - """Set the nick of the user, usefull for e.g. change the color of the user""" - self.nick = nick - - def setPresents(self, nicks): - """Set the users presents in this room - @param occupants: list of nicks (string)""" - for nick in nicks: - self.occupants_list.addOccupant(nick) - self.occupants_initialised = True - - def userJoined(self, nick, data): - if self.occupants_list.getOccupantBox(nick): - return # user is already displayed - self.occupants_list.addOccupant(nick) - if self.occupants_initialised: - self.printInfo("=> %s has joined the room" % nick) - - def userLeft(self, nick, data): - self.occupants_list.removeOccupant(nick) - self.printInfo("<= %s has left the room" % nick) - - def changeUserNick(self, old_nick, new_nick): - assert(self.type == "group") - self.occupants_list.removeOccupant(old_nick) - self.occupants_list.addOccupant(new_nick) - self.printInfo(_("%(old_nick)s is now known as %(new_nick)s") % {'old_nick': old_nick, 'new_nick': new_nick}) - - def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT): - """Print the initial history""" - def getHistoryCB(history): - # display day change - day_format = "%A, %d %b %Y" - previous_day = datetime.now().strftime(day_format) - for line in history: - timestamp, from_jid_s, to_jid_s, message, mess_type, extra = line - message_day = datetime.fromtimestamp(float(timestamp or time())).strftime(day_format) - if previous_day != message_day: - self.printInfo("* " + message_day) - previous_day = message_day - self.printMessage(jid.JID(from_jid_s), message, extra, timestamp) - self.host.bridge.call('getHistory', getHistoryCB, self.host.whoami.bare, self.target.bare, size, True) - - def printInfo(self, msg, type_='normal', link_cb=None): - """Print general info - @param msg: message to print - @param type_: one of: - "normal": general info like "toto has joined the room" (will be sanitized) - "link": general info that is clickable like "click here to join the main room" (no sanitize done) - "me": "/me" information like "/me clenches his fist" ==> "toto clenches his fist" (will stay on one line) - @param link_cb: method to call when the info is clicked, ignored if type_ is not 'link' - """ - if type_ == 'normal': - _wid = HTML(addURLToText(html_tools.XHTML2Text(msg))) - _wid.setStyleName('chatTextInfo') - elif type_ == 'link': - _wid = HTML(msg) - _wid.setStyleName('chatTextInfo-link') - if link_cb: - _wid.addClickListener(link_cb) - elif type_ == 'me': - _wid = Label(msg) - _wid.setStyleName('chatTextMe') - else: - raise ValueError("Unknown printInfo type %s" % type_) - self.content.add(_wid) - self.content_scroll.scrollToBottom() - - def printMessage(self, from_jid, msg, extra, timestamp=None): - """Print message in chat window. Must be implemented by child class""" - nick = from_jid.node if self.type == 'one2one' else from_jid.resource - mymess = from_jid.resource == self.nick if self.type == "group" else from_jid.bare == self.host.whoami.bare # mymess = True if message comes from local user - if msg.startswith('/me '): - self.printInfo('* %s %s' % (nick, msg[4:]), type_='me') - return - self.content.add(base_panels.ChatText(timestamp, nick, mymess, msg, extra.get('xhtml'))) - self.content_scroll.scrollToBottom() - - def startGame(self, game_type, waiting, referee, players, *args): - """Configure the chat window to start a game""" - classes = {"Tarot": card_game.CardPanel, "RadioCol": radiocol.RadioColPanel} - if game_type not in classes.keys(): - return # unknown game - attr = game_type.lower() - self.occupants_list.updateSpecials(players, SYMBOLS[attr]) - if waiting or not self.nick in players: - return # waiting for player or not playing - attr = "%s_panel" % attr - if hasattr(self, attr): - return - log.info("%s Game Started \o/" % game_type) - panel = classes[game_type](self, referee, self.nick, players, *args) - setattr(self, attr, panel) - self.vpanel.insert(panel, 0) - self.vpanel.setCellHeight(panel, panel.getHeight()) - - def getGame(self, game_type): - """Return class managing the game type""" - # TODO: check that the game is launched, and manage errors - if game_type == "Tarot": - return self.tarot_panel - elif game_type == "RadioCol": - return self.radiocol_panel - - def setState(self, state, nick=None): - """Set the chat state (XEP-0085) of the contact. Leave nick to None - to set the state for a one2one conversation, or give a nickname or - C.ALL_OCCUPANTS to set the state of a participant within a MUC. - @param state: the new chat state - @param nick: ignored for one2one, otherwise the MUC user nick or C.ALL_OCCUPANTS - """ - if self.type == 'group': - assert(nick) - if nick == C.ALL_OCCUPANTS: - occupants = self.occupants_list.occupants_list.keys() - else: - occupants = [nick] if nick in self.occupants_list.occupants_list else [] - for occupant in occupants: - self.occupants_list.occupants_list[occupant].setState(state) - else: - self._state = state - self.refreshTitle() - self.state_machine.started = not not state # start to send "composing" state from now - - def refreshTitle(self): - """Refresh the title of this ChatPanel dialog""" - if self._state: - self.setTitle(self.target.bare + " (" + self._state + ")") - else: - self.setTitle(self.target.bare) - - def setConnected(self, jid_s, resource, availability, priority, statuses): - """Set connection status - @param jid_s (str): JID userhost as unicode - """ - assert(jid_s == self.target.bare) - if self.type != 'group': - return - box = self.occupants_list.getOccupantBox(resource) - if box: - contact.setPresenceStyle(box, availability) - - -class WebPanel(base_widget.LiberviaWidget): - """ (mini)browser like widget """ - - def __init__(self, host, url=None): - """ - @param host: SatWebFrontend instance - """ - base_widget.LiberviaWidget.__init__(self, host) - self._vpanel = VerticalPanel() - self._vpanel.setSize('100%', '100%') - self._url = dialog.ExtTextBox(enter_cb=self.onUrlClick) - self._url.setText(url or "") - self._url.setWidth('100%') - hpanel = HorizontalPanel() - hpanel.add(self._url) - btn = Button("Go", self.onUrlClick) - hpanel.setCellWidth(self._url, "100%") - #self.setCellWidth(btn, "10%") - hpanel.add(self._url) - hpanel.add(btn) - self._vpanel.add(hpanel) - self._vpanel.setCellHeight(hpanel, '20px') - self._frame = Frame(url or "") - self._frame.setSize('100%', '100%') - DOM.setStyleAttribute(self._frame.getElement(), "position", "relative") - self._vpanel.add(self._frame) - self.setWidget(self._vpanel) - - def onUrlClick(self, sender): - self._frame.setUrl(self._url.getText()) - - -class MainPanel(AbsolutePanel): - - def __init__(self, host): - self.host = host - AbsolutePanel.__init__(self) - - # menu - self.menu = menu.MainMenuPanel(host) - - # unibox - self.unibox_panel = UniBoxPanel(host) - self.unibox_panel.setVisible(False) - - # contacts - self._contacts = HorizontalPanel() - self._contacts.addStyleName('globalLeftArea') - self.contacts_switch = Button(u'«', self._contactsSwitch) - self.contacts_switch.addStyleName('contactsSwitch') - self._contacts.add(self.contacts_switch) - self._contacts.add(self.host.contact_panel) - - # tabs - self.tab_panel = base_widget.MainTabPanel(host) - self.discuss_panel = base_widget.WidgetsPanel(self.host, locked=True) - self.tab_panel.add(self.discuss_panel, "Discussions") - self.tab_panel.selectTab(0) - - self.header = AbsolutePanel() - self.header.add(self.menu) - self.header.add(self.unibox_panel) - self.header.add(self.host.status_panel) - self.header.setStyleName('header') - self.add(self.header) - - self._hpanel = HorizontalPanel() - self._hpanel.add(self._contacts) - self._hpanel.add(self.tab_panel) - self.add(self._hpanel) - - self.setWidth("100%") - Window.addWindowResizeListener(self) - - def _contactsSwitch(self, btn=None): - """ (Un)hide contacts panel """ - if btn is None: - btn = self.contacts_switch - cpanel = self.host.contact_panel - cpanel.setVisible(not cpanel.getVisible()) - btn.setText(u"«" if cpanel.getVisible() else u"»") - self.host.resize() - - def _contactsMove(self, parent): - """Move the contacts container (containing the contact list and - the "hide/show" button) to another parent, but always as the - first child position (insert at index 0). - """ - if self._contacts.getParent(): - if self._contacts.getParent() == parent: - return - self._contacts.removeFromParent() - parent.insert(self._contacts, 0) - - def onWindowResized(self, width, height): - _elts = doc().getElementsByClassName('gwt-TabBar') - if not _elts.length: - tab_bar_h = 0 - else: - tab_bar_h = _elts.item(0).offsetHeight - ideal_height = Window.getClientHeight() - tab_bar_h - self.setHeight("%s%s" % (ideal_height, "px")) - - def refresh(self): - """Refresh the main panel""" - self.unibox_panel.refresh() - self.host.contact_panel.refresh() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/plugin_sec_otr.py --- a/src/browser/sat_browser/plugin_sec_otr.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/plugin_sec_otr.py Wed Mar 18 16:15:18 2015 +0100 @@ -23,23 +23,24 @@ The text messages to display are mostly taken from the Pidgin OTR plugin (GPL 2.0, see http://otr.cypherpunks.ca). """ -from sat.core.i18n import _, D_ from sat.core.log import getLogger -from sat.core import exceptions log = getLogger(__name__) +from sat.core.i18n import _, D_ +from sat.core import exceptions +from sat.tools.misc import TriggerManager + from constants import Const as C -import jid +from sat_frontends.tools import jid import otrjs_wrapper as otr import dialog -import panels +import chat NS_OTR = "otr_plugin" PRIVATE_KEY = "PRIVATE KEY" -MAIN_MENU = D_('OTR encryption') +MAIN_MENU = D_('OTR') # TODO: get this constant directly from backend's plugin DIALOG_EOL = "
" -DIALOG_USERS_ML = D_("users@salut-a-toi.org") AUTH_TRUSTED = D_("Verified") AUTH_UNTRUSTED = D_("Unverified") @@ -72,7 +73,8 @@ AKE_ENCRYPTED = D_(" conversation with {jid} started. Your client is not logging this conversation.") AKE_NOT_ENCRYPTED = D_("ERROR: successfully ake'd with {jid} but the conversation is not encrypted!") END_ENCRYPTED = D_("ERROR: the OTR session ended but the context is still supposedly encrypted!") -END_PLAIN = D_("Your conversation with {jid} is no more or hasn't been encrypted.") +END_PLAIN_NO_MORE = D_("Your conversation with {jid} is no more encrypted.") +END_PLAIN_HAS_NOT = D_("Your conversation with {jid} hasn't been encrypted.") END_FINISHED = D_("{jid} has ended his or her private conversation with you; you should do the same.") KEY_TITLE = D_('Private key') @@ -92,15 +94,13 @@ ACTION_NA_TITLE = D_("Impossible action") ACTION_NA = D_("Your correspondent must be connected to start an OTR conversation with him.") -RESOURCE_ISSUE_TITLE = D_("Security issue") -RESOURCE_ISSUE = D_("Your correspondent's resource is unknown!{eol}{eol}You should stop any OTR conversation with {jid} to avoid sending him unencrypted messages in an encrypted context.{eol}{eol}Please report the bug to the users mailing list: {users_ml}.") DEFAULT_POLICY_FLAGS = { 'ALLOW_V2': True, 'ALLOW_V3': True, 'REQUIRE_ENCRYPTION': False, - 'SEND_WHITESPACE_TAG': False, # FIXME: we need to complete sendMessageTrigger before turning this to True - 'WHITESPACE_START_AKE': False, # FIXME: we need to complete messageReceivedTrigger before turning this to True + 'SEND_WHITESPACE_TAG': False, # FIXME: we need to complete sendMessageTg before turning this to True + 'WHITESPACE_START_AKE': False, # FIXME: we need to complete newMessageTg before turning this to True } # list a couple of texts or htmls (untrusted, trusted) for each state @@ -120,6 +120,13 @@ } +unicode = str # FIXME: pyjamas workaround + + +class NotConnectedEntity(Exception): + pass + + class Context(otr.context.Context): def __init__(self, host, account, other_jid): @@ -127,7 +134,7 @@ @param host (satWebFrontend) @param account (Account) - @param other_jid (JID): JID of the person your chat correspondent + @param other_jid (jid.JID): JID of the person your chat correspondent """ super(Context, self).__init__(account, other_jid) self.host = host @@ -135,7 +142,7 @@ def getPolicy(self, key): """Get the value of the specified policy - @param key (str): a value in: + @param key (unicode): a value in: - ALLOW_V1 (apriori removed from otr.js) - ALLOW_V2 - ALLOW_V3 @@ -143,7 +150,7 @@ - SEND_WHITESPACE_TAG - WHITESPACE_START_AKE - ERROR_START_AKE - @return: str + @return: unicode """ if key in DEFAULT_POLICY_FLAGS: return DEFAULT_POLICY_FLAGS[key] @@ -157,14 +164,14 @@ log.debug("message received (was %s): %s" % ('encrypted' if encrypted else 'plain', msg)) if not encrypted: if self.state == otr.context.STATE_ENCRYPTED: - log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer.full()}) - self.host.newMessageCb(self.peer, RECEIVE_PLAIN_IN_ENCRYPTED_CONTEXT, C.MESS_TYPE_INFO, self.host.whoami, {}) - self.host.newMessageCb(self.peer, msg, "chat", self.host.whoami, {}) + log.warning(u"Received unencrypted message in an encrypted context (from %(jid)s)" % {'jid': self.peer}) + self.host.newMessageHandler(unicode(self.peer), RECEIVE_PLAIN_IN_ENCRYPTED_CONTEXT, C.MESS_TYPE_INFO, unicode(self.host.whoami), {}) + self.host.newMessageHandler(unicode(self.peer), msg, C.MESS_TYPE_CHAT, unicode(self.host.whoami), {}) def sendMessageCb(self, msg, meta=None): assert isinstance(self.peer, jid.JID) log.debug("message to send%s: %s" % ((' (attached meta data: %s)' % meta) if meta else '', msg)) - self.host.bridge.call('sendMessage', (None, self.host.sendError), self.peer.full(), msg, '', 'chat', {'send_only': 'true'}) + self.host.bridge.call('sendMessage', (None, self.host.sendError), unicode(self.peer), msg, '', C.MESS_TYPE_CHAT, {'send_only': 'true'}) def messageErrorCb(self, error): log.error('error occured: %s' % error) @@ -173,7 +180,7 @@ if status == otr.context.STATUS_AKE_INIT: return - other_jid_s = self.peer.full() + other_jid_s = self.peer feedback = _(u"Error: the state of the conversation with %s is unknown!") trust = self.getCurrentTrust() @@ -186,17 +193,17 @@ elif status == otr.context.STATUS_END_OTR: if msg_state == otr.context.STATE_PLAINTEXT: - feedback = END_PLAIN + feedback = END_PLAIN_NO_MORE elif msg_state == otr.context.STATE_ENCRYPTED: log.error(END_ENCRYPTED) elif msg_state == otr.context.STATE_FINISHED: feedback = END_FINISHED - self.host.newMessageCb(self.peer, feedback.format(jid=other_jid_s), C.MESS_TYPE_INFO, self.host.whoami, {'header_info': OTR.getInfoText(msg_state, trust)}) + self.host.newMessageHandler(unicode(self.peer), feedback.format(jid=other_jid_s), C.MESS_TYPE_INFO, unicode(self.host.whoami), {'header_info': OTR.getInfoText(msg_state, trust)}) def setCurrentTrust(self, new_trust='', act='asked', type_='trust'): log.debug("setCurrentTrust: trust={trust}, act={act}, type={type}".format(type=type_, trust=new_trust, act=act)) - title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer.full()) + title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer) old_trust = self.getCurrentTrust() if type_ == 'abort': msg = AUTH_ABORTED_TXT @@ -215,7 +222,7 @@ otr.context.Context.setCurrentTrust(self, new_trust) if old_trust != new_trust: feedback = AUTH_STATUS.format(state=(AUTH_TRUSTED if new_trust else AUTH_UNTRUSTED).lower()) - self.host.newMessageCb(self.peer, feedback, C.MESS_TYPE_INFO, self.host.whoami, {'header_info': OTR.getInfoText(self.state, new_trust)}) + self.host.newMessageHandler(unicode(self.peer), feedback, C.MESS_TYPE_INFO, unicode(self.host.whoami), {'header_info': OTR.getInfoText(self.state, new_trust)}) def fingerprintAuthCb(self): """OTR v2 authentication using manual fingerprint comparison""" @@ -236,17 +243,17 @@ self.setCurrentTrust('fingerprint' if confirm else '') text = (AUTH_INFO_TXT + "" + AUTH_FINGERPRINT_TXT + "" + AUTH_FINGERPRINT_VERIFY).format(you=self.host.whoami, your_fp=priv_key.fingerprint(), other=self.peer, other_fp=other_key.fingerprint(), eol=DIALOG_EOL) - title = AUTH_OTHER_TITLE.format(jid=self.peer.full()) + title = AUTH_OTHER_TITLE.format(jid=self.peer) dialog.ConfirmDialog(setTrust, text, title, AddStyleName="maxWidthLimit").show() def smpAuthCb(self, type_, data, act=None): """OTR v3 authentication using the socialist millionaire protocol. - @param type_ (str): a value in ('question', 'trust', 'abort') - @param data (str, bool): this could be: + @param type_ (unicode): a value in ('question', 'trust', 'abort') + @param data (unicode, bool): this could be: - a string containing the question if type_ is 'question' - a boolean value telling if the authentication succeed when type_ is 'trust' - @param act (str): a value in ('asked', 'answered') + @param act (unicode): a value in ('asked', 'answered') """ log.debug("smpAuthCb: type={type}, data={data}, act={act}".format(type=type_, data=data, act=act)) if act is None: @@ -261,23 +268,23 @@ # make us need the dirty self.smpAuthAbort. else: log.error("FIXME: unmanaged ambiguous 'act' value in Context.smpAuthCb!") - title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer.full()) + title = (AUTH_OTHER_TITLE if act == "asked" else AUTH_US_TITLE).format(jid=self.peer) if type_ == 'question': if act == 'asked': - def cb(question, answer=None): - if question is False or not answer: # dialog cancelled or the answer is empty + def cb(result, question, answer=None): + if not result or not answer: # dialog cancelled or the answer is empty return self.smpAuthSecret(answer, question) text = (AUTH_INFO_TXT + "" + AUTH_QUEST_DEFINE_TXT + "" + AUTH_QUEST_DEFINE).format(eol=DIALOG_EOL) dialog.PromptDialog(cb, [text, AUTH_SECRET_INPUT.format(eol=DIALOG_EOL)], title=title, AddStyleName="maxWidthLimit").show() else: - def cb(answer): - if not answer: # dialog cancelled or the answer is empty + def cb(result, answer): + if not result or not answer: # dialog cancelled or the answer is empty self.smpAuthAbort('answered') return self.smpAuthSecret(answer) text = (AUTH_INFO_TXT + "" + AUTH_QUEST_ANSWER_TXT + "" + AUTH_QUEST_ANSWER).format(eol=DIALOG_EOL, question=data) - dialog.PromptDialog(cb, text + AUTH_SECRET_INPUT.format(eol=DIALOG_EOL), title=title, AddStyleName="maxWidthLimit").show() + dialog.PromptDialog(cb, [text + AUTH_SECRET_INPUT.format(eol=DIALOG_EOL)], title=title, AddStyleName="maxWidthLimit").show() elif type_ == 'trust': self.setCurrentTrust('smp' if data else '', act) elif type_ == 'abort': @@ -297,7 +304,7 @@ class Account(otr.context.Account): def __init__(self, host): - log.debug(u"new account: %s" % host.whoami.full()) + log.debug(u"new account: %s" % host.whoami) if not host.whoami.resource: log.warning("Account created without resource") super(Account, self).__init__(host.whoami) @@ -327,7 +334,7 @@ self.contexts = {} def startContext(self, other_jid): - assert isinstance(other_jid, jid.JID) + assert isinstance(other_jid, jid.JID) # never start an OTR session with a bare JID # FIXME upstream: apparently pyjamas doesn't implement setdefault well, it ignores JID.__hash__ redefinition #context = self.contexts.setdefault(other_jid, Context(self.host, self.account, other_jid)) if other_jid not in self.contexts: @@ -337,24 +344,42 @@ def getContextForUser(self, other_jid, start=True): """Get the context for the given JID - @param other_jid (JID): your correspondent + @param other_jid (jid.JID): your correspondent @param start (bool): start non-existing context if True @return: Context """ + try: + other_jid = self.fixResource(other_jid) + except NotConnectedEntity: + log.debug(u"getContextForUser [%s]: not connected!" % other_jid) + return None log.debug(u"getContextForUser [%s]" % other_jid) - if not other_jid.resource: - log.error("getContextForUser called with a bare jid") - running_sessions = [jid.bareJID() for jid in self.contexts.keys() if self.contexts[jid].state == otr.context.STATE_ENCRYPTED] - if start or (other_jid in running_sessions): - users_ml = DIALOG_USERS_ML.format(subject=D_("OTR issue in Libervia: getContextForUser called with a bare jid in an encrypted context")) - text = RESOURCE_ISSUE.format(eol=DIALOG_EOL, jid=other_jid.full(), users_ml=users_ml) - dialog.InfoDialog(RESOURCE_ISSUE_TITLE, text, AddStyleName="maxWidthLimit").show() - return None # never start an OTR session with a bare JID if start: return self.startContext(other_jid) else: return self.contexts.get(other_jid, None) + def getContextsForBareUser(self, bare_jid): + """Get all the contexts for the users sharing the given bare JID. + + @param bare_jid (jid.JID): bare JID + @return: list[Context] + """ + return [context for other_jid, context in self.contexts.iteritems() if other_jid.bare == bare_jid] + + def fixResource(self, other_jid): + """Return the full JID in case the resource of the given JID is missing. + + @param other_jid (jid.JID): JID to check + @return jid.JID + """ + if other_jid.resource: + return other_jid + clist = self.host.contact_list + if clist.getCache(other_jid.bare, C.PRESENCE_SHOW) is None: + raise NotConnectedEntity + return clist.getFullJid(other_jid) + class OTR(object): @@ -362,161 +387,140 @@ log.info(_(u"OTR plugin initialization")) self.host = host self.context_manager = None - self.last_resources = {} self.host.bridge._registerMethods(["skipOTR"]) + self.host.trigger.add("newMessageTrigger", self.newMessageTg, priority=TriggerManager.MAX_PRIORITY) + self.host.trigger.add("sendMessageTrigger", self.sendMessageTg, priority=TriggerManager.MAX_PRIORITY) + + # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) + self._profilePluggedListener = self.profilePluggedListener + self._gotMenusListener = self.gotMenusListener + # FIXME: these listeners are never removed, can't be removed by themselves (it modifies the list while looping), maybe need a 'one_shot' argument + self.host.addListener('profilePlugged', self._profilePluggedListener) + self.host.addListener('gotMenus', self._gotMenusListener) @classmethod def getInfoText(self, state=otr.context.STATE_PLAINTEXT, trust=''): """Get the widget info text for a certain message state and trust. - @param state (str): message state - @param trust (str): trust - @return: str + @param state (unicode): message state + @param trust (unicode): trust + @return: unicode """ if not state: state = OTR_MSG_STATES.keys()[0] return OTR_MSG_STATES[state][1 if trust else 0] - def infoTextCallback(self, other_jid, cb): - """Get the current info text for a conversation and run a callback. + def getInfoTextForUser(self, other_jid): + """Get the current info text for a conversation. - @param other_jid (JID): JID of the correspondant - @paam cb (callable): method to be called with the computed info text + @param other_jid (jid.JID): JID of the correspondant """ - def gotResource(other_jid): - otrctx = self.context_manager.getContextForUser(other_jid, start=False) - if otrctx is None: - cb(OTR.getInfoText()) - else: - cb(OTR.getInfoText(otrctx.state, otrctx.getCurrentTrust())) + otrctx = self.context_manager.getContextForUser(other_jid, start=False) + if otrctx is None: + return OTR.getInfoText() + else: + return OTR.getInfoText(otrctx.state, otrctx.getCurrentTrust()) - self.fixResource(other_jid, gotResource) - - def inhibitMenus(self): - """Tell the caller which dynamic menus should be inhibited""" - return ["OTR"] # menu categories name to inhibit + def gotMenusListener(self,): + # TODO: get menus paths to hook directly from backend's OTR plugin + self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Start/Refresh")), callback=self._startRefresh) + self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"End session")), callback=self._endSession) + self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Authenticate")), callback=self._authenticate) + self.host.menus.addMenuHook(C.MENU_SINGLE, (MAIN_MENU, D_(u"Drop private key")), callback=self._dropPrivkey) - def extraMenus(self): - # FIXME: handle help strings too - return [(self._startRefresh, C.MENU_SINGLE, (MAIN_MENU, "Start / refresh"), (MAIN_MENU, D_("Start / refresh"))), - (self._endSession, C.MENU_SINGLE, (MAIN_MENU, "Stop encryption"), (MAIN_MENU, D_("Stop encryption"))), - (self._authenticate, C.MENU_SINGLE, (MAIN_MENU, "Authenticate correspondent"), (MAIN_MENU, D_("Authenticate correspondent"))), - (self._dropPrivkey, C.MENU_SINGLE, (MAIN_MENU, "Drop your private key"), (MAIN_MENU, D_("Drop your private key")))] + def profilePluggedListener(self, profile): + # FIXME: workaround for a pyjamas issue: calling hash on a class method always return a different value if that method is defined directly within the class (with the "def" keyword) + self._presenceListener = self.presenceListener + self._disconnectListener = self.disconnectListener + self.host.addListener('presence', self._presenceListener, [C.PROF_KEY_NONE]) + # FIXME: this listener is never removed, can't be removed by itself (it modifies the list while looping), maybe need a 'one_shot' argument + self.host.addListener('disconnect', self._disconnectListener, [C.PROF_KEY_NONE]) - def profileConnected(self): self.host.bridge.call('skipOTR', None) self.context_manager = ContextManager(self.host) # TODO: retrieve the encrypted private key from a HTML5 persistent storage, # decrypt it, parse it with otr.crypt.PK.parsePrivateKey(privkey) and # assign it to self.context_manager.account.privkey - def profileDisconnected(self): + def disconnectListener(self, profile): + """Things to do just before the profile disconnection""" + self.host.removeListener('presence', self._presenceListener) + for context in self.context_manager.contexts.values(): - context.disconnect() + context.disconnect() # FIXME: no time to send the message before the profile has been disconnected - def fixResource(self, jid, cb): - # FIXME: it's dirty, but libervia doesn't manage resources correctly now, refactoring is planed - if jid.resource: - self.last_resources[jid.bare] = jid.resource - cb(jid) - elif jid.bare in self.last_resources: - jid.setResource(self.last_resources[jid.bare]) - cb(jid) - else: - def gotResource(resource): - if resource: - jid.setResource(resource) - self.last_resources[jid.bare] = jid.resource - cb(jid) - self.host.bridge.call('getLastResource', gotResource, jid.full()) + def presenceListener(self, entity, show, priority, statuses, profile): + if show == C.PRESENCE_UNAVAILABLE: + self.endSession(entity, disconnect=False) - def messageReceivedTrigger(self, from_jid, msg, msg_type, to_jid, extra): - if msg_type == C.MESS_TYPE_INFO: + def newMessageTg(self, from_jid, msg, msg_type, to_jid, extra, profile): + if msg_type != C.MESS_TYPE_CHAT: return True tag = otr.proto.checkForOTR(msg) if tag is None or (tag == otr.context.WHITESPACE_TAG and not DEFAULT_POLICY_FLAGS['WHITESPACE_START_AKE']): return True - def decrypt(context): - context.receiveMessage(msg) + other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid + otrctx = self.context_manager.getContextForUser(other_jid, start=False) + if otrctx is None: + def confirm(confirm): + if confirm: + self.host.displayWidget(chat.Chat, other_jid) + self.context_manager.startContext(other_jid).receiveMessage(msg) + else: + # FIXME: plain text messages with whitespaces would be lost here when WHITESPACE_START_AKE is True + pass + key = self.context_manager.account.privkey + question = QUERY_RECEIVED + QUERY_SLOWDOWN + (QUERY_KEY if key else QUERY_NO_KEY) + QUERY_CONFIRM + dialog.ConfirmDialog(confirm, question.format(jid=other_jid, eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show() + else: # do not ask for user confirmation if the context exist + otrctx.receiveMessage(msg) - def cb(jid): - otrctx = self.context_manager.getContextForUser(jid, start=False) - if otrctx is None: - def confirm(confirm): - if confirm: - self.host.getOrCreateLiberviaWidget(panels.ChatPanel, {'item': jid}) - decrypt(self.context_manager.startContext(jid)) - else: - # FIXME: plain text messages with whitespaces would be lost here when WHITESPACE_START_AKE is True - pass - key = self.context_manager.account.privkey - msg = QUERY_RECEIVED + QUERY_SLOWDOWN + (QUERY_KEY if key else QUERY_NO_KEY) + QUERY_CONFIRM - dialog.ConfirmDialog(confirm, msg.format(jid=jid.full(), eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show() - else: # do not ask if the context exist - decrypt(otrctx) - - other_jid = to_jid if from_jid.bare == self.host.whoami.bare else from_jid - self.fixResource(other_jid, cb) return False # interrupt the main process - def sendMessageTrigger(self, to_jid, msg, msg_type, extra): - def cb(jid): - otrctx = self.context_manager.getContextForUser(jid, start=False) - if otrctx is not None and otrctx.state != otr.context.STATE_PLAINTEXT: - if otrctx.state == otr.context.STATE_ENCRYPTED: - log.debug(u"encrypting message") - otrctx.sendMessage(msg) - self.host.newMessageCb(self.host.whoami, msg, msg_type, jid, extra) - else: - feedback = SEND_PLAIN_IN_FINISHED_CONTEXT - dialog.InfoDialog(FINISHED_CONTEXT_TITLE.format(jid=to_jid.full()), feedback, AddStyleName="maxWidthLimit").show() + def sendMessageTg(self, to_jid, message, subject, mess_type, extra, callback, errback, profile_key): + if mess_type != C.MESS_TYPE_CHAT: + return True + + otrctx = self.context_manager.getContextForUser(to_jid, start=False) + if otrctx is not None and otrctx.state != otr.context.STATE_PLAINTEXT: + if otrctx.state == otr.context.STATE_ENCRYPTED: + log.debug(u"encrypting message") + otrctx.sendMessage(message) + self.host.newMessageHandler(unicode(self.host.whoami), message, mess_type, unicode(to_jid), extra) else: - log.debug(u"sending message unencrypted") - self.host.bridge.call('sendMessage', (None, self.host.sendError), to_jid.full(), msg, '', msg_type, extra) + feedback = SEND_PLAIN_IN_FINISHED_CONTEXT + dialog.InfoDialog(FINISHED_CONTEXT_TITLE.format(jid=to_jid), feedback, AddStyleName="maxWidthLimit").show() + return False # interrupt the main process - if msg_type == 'groupchat': - return True - self.fixResource(to_jid, cb) - return False # interrupt the main process - - def presenceReceivedTrigger(self, entity, show, priority, statuses): - if show == "unavailable": - self.endSession(entity, finish=True) + log.debug(u"sending message unencrypted") return True - def endSession(self, other_jid, profile, finish=False): + def endSession(self, other_jid, disconnect=True): """Finish or disconnect an OTR session - @param other_jid (JID): str - @param finish: if True, finish the session but do not disconnect it - @return: True if the session has been finished or disconnected, False if there was nothing to do + @param other_jid (jid.JID): other JID + @param disconnect (bool): if False, finish the session but do not disconnect it """ - def cb(other_jid): - def not_available(): - if not finish: - self.host.newMessageCb(other_jid, END_PLAIN.format(jid=other_jid.full()), C.MESS_TYPE_INFO, self.host.whoami, {}) - - priv_key = self.context_manager.account.privkey - if priv_key is None: - not_available() + # checking for private key existence is not needed, context checking is enough + if other_jid.resource: + contexts = [self.context_manager.getContextForUser(other_jid, start=False)] + else: # contact disconnected itself so we need to terminate the OTR session but the Chat panel lost its resource + contexts = self.context_manager.getContextsForBareUser(other_jid) + for otrctx in contexts: + if otrctx is None or otrctx.state == otr.context.STATE_PLAINTEXT: + if disconnect: + self.host.newMessageHandler(unicode(other_jid), END_PLAIN_HAS_NOT.format(jid=other_jid), C.MESS_TYPE_INFO, unicode(self.host.whoami), {}) return - - otrctx = self.context_manager.getContextForUser(other_jid, start=False) - if otrctx is None: - not_available() - return - if finish: + if disconnect: + otrctx.disconnect() + else: otrctx.finish() - else: - otrctx.disconnect() - - self.fixResource(other_jid, cb) # Menu callbacks - def _startRefresh(self, menu_data): + def _startRefresh(self, caller, menu_data, profile): """Start or refresh an OTR session @param menu_data: %(menu_data)s @@ -526,39 +530,30 @@ if otrctx: otrctx.sendQueryMessage() - def cb(jid): - key = self.context_manager.account.privkey - if key is None: - def confirm(confirm): - if confirm: - query(jid) - msg = QUERY_SEND + QUERY_SLOWDOWN + QUERY_NO_KEY + QUERY_CONFIRM - dialog.ConfirmDialog(confirm, msg.format(jid=jid.full(), eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show() - else: # on query reception we ask always, if we initiate we just ask the first time - query(jid) + other_jid = jid.JID(menu_data['jid']) + clist = self.host.contact_list + if clist.getCache(other_jid.bare, C.PRESENCE_SHOW) is None: + dialog.InfoDialog(ACTION_NA_TITLE, ACTION_NA, AddStyleName="maxWidthLimit").show() + return - try: - other_jid = menu_data['jid'] - if other_jid.bare not in self.host.contact_panel.connected: - dialog.InfoDialog(ACTION_NA_TITLE, ACTION_NA, AddStyleName="maxWidthLimit").show() - return - self.fixResource(other_jid, cb) - except KeyError: - log.error(_("jid key is not present !")) + key = self.context_manager.account.privkey + if key is None: + def confirm(confirm): + if confirm: + query(other_jid) + msg = QUERY_SEND + QUERY_SLOWDOWN + QUERY_NO_KEY + QUERY_CONFIRM + dialog.ConfirmDialog(confirm, msg.format(jid=other_jid, eol=DIALOG_EOL), QUERY_TITLE, AddStyleName="maxWidthLimit").show() + else: # on query reception we ask always, if we initiate we just ask the first time + query(other_jid) - def _endSession(self, menu_data): + def _endSession(self, caller, menu_data, profile): """End an OTR session @param menu_data: %(menu_data)s """ - try: - other_jid = menu_data['jid'] - except KeyError: - log.error(_("jid key is not present !")) - return None - self.endSession(other_jid) + self.endSession(jid.JID(menu_data['jid'])) - def _authenticate(self, menu_data, profile): + def _authenticate(self, caller, menu_data, profile): """Authenticate other user and see our own fingerprint @param menu_data: %(menu_data)s @@ -567,32 +562,22 @@ def not_available(): dialog.InfoDialog(AUTH_TRUST_NA_TITLE, AUTH_TRUST_NA_TXT, AddStyleName="maxWidthLimit").show() - priv_key = self.context_manager.account.privkey - if priv_key is None: + to_jid = jid.JID(menu_data['jid']) + + # checking for private key existence is not needed, context checking is enough + otrctx = self.context_manager.getContextForUser(to_jid, start=False) + if otrctx is None or otrctx.state != otr.context.STATE_ENCRYPTED: not_available() return + otr_version = otrctx.getUsedVersion() + if otr_version == otr.context.OTR_VERSION_2: + otrctx.fingerprintAuthCb() + elif otr_version == otr.context.OTR_VERSION_3: + otrctx.smpAuthCb('question', None, 'asked') + else: + not_available() - def cb(to_jid): - otrctx = self.context_manager.getContextForUser(to_jid, start=False) - if otrctx is None: - not_available() - return - otr_version = otrctx.getUsedVersion() - if otr_version == otr.context.OTR_VERSION_2: - otrctx.fingerprintAuthCb() - elif otr_version == otr.context.OTR_VERSION_3: - otrctx.smpAuthCb('question', None, 'asked') - else: - not_available() - - try: - to_jid = menu_data['jid'] - self.fixResource(to_jid, cb) - except KeyError: - log.error(_("jid key is not present !")) - return None - - def _dropPrivkey(self, menu_data, profile): + def _dropPrivkey(self, caller, menu_data, profile): """Drop our private Key @param menu_data: %(menu_data)s @@ -604,21 +589,13 @@ dialog.InfoDialog(KEY_NA_TITLE, KEY_NA_TXT, AddStyleName="maxWidthLimit").show() return - def cb(to_jid): - def dropKey(confirm): - if confirm: - # we end all sessions - for context in self.context_manager.contexts.values(): - context.disconnect() - self.context_manager.contexts.clear() - self.context_manager.account.privkey = None - dialog.InfoDialog(KEY_TITLE, KEY_DROPPED_TXT, AddStyleName="maxWidthLimit").show() + def dropKey(confirm): + if confirm: + # we end all sessions + for context in self.context_manager.contexts.values(): + context.disconnect() + self.context_manager.contexts.clear() + self.context_manager.account.privkey = None + dialog.InfoDialog(KEY_TITLE, KEY_DROPPED_TXT, AddStyleName="maxWidthLimit").show() - dialog.ConfirmDialog(dropKey, KEY_DROP_TXT.format(eol=DIALOG_EOL), KEY_DROP_TITLE, AddStyleName="maxWidthLimit").show() - - try: - to_jid = menu_data['jid'] - self.fixResource(to_jid, cb) - except KeyError: - log.error(_("jid key is not present !")) - return None + dialog.ConfirmDialog(dropKey, KEY_DROP_TXT.format(eol=DIALOG_EOL), KEY_DROP_TITLE, AddStyleName="maxWidthLimit").show() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/radiocol.py --- a/src/browser/sat_browser/radiocol.py Thu Feb 05 12:05:32 2015 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,322 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Libervia: a Salut à Toi frontend -# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. - -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -import pyjd # this is dummy in pyjs -from sat.core.log import getLogger -log = getLogger(__name__) -from sat_frontends.tools.misc import DEFAULT_MUC -from sat.core.i18n import _ -from constants import Const as C - -from pyjamas.ui.VerticalPanel import VerticalPanel -from pyjamas.ui.HorizontalPanel import HorizontalPanel -from pyjamas.ui.FlexTable import FlexTable -from pyjamas.ui.FormPanel import FormPanel -from pyjamas.ui.Label import Label -from pyjamas.ui.Button import Button -from pyjamas.ui.ClickListener import ClickHandler -from pyjamas.ui.Hidden import Hidden -from pyjamas.ui.CaptionPanel import CaptionPanel -from pyjamas.media.Audio import Audio -from pyjamas import Window -from pyjamas.Timer import Timer - -import html_tools -import file_tools - - -class MetadataPanel(FlexTable): - - def __init__(self): - FlexTable.__init__(self) - title_lbl = Label("title:") - title_lbl.setStyleName('radiocol_metadata_lbl') - artist_lbl = Label("artist:") - artist_lbl.setStyleName('radiocol_metadata_lbl') - album_lbl = Label("album:") - album_lbl.setStyleName('radiocol_metadata_lbl') - self.title = Label("") - self.title.setStyleName('radiocol_metadata') - self.artist = Label("") - self.artist.setStyleName('radiocol_metadata') - self.album = Label("") - self.album.setStyleName('radiocol_metadata') - self.setWidget(0, 0, title_lbl) - self.setWidget(1, 0, artist_lbl) - self.setWidget(2, 0, album_lbl) - self.setWidget(0, 1, self.title) - self.setWidget(1, 1, self.artist) - self.setWidget(2, 1, self.album) - self.setStyleName("radiocol_metadata_pnl") - - def setTitle(self, title): - self.title.setText(title) - - def setArtist(self, artist): - self.artist.setText(artist) - - def setAlbum(self, album): - self.album.setText(album) - - -class ControlPanel(FormPanel): - """Panel used to show controls to add a song, or vote for the current one""" - - def __init__(self, parent): - FormPanel.__init__(self) - self.setEncoding(FormPanel.ENCODING_MULTIPART) - self.setMethod(FormPanel.METHOD_POST) - self.setAction("upload_radiocol") - self.timer_on = False - self._parent = parent - vPanel = VerticalPanel() - - types = [('audio/ogg', '*.ogg', 'Ogg Vorbis Audio'), - ('video/ogg', '*.ogv', 'Ogg Vorbis Video'), - ('application/ogg', '*.ogx', 'Ogg Vorbis Multiplex'), - ('audio/mpeg', '*.mp3', 'MPEG-Layer 3'), - ('audio/mp3', '*.mp3', 'MPEG-Layer 3'), - ] - self.file_upload = file_tools.FilterFileUpload("song", 10, types) - vPanel.add(self.file_upload) - - hPanel = HorizontalPanel() - self.upload_btn = Button("Upload song", getattr(self, "onBtnClick")) - hPanel.add(self.upload_btn) - self.status = Label() - self.updateStatus() - hPanel.add(self.status) - #We need to know the filename and the referee - self.filename_field = Hidden('filename', '') - hPanel.add(self.filename_field) - referee_field = Hidden('referee', self._parent.referee) - hPanel.add(self.filename_field) - hPanel.add(referee_field) - vPanel.add(hPanel) - - self.add(vPanel) - self.addFormHandler(self) - - def updateStatus(self): - if self.timer_on: - return - # TODO: the status should be different if a song is being played or not - queue = self._parent.getQueueSize() - queue_data = self._parent.queue_data - if queue < queue_data[0]: - left = queue_data[0] - queue - self.status.setText("[we need %d more song%s]" % (left, "s" if left > 1 else "")) - elif queue < queue_data[1]: - left = queue_data[1] - queue - self.status.setText("[%d available spot%s]" % (left, "s" if left > 1 else "")) - elif queue >= queue_data[1]: - self.status.setText("[The queue is currently full]") - self.status.setStyleName('radiocol_status') - - def onBtnClick(self): - if self.file_upload.check(): - self.status.setText('[Submitting, please wait...]') - self.filename_field.setValue(self.file_upload.getFilename()) - if self.file_upload.getFilename().lower().endswith('.mp3'): - self._parent._parent.host.showWarning('STATUS', 'For a better support, it is recommended to submit Ogg Vorbis file instead of MP3. You can convert your files easily, ask for help if needed!', 5000) - self.submit() - self.file_upload.setFilename("") - - def onSubmit(self, event): - pass - - def blockUpload(self): - self.file_upload.setVisible(False) - self.upload_btn.setEnabled(False) - - def unblockUpload(self): - self.file_upload.setVisible(True) - self.upload_btn.setEnabled(True) - - def setTemporaryStatus(self, text, style): - self.status.setText(text) - self.status.setStyleName('radiocol_upload_status_%s' % style) - self.timer_on = True - - def cb(timer): - self.timer_on = False - self.updateStatus() - - Timer(5000, cb) - - def onSubmitComplete(self, event): - result = event.getResults() - if result == C.UPLOAD_OK: - # the song can still be rejected (not readable, full queue...) - self.setTemporaryStatus('[Your song has been submitted to the radio]', "ok") - elif result == C.UPLOAD_KO: - self.setTemporaryStatus('[Something went wrong during your song upload]', "ko") - self._parent.radiocolSongRejected(_("The uploaded file has been rejected, only Ogg Vorbis and MP3 songs are accepted.")) - # TODO: would be great to re-use the original Exception class and message - # but it is lost in the middle of the traceback and encapsulated within - # a DBusException instance --> extract the data from the traceback? - else: - Window.alert(_('Submit error: %s' % result)) - self.status.setText('') - - -class Player(Audio): - - def __init__(self, player_id, metadata_panel): - Audio.__init__(self) - self._id = player_id - self.metadata = metadata_panel - self.timestamp = "" - self.title = "" - self.artist = "" - self.album = "" - self.filename = None - self.played = False # True when the song is playing/has played, becomes False on preload - self.setAutobuffer(True) - self.setAutoplay(False) - self.setVisible(False) - - def preload(self, timestamp, filename, title, artist, album): - """preload the song but doesn't play it""" - self.timestamp = timestamp - self.filename = filename - self.title = title - self.artist = artist - self.album = album - self.played = False - self.setSrc("radiocol/%s" % html_tools.html_sanitize(filename)) - log.debug("preloading %s in %s" % (title, self._id)) - - def play(self, play=True): - """Play or pause the song - @param play: set to True to play or to False to pause - """ - if play: - self.played = True - self.metadata.setTitle(self.title) - self.metadata.setArtist(self.artist) - self.metadata.setAlbum(self.album) - Audio.play(self) - else: - self.pause() - - -class RadioColPanel(HorizontalPanel, ClickHandler): - - def __init__(self, parent, referee, player_nick, players, queue_data): - """ - @param parent - @param referee - @param player_nick - @param players - @param queue_data: list of integers (queue to start, queue limit) - """ - # We need to set it here and not in the CSS :( - HorizontalPanel.__init__(self, Height="90px") - ClickHandler.__init__(self) - self._parent = parent - self.referee = referee - self.queue_data = queue_data - self.setStyleName("radiocolPanel") - - # Now we set up the layout - self.metadata_panel = MetadataPanel() - self.add(CaptionPanel("Now playing", self.metadata_panel)) - self.playlist_panel = VerticalPanel() - self.add(CaptionPanel("Songs queue", self.playlist_panel)) - self.control_panel = ControlPanel(self) - self.add(CaptionPanel("Controls", self.control_panel)) - - self.next_songs = [] - self.players = [Player("player_%d" % i, self.metadata_panel) for i in xrange(queue_data[1] + 1)] - self.current_player = None - for player in self.players: - self.add(player) - self.addClickListener(self) - - help_msg = """Accepted file formats: Ogg Vorbis (recommended), MP3.
- Please do not submit files that are protected by copyright.
- Click here if you need some support :)""" - link_cb = lambda: self._parent.host.bridge.call('joinMUC', None, DEFAULT_MUC, self._parent.nick) - self._parent.printInfo(help_msg, type_='link', link_cb=link_cb) - - def pushNextSong(self, title): - """Add a song to the left panel's next songs queue""" - next_song = Label(title) - next_song.setStyleName("radiocol_next_song") - self.next_songs.append(next_song) - self.playlist_panel.append(next_song) - self.control_panel.updateStatus() - - def popNextSong(self): - """Remove the first song of next songs list - should be called when the song is played""" - #FIXME: should check that the song we remove is the one we play - next_song = self.next_songs.pop(0) - self.playlist_panel.remove(next_song) - self.control_panel.updateStatus() - - def getQueueSize(self): - return len(self.playlist_panel.getChildren()) - - def radiocolCheckPreload(self, timestamp): - for player in self.players: - if player.timestamp == timestamp: - return False - return True - - def radiocolPreload(self, timestamp, filename, title, artist, album, sender): - if not self.radiocolCheckPreload(timestamp): - return # song already preloaded - preloaded = False - for player in self.players: - if not player.filename or \ - (player.played and player != self.current_player): - #if player has no file loaded, or it has already played its song - #we use it to preload the next one - player.preload(timestamp, filename, title, artist, album) - preloaded = True - break - if not preloaded: - log.warning("Can't preload song, we are getting too many songs to preload, we shouldn't have more than %d at once" % self.queue_data[1]) - else: - self.pushNextSong(title) - self._parent.printInfo(_('%(user)s uploaded %(artist)s - %(title)s') % {'user': sender, 'artist': artist, 'title': title}) - - def radiocolPlay(self, filename): - found = False - for player in self.players: - if not found and player.filename == filename: - player.play() - self.popNextSong() - self.current_player = player - found = True - else: - player.play(False) # in case the previous player was not sync - if not found: - log.error("Song not found in queue, can't play it. This should not happen") - - def radiocolNoUpload(self): - self.control_panel.blockUpload() - - def radiocolUploadOk(self): - self.control_panel.unblockUpload() - - def radiocolSongRejected(self, reason): - Window.alert("Song rejected: %s" % reason) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/register.py --- a/src/browser/sat_browser/register.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/register.py Wed Mar 18 16:15:18 2015 +0100 @@ -186,7 +186,7 @@ self.login_warning_msg.setVisible(True) else: self.submit_type.setValue('login') - self.submit() + self.submit(None) def onRegister(self, button): # XXX: for now libervia forces the creation to lower case @@ -203,7 +203,7 @@ else: self.register_warning_msg.setVisible(False) self.submit_type.setValue('register') - self.submit() + self.submit(None) def onSubmit(self, event): pass diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/richtext.py --- a/src/browser/sat_browser/richtext.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/richtext.py Wed Mar 18 16:15:18 2015 +0100 @@ -34,13 +34,15 @@ from constants import Const as C import dialog -import base_panels +import base_panel +import editor_widget import list_manager import html_tools -import panels +import blog +import chat -class RichTextEditor(base_panels.BaseTextEditor, FlexTable): +class RichTextEditor(editor_widget.BaseTextEditor, FlexTable): """Panel for the rich text editor.""" def __init__(self, host, content=None, modifiedCb=None, afterEditCb=None, options=None, style=None): @@ -51,6 +53,7 @@ @param afterEditCb: method to be called when the edition is done @param options: list of UI options (see self.readOptions) """ + FlexTable.__init__(self) # FIXME self.host = host self._debug = False # TODO: don't forget to set it False before commit self.wysiwyg = False @@ -62,7 +65,7 @@ if isinstance(style, dict): self.style.update(style) self._prepareUI() - base_panels.BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb) + editor_widget.BaseTextEditor.__init__(self, content, None, modifiedCb, afterEditCb) def __readOptions(self, options): """Set the internal flags according to the given options.""" @@ -84,13 +87,13 @@ else: self.title_offset = self.toolbar_offset = self.content_offset = y_offset self.command_offset = self.content_offset + 1 - FlexTable.__init__(self, self.command_offset + (0 if self.no_command else 1), 2) + # FlexTable.__init__(self, rowspan=self.command_offset + (0 if self.no_command else 1), colspan=2) # FIXME self.addStyleName(self.style['main']) def addEditListener(self, listener): """Add a method to be called whenever the text is edited. @param listener: method taking two arguments: sender, keycode""" - base_panels.BaseTextEditor.addEditListener(self, listener) + editor_widget.BaseTextEditor.addEditListener(self, listener) if hasattr(self, 'display'): self.display.addEditListener(listener) @@ -107,7 +110,7 @@ if hasattr(self, 'toolbar'): self.toolbar.setVisible(False) if not hasattr(self, 'display'): - self.display = base_panels.HTMLTextEditor(options={'enhance_display': False, 'listen_keyboard': False}) # for display mode + self.display = editor_widget.HTMLTextEditor(options={'enhance_display': False, 'listen_keyboard': False}) # for display mode for listener in self.edit_listeners: self.display.addEditListener(listener) if not self.read_only and not hasattr(self, 'textarea'): @@ -124,7 +127,7 @@ return if not self.no_title and not hasattr(self, 'title_panel'): - self.title_panel = base_panels.TitlePanel() + self.title_panel = base_panel.TitlePanel() self.title_panel.addStyleName(self.style['title']) self.getFlexCellFormatter().setColSpan(self.title_offset, 0, 2) self.setWidget(self.title_offset, 0, self.title_panel) @@ -174,14 +177,14 @@ self.wysiwyg = wysiwyg try: self.wysiwyg_button.setChecked(wysiwyg) - except TypeError: + except (AttributeError, TypeError): pass try: if wysiwyg: self.syntax_label.addStyleName('transparent') else: self.syntax_label.removeStyleName('transparent') - except TypeError: + except (AttributeError, TypeError): pass if not wysiwyg: self.display.removeStyleName('richTextWysiwyg') @@ -284,10 +287,10 @@ """ if not (edit and abort): self.refresh(edit) # not when we are asking for a confirmation - base_panels.BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed + editor_widget.BaseTextEditor.edit(self, edit, abort, sync) # after the UI has been refreshed if (edit and abort): - return # self.abortEdition is called by base_panels.BaseTextEditor.edit - self.setWysiwyg(False, init=True) # after base_panels.BaseTextEditor (it affects self.getContent) + return # self.abortEdition is called by editor_widget.BaseTextEditor.edit + self.setWysiwyg(False, init=True) # after editor_widget.BaseTextEditor (it affects self.getContent) if sync: return # the following must NOT be done at each UI refresh! @@ -317,7 +320,7 @@ self.display.edit(False) def setDisplayContent(self): - """Set the content of the base_panels.HTMLTextEditor which is used for display/wysiwyg""" + """Set the content of the editor_widget.HTMLTextEditor which is used for display/wysiwyg""" content = self._original_content text = content['text'] if 'title' in content and content['title']: @@ -440,7 +443,7 @@ setText() return True if recipients is None: - recipients = self.recipient.getContacts() + recipients = self.recipient.getItemsByKey() target = "" # we could eventually allow more in the future allowed = 1 @@ -457,11 +460,11 @@ if target == "": return True if target.startswith("@"): - _class = panels.MicroblogPanel + _class = blog.MicroblogPanel target = None if target == "@@" else target[1:] else: - _class = panels.ChatPanel - self.host.getOrCreateLiberviaWidget(_class, {'item': target}) + _class = chat.Chat + self.host.displayWidget(_class, target) return True def syncFromEditor(self, content): @@ -485,7 +488,7 @@ def __sendMessage(self): """Send the message.""" - recipients = self.recipient.getContacts() + recipients = self.recipient.getItemsByKey() targets = [] for addr in recipients: for recipient in recipients[addr]: @@ -516,7 +519,7 @@ list_ = [] list_.append("@@") list_.extend("@%s" % group for group in parent.host.contact_panel.getGroups()) - list_.extend(contact for contact in parent.host.contact_panel.getContacts()) + list_.extend(contact for contact in parent.host.contact_list.roster_entities) list_manager.ListManager.__init__(self, parent, composition.RECIPIENT_TYPES, list_, {'y': y_offset}) self.registerPopupMenuPanel(entries=composition.RECIPIENT_TYPES, diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/widget.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/browser/sat_browser/widget.py Wed Mar 18 16:15:18 2015 +0100 @@ -0,0 +1,261 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011, 2012, 2013, 2014 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pyjd # this is dummy in pyjs +from sat.core.log import getLogger +log = getLogger(__name__) + +from sat.core.i18n import D_ + +from pyjamas.ui.VerticalPanel import VerticalPanel +from pyjamas.ui.HorizontalPanel import HorizontalPanel +from pyjamas.ui.Button import Button +from pyjamas.ui.Frame import Frame +from pyjamas import DOM + + +import dialog +import libervia_widget +from constants import Const as C +from sat_frontends.quick_frontend import quick_widgets +from sat_frontends.tools import host_listener + + +# class UniBoxPanel(HorizontalPanel): +# """Panel containing the UniBox""" +# +# def __init__(self, host): +# HorizontalPanel.__init__(self) +# self.host = host +# self.setStyleName('uniBoxPanel') +# self.unibox = None +# +# def refresh(self): +# """Enable or disable this panel. Contained widgets are created when necessary.""" +# enable = self.host.getCachedParam(C.COMPOSITION_KEY, C.ENABLE_UNIBOX_PARAM) == 'true' +# self.setVisible(enable) +# if enable and not self.unibox: +# self.button = Button('') +# self.button.setTitle('Open the rich text editor') +# self.button.addStyleName('uniBoxButton') +# self.add(self.button) +# self.unibox = UniBox(self.host) +# self.add(self.unibox) +# self.setCellWidth(self.unibox, '100%') +# self.button.addClickListener(self.openRichMessageEditor) +# self.unibox.addKey("@@: ") +# self.unibox.onSelectedChange(self.host.getSelected()) +# +# def openRichMessageEditor(self): +# """Open the rich text editor.""" +# self.button.setVisible(False) +# self.unibox.setVisible(False) +# self.setCellWidth(self.unibox, '0px') +# self.host.panel._contactsMove(self) +# +# def afterEditCb(): +# Window.removeWindowResizeListener(self) +# self.host.panel._contactsMove(self.host.panel._hpanel) +# self.setCellWidth(self.unibox, '100%') +# self.button.setVisible(True) +# self.unibox.setVisible(True) +# self.host.resize() +# +# richtext.RichMessageEditor.getOrCreate(self.host, self, afterEditCb) +# Window.addWindowResizeListener(self) +# self.host.resize() +# +# def onWindowResized(self, width, height): +# right = self.host.panel.menu.getAbsoluteLeft() + self.host.panel.menu.getOffsetWidth() +# left = self.host.panel._contacts.getAbsoluteLeft() + self.host.panel._contacts.getOffsetWidth() +# ideal_width = right - left - 40 +# self.host.richtext.setWidth("%spx" % ideal_width) + + + +# class UniBox(MessageBox, MouseHandler): # AutoCompleteTextBox): +# """This text box is used as a main typing point, for message, microblog, etc""" +# +# def __init__(self, host): +# MessageBox.__init__(self, host) +# #AutoCompleteTextBox.__init__(self) +# self.setStyleName('uniBox') +# # FIXME +# # host.addSelectedListener(self.onSelectedChange) +# +# def addKey(self, key): +# return +# #self.getCompletionItems().completions.append(key) +# +# def removeKey(self, key): +# return +# # TODO: investigate why AutoCompleteTextBox doesn't work here, +# # maybe it can work on a TextBox but no TextArea. Remove addKey +# # and removeKey methods if they don't serve anymore. +# try: +# self.getCompletionItems().completions.remove(key) +# except KeyError: +# log.warning("trying to remove an unknown key") +# +# def _getTarget(self, txt): +# """ Say who will receive the messsage +# @return: a tuple (selected, target_type, target info) with: +# - target_hook: None if we use the selected widget, (msg, data) if we have a hook (e.g. "@@: " for a public blog), where msg is the parsed message (i.e. without the "hook key: "@@: bla" become ("bla", None)) +# - target_type: one of PUBLIC, GROUP, ONE2ONE, STATUS, MISC +# - msg: HTML message which will appear in the privacy warning banner """ +# target = self._selected_cache +# +# def getSelectedOrStatus(): +# if target and target.isSelectable(): +# _type, msg = target.getWarningData() +# target_hook = None # we use the selected widget, not a hook +# else: +# _type, msg = "STATUS", "This will be your new status message" +# target_hook = (txt, None) +# return (target_hook, _type, msg) +# +# if not txt.startswith('@'): +# target_hook, _type, msg = getSelectedOrStatus() +# elif txt.startswith('@@: '): +# _type = "PUBLIC" +# msg = MicroblogPanel.warning_msg_public +# target_hook = (txt[4:], None) +# elif txt.startswith('@'): +# _end = txt.find(': ') +# if _end == -1: +# target_hook, _type, msg = getSelectedOrStatus() +# else: +# group = txt[1:_end] # only one target group is managed for the moment +# if not group or not group in self.host.contact_panel.getGroups(): +# # the group doesn't exists, we ignore the key +# group = None +# target_hook, _type, msg = getSelectedOrStatus() +# else: +# _type = "GROUP" +# msg = MicroblogPanel.warning_msg_group % group +# target_hook = (txt[_end + 2:], group) +# else: +# log.error("Unknown target") +# target_hook, _type, msg = getSelectedOrStatus() +# +# return (target_hook, _type, msg) +# +# def onKeyPress(self, sender, keycode, modifiers): +# _txt = self.getText() +# target_hook, type_, msg = self._getTarget(_txt) +# +# if keycode == KEY_ENTER: +# if _txt: +# if target_hook: +# parsed_txt, data = target_hook +# self.host.send([(type_, data)], parsed_txt) +# self.host._updateInputHistory(_txt) +# self.setText('') +# self.host.showWarning(None, None) +# else: +# self.host.showWarning(type_, msg) +# MessageBox.onKeyPress(self, sender, keycode, modifiers) +# +# def getTargetAndData(self): +# """For external use, to get information about the (hypothetical) message +# that would be sent if we press Enter right now in the unibox. +# @return a tuple (target, data) with: +# - data: what would be the content of the message (body) +# - target: JID, group with the prefix "@" or the public entity "@@" +# """ +# _txt = self.getText() +# target_hook, _type, _msg = self._getTarget(_txt) +# if target_hook: +# data, target = target_hook +# if target is None: +# return target_hook +# return (data, "@%s" % (target if target != "" else "@")) +# if isinstance(self._selected_cache, MicroblogPanel): +# groups = self._selected_cache.accepted_groups +# target = "@%s" % (groups[0] if len(groups) > 0 else "@") +# if len(groups) > 1: +# Window.alert("Sole the first group of the selected panel is taken in consideration: '%s'" % groups[0]) +# # elif isinstance(self._selected_cache, ChatPanel): # FIXME +# # target = self._selected_cache.target +# else: +# target = None +# return (_txt, target) +# +# def onWidgetClosed(self, lib_wid): +# """Called when a libervia widget is closed""" +# if self._selected_cache == lib_wid: +# self.onSelectedChange(None) +# +# """def complete(self): +# +# #self.visible=False #XXX: self.visible is not unset in pyjamas when ENTER is pressed and a completion is done +# #XXX: fixed directly on pyjamas, if the patch is accepted, no need to walk around this +# return AutoCompleteTextBox.complete(self)""" + + +class WebWidget(quick_widgets.QuickWidget, libervia_widget.LiberviaWidget): + """ (mini)browser like widget """ + + def __init__(self, host, target, show_url=True, profiles=None): + """ + @param host: SatWebFrontend instance + @param target: url to open + """ + quick_widgets.QuickWidget.__init__(self, host, target, C.PROF_KEY_NONE) + libervia_widget.LiberviaWidget.__init__(self, host) + self._vpanel = VerticalPanel() + self._vpanel.setSize('100%', '100%') + self._url = dialog.ExtTextBox(enter_cb=self.onUrlClick) + self._url.setText(target or "") + self._url.setWidth('100%') + if show_url: + hpanel = HorizontalPanel() + hpanel.add(self._url) + btn = Button("Go", self.onUrlClick) + hpanel.setCellWidth(self._url, "100%") + hpanel.add(btn) + self._vpanel.add(hpanel) + self._vpanel.setCellHeight(hpanel, '20px') + self._frame = Frame(target or "") + self._frame.setSize('100%', '100%') + DOM.setStyleAttribute(self._frame.getElement(), "position", "relative") + self._vpanel.add(self._frame) + self.setWidget(self._vpanel) + + def onUrlClick(self, sender): + url = self._url.getText() + scheme_end = url.find(':') + scheme = "" if scheme_end == -1 else url[:scheme_end] + if scheme not in C.WEB_PANEL_SCHEMES: + url = "http://" + url + self._frame.setUrl(url) + + +## Menu + +def hostReady(host): + def onWebWidget(): + web_widget = host.displayWidget(WebWidget, C.WEB_PANEL_DEFAULT_URL) + host.setSelected(web_widget) + + def gotMenus(): + host.menus.addMenu(C.MENU_GLOBAL, (D_(u"General"), D_(u"Web widget")), callback=onWebWidget) + host.addListener('gotMenus', gotMenus) + +host_listener.addListener(hostReady) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/browser/sat_browser/xmlui.py --- a/src/browser/sat_browser/xmlui.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/browser/sat_browser/xmlui.py Wed Mar 18 16:15:18 2015 +0100 @@ -71,7 +71,7 @@ """Add a divider @param _xmlui_parent - @param style (string): one of: + @param style (unicode): one of: - line: a simple line - dot: a line of dots - dash: a line of dashes @@ -407,11 +407,11 @@ class XMLUIPanel(LiberviaXMLUIBase, xmlui.XMLUIPanel, VerticalPanel): widget_factory = WidgetFactory() - def __init__(self, host, parsed_xml, title=None, flags=None): + def __init__(self, host, parsed_xml, title=None, flags=None, callback=None, profile=C.PROF_KEY_NONE): self.widget_factory._xmlui_main = self VerticalPanel.__init__(self) self.setSize('100%', '100%') - xmlui.XMLUIPanel.__init__(self, host, parsed_xml, title, flags) + xmlui.XMLUIPanel.__init__(self, host, parsed_xml, title, flags, callback, profile) def setCloseCb(self, close_cb): self.close_cb = close_cb @@ -445,7 +445,7 @@ def show(self): options = ['NO_CLOSE'] if self.type == C.XMLUI_FORM else [] - _dialog = dialog.GenericDialog(self.title, self, options=options) + _dialog = dialog.GenericDialog(self.xmlui_title, self, options=options) self.setCloseCb(_dialog.close) _dialog.show() @@ -453,8 +453,8 @@ class XMLUIDialog(LiberviaXMLUIBase, xmlui.XMLUIDialog): dialog_factory = GenericFactory() - def __init__(self, host, parsed_dom, title = None, flags = None): - xmlui.XMLUIDialog.__init__(self, host, parsed_dom, title, flags) + def __init__(self, host, parsed_dom, title = None, flags = None, callback=None, profile=C.PROF_KEY_NONE): + xmlui.XMLUIDialog.__init__(self, host, parsed_dom, title, flags, callback, profile) xmlui.registerClass(xmlui.CLASS_PANEL, XMLUIPanel) xmlui.registerClass(xmlui.CLASS_DIALOG, XMLUIDialog) diff -r 1bffc4c244c3 -r a90cc8fc9605 src/common/constants.py --- a/src/common/constants.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/common/constants.py Wed Mar 18 16:15:18 2015 +0100 @@ -18,11 +18,14 @@ # along with this program. If not, see . from sat.core.i18n import D_ -from sat_frontends import constants +from sat_frontends.quick_frontend import constants +import os.path class Const(constants.Const): + LIBERVIA_MAIN_PAGE = "libervia.html" + # Frontend parameters COMPOSITION_KEY = D_("Composition") ENABLE_UNIBOX_PARAM = D_("Enable unibox") @@ -46,11 +49,15 @@ UPLOAD_KO = 'UPLOAD KO' UNKNOWN_ERROR = 'UNMANAGED FAULT STRING (%s)' - # PATHS + # directories AVATARS_DIR = "avatars/" + MEDIA_DIR = "media/" - # Default avatar - DEFAULT_AVATAR = "/media/misc/default_avatar.png" + # avatars + DEFAULT_AVATAR_FILE = "default_avatar.png" + DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE) + EMPTY_AVATAR_FILE = "empty_avatar" + EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE) RSM_MAX_ITEMS = 5 RSM_MAX_COMMENTS = 5 diff -r 1bffc4c244c3 -r a90cc8fc9605 src/server/blog.py --- a/src/server/blog.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/server/blog.py Wed Mar 18 16:15:18 2015 +0100 @@ -96,14 +96,14 @@ except IndexError: # impossible to guess the entity return log.debug(_("Using default avatar for entity %s") % entity_jid_s) - self.avatars_cache[entity_jid_s] = C.DEFAULT_AVATAR - self.waiting_deferreds[entity_jid_s][1].callback(C.DEFAULT_AVATAR) + self.avatars_cache[entity_jid_s] = C.DEFAULT_AVATAR_URL + self.waiting_deferreds[entity_jid_s][1].callback(C.DEFAULT_AVATAR_URL) del self.waiting_deferreds[entity_jid_s] def getAvatar(self, profile): """Get the avatar of the given profile - @param profile (str): + @param profile(unicode): %(doc_profile)s @return: deferred avatar path, relative to the server's root """ jid_s = (profile + '@' + self.host.bridge.getNewAccountDomain()).lower() diff -r 1bffc4c244c3 -r a90cc8fc9605 src/server/constants.py --- a/src/server/constants.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/server/constants.py Wed Mar 18 16:15:18 2015 +0100 @@ -44,3 +44,6 @@ # Security limit for Libervia server_side SERVER_SECURITY_LIMIT = constants.Const.NO_SECURITY_LIMIT + + # keys for cache values we can get from browser + ALLOWED_ENTITY_DATA = {'avatar', 'nick'} diff -r 1bffc4c244c3 -r a90cc8fc9605 src/server/server.py --- a/src/server/server.py Thu Feb 05 12:05:32 2015 +0100 +++ b/src/server/server.py Wed Mar 18 16:15:18 2015 +0100 @@ -34,6 +34,7 @@ log = getLogger(__name__) from sat_frontends.bridge.DBus import DBusBridgeFrontend, BridgeExceptionNoService, const_TIMEOUT as BRIDGE_TIMEOUT from sat.core.i18n import _, D_ +from sat.core import exceptions from sat.tools.xml_tools import paramsXML2XMLUI import re @@ -184,13 +185,6 @@ return jsonrpc.JSONRPC._cbRender(self, fault, request, parsed.get('id'), parsed.get('jsonrpc')) # pylint: disable=E1103 return jsonrpc.JSONRPC.render(self, request) - def jsonrpc_getProfileJid(self): - """Return the jid of the profile""" - sat_session = ISATSession(self.session) - profile = sat_session.profile - sat_session.jid = JID(self.sat_host.bridge.getParamA("JabberID", "Connection", profile_key=profile)) - return sat_session.jid.full() - def jsonrpc_disconnect(self): """Disconnect the profile""" sat_session = ISATSession(self.session) @@ -218,19 +212,22 @@ profile = ISATSession(self.session).profile self.sat_host.bridge.updateContact(entity, name, groups, profile) - def jsonrpc_subscription(self, sub_type, entity, name, groups): + def jsonrpc_subscription(self, sub_type, entity): """Confirm (or infirm) subscription, and setup user roster in case of subscription""" profile = ISATSession(self.session).profile self.sat_host.bridge.subscription(sub_type, entity, profile) - if sub_type == 'subscribed': - self.sat_host.bridge.updateContact(entity, name, groups, profile) def jsonrpc_getWaitingSub(self): """Return list of room already joined by user""" profile = ISATSession(self.session).profile return self.sat_host.bridge.getWaitingSub(profile) + def jsonrpc_getWaitingConf(self): + """Return list of waiting confirmations""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getWaitingConf(profile) + def jsonrpc_setStatus(self, presence, status): """Change the presence and/or status @param presence: value from ("", "chat", "away", "dnd", "xa") @@ -246,9 +243,9 @@ def jsonrpc_sendMblog(self, type_, dest, text, extra={}): """ Send microblog message - @param type_: one of "PUBLIC", "GROUP" - @param dest: destinees (list of groups, ignored for "PUBLIC") - @param text: microblog's text + @param type_ (unicode): one of "PUBLIC", "GROUP" + @param dest (tuple(unicode)): recipient groups (ignored for "PUBLIC") + @param text (unicode): microblog's text """ profile = ISATSession(self.session).profile extra['allow_comments'] = 'True' @@ -260,7 +257,7 @@ if type_ == "PUBLIC": #This text if for the public microblog log.debug("sending public blog") - return self.sat_host.bridge.sendGroupBlog("PUBLIC", [], text, extra, profile) + return self.sat_host.bridge.sendGroupBlog("PUBLIC", (), text, extra, profile) else: log.debug("sending group blog") dest = dest if isinstance(dest, list) else [dest] @@ -319,17 +316,21 @@ d = self.asyncBridgeCall("getGroupBlogsWithComments", publisher_jid, item_ids, {}, max_comments, profile) return d - def jsonrpc_getMassiveMblogs(self, publishers_type, publishers_list, rsm=None): + def jsonrpc_getMassiveMblogs(self, publishers_type, publishers, rsm=None): """Get lasts microblogs posted by several contacts at once - @param publishers_type: one of "ALL", "GROUP", "JID" - @param publishers_list: list of publishers type (empty list of all, list of groups or list of jids) - @param max_item: number of items to ask - @return: dictionary key=publisher's jid, value=list of microblog data (dict)""" + + @param publishers_type (unicode): one of "ALL", "GROUP", "JID" + @param publishers (tuple(unicode)): tuple of publishers (empty list for all, list of groups or list of jids) + @param rsm (dict): TODO + @return: dict{unicode: list[dict]) + key: publisher's jid + value: list of microblog data (dict) + """ profile = ISATSession(self.session).profile if rsm is None: rsm = {'max': unicode(C.RSM_MAX_ITEMS)} - d = self.asyncBridgeCall("getMassiveGroupBlogs", publishers_type, publishers_list, rsm, profile) - self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers_list, profile) + d = self.asyncBridgeCall("getMassiveGroupBlogs", publishers_type, publishers, rsm, profile) + self.sat_host.bridge.massiveSubscribeGroupBlogs(publishers_type, publishers, profile) return d def jsonrpc_getMblogComments(self, service, node, rsm=None): @@ -354,10 +355,10 @@ profile = sat_session.profile sat_jid = sat_session.jid if not sat_jid: - log.error("No jid saved for this profile") - return {} + # we keep a session cache for jid to avoir jid spoofing + sat_jid = sat_session.jid = JID(self.sat_host.bridge.getParamA("JabberID", "Connection", profile_key=profile)) if JID(from_jid).userhost() != sat_jid.userhost() and JID(to_jid).userhost() != sat_jid.userhost(): - log.error("Trying to get history from a different jid, maybe a hack attempt ?") + log.error("Trying to get history from a different jid (given (browser): {}, real (backend): {}), maybe a hack attempt ?".format(from_jid, sat_jid)) return {} d = self.asyncBridgeCall("getHistory", from_jid, to_jid, size, between, search, profile) @@ -377,12 +378,6 @@ @room_jid: leave empty string to generate a unique name """ profile = ISATSession(self.session).profile - try: - if room_jid != "": - room_jid = JID(room_jid).userhost() - except: - log.warning('Invalid room jid') - return d = self.asyncBridgeCall("joinMUC", room_jid, nick, {}, profile) return d @@ -413,6 +408,11 @@ profile = ISATSession(self.session).profile return self.sat_host.bridge.getRoomsJoined(profile) + def jsonrpc_getRoomsSubjects(self): + """Return list of room subjects""" + profile = ISATSession(self.session).profile + return self.sat_host.bridge.getRoomsSubjects(profile) + def jsonrpc_launchTarotGame(self, other_players, room_jid=""): """Create a room, invite the other players and start a Tarot game @param room_jid: leave empty string to generate a unique room name @@ -455,20 +455,40 @@ return self.sat_host.bridge.radiocolLaunch(invited, room_jid, profile) + def jsonrpc_getEntitiesData(self, jids, keys): + """Get cached data for several entities at once + + @param jids: list jids from who we wants data, or empty list for all jids in cache + @param keys: name of data we want (list) + @return: requested data""" + if not C.ALLOWED_ENTITY_DATA.issuperset(keys): + raise exceptions.PermissionError("Trying to access unallowed data (hack attempt ?)") + profile = ISATSession(self.session).profile + try: + return self.sat_host.bridge.getEntitiesData(jids, keys, profile) + except Exception as e: + raise Failure(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, unicode(e))) + def jsonrpc_getEntityData(self, jid, keys): - """Get cached data for an entit + """Get cached data for an entity + @param jid: jid of contact from who we want data @param keys: name of data we want (list) @return: requested data""" + if not C.ALLOWED_ENTITY_DATA.issuperset(keys): + raise exceptions.PermissionError("Trying to access unallowed data (hack attempt ?)") profile = ISATSession(self.session).profile - return self.sat_host.bridge.getEntityData(jid, keys, profile) + try: + return self.sat_host.bridge.getEntityData(jid, keys, profile) + except Exception as e: + raise Failure(jsonrpclib.Fault(C.ERRNUM_BRIDGE_ERRBACK, unicode(e))) - def jsonrpc_getCard(self, jid): + def jsonrpc_getCard(self, jid_): """Get VCard for entiry - @param jid: jid of contact from who we want data + @param jid_: jid of contact from who we want data @return: id to retrieve the profile""" profile = ISATSession(self.session).profile - return self.sat_host.bridge.getCard(jid, profile) + return self.sat_host.bridge.getCard(jid_, profile) def jsonrpc_getAccountDialogUI(self): """Get the dialog for managing user account @@ -756,6 +776,7 @@ request.write(C.SESSION_ACTIVE) request.finish() return + # we manage profile server side to avoid profile spoofing sat_session.profile = profile self.sat_host.prof_connected.add(profile) @@ -1077,7 +1098,8 @@ """Add a child to the root resource""" root.putChild(path, EncodingResourceWrapper(resource, [server.GzipEncoderFactory()])) - putChild('', Redirect('libervia.html')) + putChild('', Redirect(C.LIBERVIA_MAIN_PAGE)) + putChild('test', Redirect('libervia_test.html')) putChild('json_signal_api', self.signal_handler) putChild('json_api', MethodHandler(self)) putChild('register_api', _register)