From 2653c160f279d196cb22e07a4c44088d84b1eade Mon Sep 17 00:00:00 2001
From: Yann Leboulanger <asterix@lagaule.org>
Date: Sat, 11 Dec 2010 12:57:09 +0100
Subject: [PATCH] handle nested roster group. TODO: Improve the way it's
 displayed in roster. Fixes #1381

---
 src/common/connection.py          |  44 +++++++
 src/common/connection_handlers.py |  28 +++--
 src/roster_window.py              | 189 ++++++++++++++++++++++--------
 3 files changed, 196 insertions(+), 65 deletions(-)

diff --git a/src/common/connection.py b/src/common/connection.py
index 4b5a169867..1b70a6b058 100644
--- a/src/common/connection.py
+++ b/src/common/connection.py
@@ -161,6 +161,8 @@ class CommonConnection:
 
         self.awaiting_cids = {} # Used for XEP-0231
 
+        self.nested_group_delimiter = '::'
+
         self.get_config_values_or_default()
 
     def _compute_resource(self):
@@ -2103,6 +2105,32 @@ class Connection(CommonConnection, ConnectionHandlers):
                 iq4.setData(self.annotations[jid])
         self.connection.send(iq)
 
+    def get_roster_delimiter(self):
+        """
+        Get roster group delimiter from storage as described in XEP 0083
+        """
+        if not gajim.account_is_connected(self.name):
+            return
+        iq = common.xmpp.Iq(typ='get')
+        iq2 = iq.addChild(name='query', namespace=common.xmpp.NS_PRIVATE)
+        iq2.addChild(name='roster', namespace='roster:delimiter')
+        id_ = self.connection.getAnID()
+        iq.setID(id_)
+        self.awaiting_answers[id_] = (DELIMITER_ARRIVED, )
+        self.connection.send(iq)
+
+    def set_roster_delimiter(self, delimiter='::'):
+        """
+        Set roster group delimiter to the storage namespace
+        """
+        if not gajim.account_is_connected(self.name):
+            return
+        iq = common.xmpp.Iq(typ='set')
+        iq2 = iq.addChild(name='query', namespace=common.xmpp.NS_PRIVATE)
+        iq3 = iq2.addChild(name='roster', namespace='roster:delimiter')
+        iq3.setData(delimiter)
+
+        self.connection.send(iq)
 
     def get_metacontacts(self):
         """
@@ -2136,6 +2164,22 @@ class Connection(CommonConnection, ConnectionHandlers):
                 iq3.addChild(name = 'meta', attrs = dict_)
         self.connection.send(iq)
 
+    def request_roster(self):
+        version = None
+        features =  self.connection.Dispatcher.Stream.features
+        if features and features.getTag('ver',
+        namespace=common.xmpp.NS_ROSTER_VER):
+            version = gajim.config.get_per('accounts', self.name,
+                'roster_version')
+            if version and not gajim.contacts.get_contacts_jid_list(
+            self.name):
+                gajim.config.set_per('accounts', self.name, 'roster_version',
+                    '')
+                version = None
+
+        iq_id = self.connection.initRoster(version=version)
+        self.awaiting_answers[iq_id] = (ROSTER_ARRIVED, )
+
     def send_agent_status(self, agent, ptype):
         if not gajim.account_is_connected(self.name):
             return
diff --git a/src/common/connection_handlers.py b/src/common/connection_handlers.py
index d889927f00..1eacf7db8f 100644
--- a/src/common/connection_handlers.py
+++ b/src/common/connection_handlers.py
@@ -84,6 +84,7 @@ VCARD_ARRIVED = 'vcard_arrived'
 AGENT_REMOVED = 'agent_removed'
 METACONTACTS_ARRIVED = 'metacontacts_arrived'
 ROSTER_ARRIVED = 'roster_arrived'
+DELIMITER_ARRIVED = 'delimiter_arrived'
 PRIVACY_ARRIVED = 'privacy_arrived'
 PEP_CONFIG = 'pep_config'
 HAS_IDLE = True
@@ -525,21 +526,22 @@ class ConnectionVcard:
             else:
                 if iq_obj.getErrorCode() not in ('403', '406', '404'):
                     self.private_storage_supported = False
+            self.get_roster_delimiter()
+        elif self.awaiting_answers[id_][0] == DELIMITER_ARRIVED:
+            if not self.connection:
+                return
+            if iq_obj.getType() == 'result':
+                query = iq_obj.getTag('query')
+                delimiter = query.getTagData('roster')
+                if delimiter:
+                    self.nested_group_delimiter = delimiter
+                else:
+                    self.set_roster_delimiter('::')
+            else:
+                self.private_storage_supported = False
 
             # We can now continue connection by requesting the roster
-            version = None
-            if con.Stream.features and con.Stream.features.getTag('ver',
-            namespace=common.xmpp.NS_ROSTER_VER):
-                version = gajim.config.get_per('accounts', self.name,
-                    'roster_version')
-                if version and not gajim.contacts.get_contacts_jid_list(
-                self.name):
-                    gajim.config.set_per('accounts', self.name,
-                        'roster_version', '')
-                    version = None
-
-            iq_id = self.connection.initRoster(version=version)
-            self.awaiting_answers[iq_id] = (ROSTER_ARRIVED, )
+            self.request_roster()
         elif self.awaiting_answers[id_][0] == ROSTER_ARRIVED:
             if iq_obj.getType() == 'result':
                 if not iq_obj.getTag('query'):
diff --git a/src/roster_window.py b/src/roster_window.py
index 06067714c8..fe16ab6104 100644
--- a/src/roster_window.py
+++ b/src/roster_window.py
@@ -284,6 +284,36 @@ class RosterWindow:
 
         self.starting = False
 
+    def _add_group_iter(self, account, group):
+        """
+        Add a group iter in roster and return the newly created iter
+        """
+        if self.regroup:
+            account_group = 'MERGED'
+        else:
+            account_group = account
+        delimiter = gajim.connections[account].nested_group_delimiter
+        group_splited = group.split(delimiter)
+        parent_group = delimiter.join(group_splited[:-1])
+        if parent_group in self._iters[account_group]['groups']:
+            iter_parent = self._iters[account_group]['groups'][parent_group]
+        elif parent_group:
+            iter_parent = self._add_group_iter(account, parent_group)
+            if parent_group not in gajim.groups[account]:
+                if account + parent_group in self.collapsed_rows:
+                    is_expanded = False
+                else:
+                    is_expanded = True
+                gajim.groups[account][parent_group] = {'expand': is_expanded}
+        else:
+            iter_parent = self._get_account_iter(account, self.model)
+        iter_group = self.model.append(iter_parent,
+            [gajim.interface.jabber_state_images['16']['closed'],
+            gobject.markup_escape_text(group), 'group', group, account, None,
+            None, None, None, None, None] + [None] * self.nb_ext_renderers)
+        self.draw_group(group, account)
+        self._iters[account_group]['groups'][group] = iter_group
+        return iter_group
 
     def _add_entity(self, contact, account, groups=None,
                     big_brother_contact=None, big_brother_account=None):
@@ -328,23 +358,12 @@ class RosterWindow:
             # We are a normal contact. Add us to our groups.
             if not groups:
                 groups = contact.get_shown_groups()
-            if self.regroup:
-                account_group = 'MERGED'
-            else:
-                account_group = account
             for group in groups:
                 child_iterG = self._get_group_iter(group, account,
                     model=self.model)
                 if not child_iterG:
                     # Group is not yet in roster, add it!
-                    child_iterA = self._get_account_iter(account, self.model)
-                    child_iterG = self.model.append(child_iterA,
-                        [gajim.interface.jabber_state_images['16']['closed'],
-                        gobject.markup_escape_text(group),
-                        'group', group, account, None, None, None, None, None,
-                        None] + [None] * self.nb_ext_renderers)
-                    self.draw_group(group, account)
-                    self._iters[account_group]['groups'][group] = child_iterG
+                    child_iterG = self._add_group_iter(account, group)
 
                 if contact.is_transport():
                     typestr = 'agent'
@@ -419,8 +438,10 @@ class RosterWindow:
                         "Invalidated iters of %s" % contact.jid
 
                 parent_i = self.model.iter_parent(i)
+                parent_type = self.model[parent_i][C_TYPE]
 
-                if parent_type == 'group' and \
+                to_be_removed = i
+                while parent_type == 'group' and \
                 self.model.iter_n_children(parent_i) == 1:
                     if self.regroup:
                         account_group = 'MERGED'
@@ -429,10 +450,12 @@ class RosterWindow:
                     group = self.model[parent_i][C_JID].decode('utf-8')
                     if group in gajim.groups[account]:
                         del gajim.groups[account][group]
-                    self.model.remove(parent_i)
+                    to_be_removed = parent_i
                     del self._iters[account_group]['groups'][group]
-                else:
-                    self.model.remove(i)
+                    parent_i = self.model.iter_parent(parent_i)
+                    parent_type = self.model[parent_i][C_TYPE]
+                self.model.remove(to_be_removed)
+
             del self._iters[account]['contacts'][contact.jid]
             return True
 
@@ -1248,13 +1271,19 @@ class RosterWindow:
             if family and not is_big_brother and not self.starting:
                 self.draw_parent_contact(jid, account)
 
+        delimiter = gajim.connections[account].nested_group_delimiter
         for group in contact.get_shown_groups():
             # We need to make sure that _visible_func is called for
             # our groups otherwise we might not be shown
-            iterG = self._get_group_iter(group, account, model=self.model)
-            if iterG:
-                # it's not self contact
-                self.model[iterG][C_JID] = self.model[iterG][C_JID]
+            group_splited = group.split(delimiter)
+            i = 1
+            while i < len(group_splited) + 1:
+                g = delimiter.join(group_splited[:i])
+                iterG = self._get_group_iter(g, account, model=self.model)
+                if iterG:
+                    # it's not self contact
+                    self.model[iterG][C_JID] = self.model[iterG][C_JID]
+                i += 1
 
         gajim.plugin_manager.gui_extension_point('roster_draw_contact', self,
             jid, account, contact)
@@ -1446,16 +1475,21 @@ class RosterWindow:
         """
         if not self.tree.get_model():
             return
-        iterG = self._get_group_iter(group, account)
-        if not iterG:
-            # Group not visible
-            return
-        path = self.modelfilter.get_path(iterG)
-        if account + group in self.collapsed_rows:
-            self.tree.collapse_row(path)
-        else:
-            self.tree.expand_row(path, False)
-        return False
+        delimiter = gajim.connections[account].nested_group_delimiter
+        group_splited = group.split(delimiter)
+        i = 1
+        while i < len(group_splited) + 1:
+            g = delimiter.join(group_splited[:i])
+            iterG = self._get_group_iter(g, account)
+            if not iterG:
+                # Group not visible
+                return
+            path = self.modelfilter.get_path(iterG)
+            if account + g in self.collapsed_rows:
+                self.tree.collapse_row(path)
+            else:
+                self.tree.expand_row(path, False)
+            i += 1
 
 ##############################################################################
 ### Roster and Modelfilter handling
@@ -1544,11 +1578,16 @@ class RosterWindow:
             else:
                 accounts = [account]
             for _acc in accounts:
+                delimiter = gajim.connections[_acc].nested_group_delimiter
                 for contact in gajim.contacts.iter_contacts(_acc):
+                    if not self.contact_is_visible(contact, _acc):
+                        continue
                     # Is this contact in this group?
-                    if group in contact.get_shown_groups():
-                        if self.contact_is_visible(contact, _acc):
-                            return True
+                    for grp in contact.get_shown_groups():
+                        while grp:
+                            if group == grp:
+                                return True
+                            grp = delimiter.join(grp.split(delimiter)[:-1])
             return False
         if type_ == 'contact':
             if gajim.config.get('showoffline'):
@@ -2101,7 +2140,7 @@ class RosterWindow:
         ctrl = gajim.interface.msg_win_mgr.get_control(contact.jid, account)
         if ctrl and ctrl.type_id != message_control.TYPE_GC:
             ctrl.contact = gajim.contacts.get_contact_with_highest_priority(
-                    account, contact.jid)
+                account, contact.jid)
             ctrl.update_status_display(name, uf_show, status)
 
         if contact.resource:
@@ -2111,7 +2150,7 @@ class RosterWindow:
 
         # Delete pep if needed
         keep_pep = any(c.show not in ('error', 'offline') for c in
-                contact_instances)
+            contact_instances)
         if not keep_pep and contact.jid != gajim.get_jid_from_account(account) \
         and not contact.is_groupchat():
             self.delete_pep(contact.jid, account)
@@ -3384,7 +3423,7 @@ class RosterWindow:
                     if (self.tree.row_expanded(path)):
                         self.tree.collapse_row(path)
                     else:
-                        self.tree.expand_row(path, False)
+                        self.expand_group_row(path)
 
                 elif type_ == 'contact' and x > x_min and x < x_min + 27:
                     if (self.tree.row_expanded(path)):
@@ -3392,6 +3431,18 @@ class RosterWindow:
                     else:
                         self.tree.expand_row(path, False)
 
+    def expand_group_row(self, path):
+        self.tree.expand_row(path, False)
+        iter = self.modelfilter.get_iter(path)
+        child_iter = self.modelfilter.iter_children(iter)
+        while child_iter:
+            type_ = self.modelfilter[child_iter][C_TYPE]
+            account = self.modelfilter[child_iter][C_ACCOUNT]
+            group = self.modelfilter[child_iter][C_JID]
+            if type_ == 'group' and account + group not in self.collapsed_rows:
+                self.expand_group_row(self.modelfilter.get_path(child_iter))
+            child_iter = self.modelfilter.iter_next(child_iter)
+
     def on_req_usub(self, widget, list_):
         """
         Remove a contact. list_ is a list of (contact, account) tuples
@@ -3966,7 +4017,7 @@ class RosterWindow:
         type_ = model[titer][C_TYPE]
         if type_ == 'group':
             child_model[child_iter][C_IMG] = gajim.interface.\
-            jabber_state_images['16']['closed']
+                jabber_state_images['16']['closed']
             group = model[titer][C_JID].decode('utf-8')
             for account in accounts:
                 if group in gajim.groups[account]: # This account has this group
@@ -4133,7 +4184,7 @@ class RosterWindow:
             return
         path = list_of_paths[0]
         data = ''
-        if len(path) >= 3:
+        if len(path) >= 2:
             data = model[path][C_JID]
         selection.set(selection.target, 8, data)
 
@@ -4306,6 +4357,12 @@ class RosterWindow:
         context.finish(False, True)
         return True
 
+    def move_group(self, old_name, new_name, account):
+        for group in gajim.groups[account].keys():
+            if group.startswith(old_name):
+                self.rename_group(group, group.replace(old_name, new_name),
+                    account)
+
     def drag_data_received_data(self, treeview, context, x, y, selection, info,
     etime):
         treeview.stop_emission('drag_data_received')
@@ -4395,26 +4452,46 @@ class RosterWindow:
         type_source = model[iter_source][C_TYPE]
         account_source = model[iter_source][C_ACCOUNT].decode('utf-8')
 
-        # Only normal contacts can be dragged
-        if type_source != 'contact':
-            return
         if gajim.config.get_per('accounts', account_source, 'is_zeroconf'):
             return
 
+        if type_dest == 'self_contact':
+            # drop on self contact row
+            return
+
+        if type_dest == 'groupchat':
+            # drop on a minimized groupchat
+            # TODO: Invite to groupchat if type_dest = contact
+            return
+
+        if type_source == 'group':
+            if account_source != account_dest:
+                # drop on another account
+                return
+            grp_source = model[iter_source][C_JID].decode('utf-8')
+            delimiter = gajim.connections[account_source].nested_group_delimiter
+            grp_source_list = grp_source.split(delimiter)
+            new_grp = None
+            if type_dest == 'account':
+                new_grp = grp_source_list[-1]
+            elif type_dest == 'group':
+                new_grp = model[iter_dest][C_JID].decode('utf-8') + delimiter +\
+                    grp_source_list[-1]
+            if new_grp:
+                self.move_group(grp_source, new_grp, account_source)
+
+        # Only normal contacts and group can be dragged
+        if type_source != 'contact':
+            return
+
         # A contact was dropped
         if gajim.config.get_per('accounts', account_dest, 'is_zeroconf'):
             # drop on zeroconf account, adding not possible
             return
-        if type_dest == 'self_contact':
-            # drop on self contact row
-            return
+
         if type_dest == 'account' and account_source == account_dest:
             # drop on the account it was dragged from
             return
-        if type_dest == 'groupchat':
-            # drop on a minimized groupchat
-            # TODO: Invite to groupchat
-            return
 
         # Get valid source group, jid and contact
         it = iter_source
@@ -4683,7 +4760,11 @@ class RosterWindow:
             renderer.set_property('xalign', 0)
         elif type_ == 'group':
             self._set_group_row_background_color(renderer)
-            renderer.set_property('xalign', 0.2)
+            parent_iter = model.iter_parent(titer)
+            if model[parent_iter][C_TYPE] == 'group':
+                renderer.set_property('xalign', 0.4)
+            else:
+                renderer.set_property('xalign', 0.2)
         elif type_:
             # prevent type_ = None, see http://trac.gajim.org/ticket/2534
             if not model[titer][C_JID] or not model[titer][C_ACCOUNT]:
@@ -4696,7 +4777,7 @@ class RosterWindow:
             if model[parent_iter][C_TYPE] == 'contact':
                 renderer.set_property('xalign', 1)
             else:
-                renderer.set_property('xalign', 0.4)
+                renderer.set_property('xalign', 0.6)
         renderer.set_property('width', 26)
 
     def _nameCellDataFunc(self, column, renderer, model, titer, data=None):
@@ -4724,7 +4805,11 @@ class RosterWindow:
                 self.set_renderer_color(renderer, gtk.STATE_PRELIGHT, False)
             renderer.set_property('font',
                 gtkgui_helpers.get_theme_font_for_option(theme, 'groupfont'))
-            renderer.set_property('xpad', 4)
+            parent_iter = model.iter_parent(titer)
+            if model[parent_iter][C_TYPE] == 'group':
+                renderer.set_property('xpad', 8)
+            else:
+                renderer.set_property('xpad', 4)
             self._set_group_row_background_color(renderer)
         elif type_:
             # prevent type_ = None, see http://trac.gajim.org/ticket/2534
@@ -4755,7 +4840,7 @@ class RosterWindow:
             if model[parent_iter][C_TYPE] == 'contact':
                 renderer.set_property('xpad', 16)
             else:
-                renderer.set_property('xpad', 8)
+                renderer.set_property('xpad', 12)
 
     def _fill_pep_pixbuf_renderer(self, column, renderer, model, titer,
     data=None):
-- 
GitLab