Don't set Individual.is-favourite when updating from Personas
[platform/upstream/folks.git] / folks / individual.vala
index f3c3da5..d20bc3d 100644 (file)
@@ -23,28 +23,81 @@ using GLib;
 using Folks;
 
 /**
+ * Trust level for an {@link Individual} for use in the UI.
+ *
+ * @since 0.1.15
+ */
+public enum Folks.TrustLevel
+{
+  /**
+   * The {@link Individual}'s {@link Persona}s aren't trusted at all.
+   *
+   * This is the trust level for an {@link Individual} which contains one or
+   * more {@link Persona}s which cannot be guaranteed to be the same
+   * {@link Persona}s as were originally linked together.
+   *
+   * For example, an {@link Individual} containing a link-local XMPP
+   * {@link Persona} would have this trust level, since someone else could
+   * easily spoof the link-local XMPP {@link Persona}'s identity.
+   *
+   * @since 0.1.15
+   */
+  NONE,
+
+  /**
+   * The {@link Individual}'s {@link Persona}s are trusted.
+   *
+   * This trust level is for {@link Individual}s where it can be guaranteed
+   * that all the {@link Persona}s are the same ones as when they were
+   * originally linked together.
+   *
+   * Note that this doesn't guarantee that the user who behind each
+   * {@link Persona} is who they claim to be.
+   *
+   * @since 0.1.15
+   */
+  PERSONAS
+}
+
+/**
  * A physical person, aggregated from the various {@link Persona}s the person
  * might have, such as their different IM addresses or vCard entries.
  */
 public class Folks.Individual : Object,
     Alias,
     Avatar,
-    Capabilities,
     Favourite,
     Groups,
     Presence
 {
-  private HashTable<string, bool> _groups;
-  private GLib.List<Persona> _personas;
-  private HashTable<PersonaStore, HashSet<Persona>> stores;
   private bool _is_favourite;
+  private string _alias;
+  private HashTable<string, bool> _groups;
+  /* These two data structures should store exactly the same set of Personas:
+   * the Personas contained in this Individual. The HashSet is used for fast
+   * lookups, whereas the List is used for iteration.
+   * The Individual's references to its Personas are kept by the HashSet;
+   * since the List contains the same set of Personas, it doesn't need an
+   * extra reference (and due to bgo#624249, this is a good thing). */
+  private GLib.List<unowned Persona> _persona_list;
+  private HashSet<Persona> _persona_set;
+  /* Mapping from PersonaStore -> number of Personas from that store contained
+   * in this Individual. There shouldn't be any entries with a number < 1.
+   * This is used for working out when to disconnect from store signals. */
+  private HashMap<PersonaStore, uint> stores;
 
-  /* XXX: should setting this push it down into the Persona (to foward along to
-   * the actual store if possible?) */
   /**
-   * {@inheritDoc}
+   * The trust level of the Individual.
+   *
+   * This specifies how far the Individual can be trusted to be who it claims
+   * to be. See the descriptions for the elements of {@link TrustLevel}.
+   *
+   * Clients should ''not'' allow linking of Individuals who have a trust level
+   * of {@link TrustLevel.NONE}.
+   *
+   * @since 0.1.15
    */
-  public string alias { get; set; }
+  public TrustLevel trust_level { get; private set; }
 
   /**
    * {@inheritDoc}
@@ -54,11 +107,6 @@ public class Folks.Individual : Object,
   /**
    * {@inheritDoc}
    */
-  public CapabilitiesFlags capabilities { get; private set; }
-
-  /**
-   * {@inheritDoc}
-   */
   public Folks.PresenceType presence_type { get; private set; }
 
   /**
@@ -82,34 +130,79 @@ public class Folks.Individual : Object,
    *
    * At this point, the Individual is invalid, so any client referencing it
    * should unreference it and remove it from their UI.
+   *
+   * @param replacement_individual the individual which has replaced this one
+   * due to linking, or `null` if this individual was removed for another reason
    */
-  public signal void removed ();
+  public signal void removed (Individual? replacement_individual);
+
+  /**
+   * {@inheritDoc}
+   */
+  public string alias
+    {
+      get { return this._alias; }
+
+      set
+        {
+          if (this._alias == value)
+            return;
+
+          this._alias = value;
+
+          /* First, try to write it to only the writeable Personas… */
+          bool alias_changed = false;
+          this._persona_list.foreach ((p) =>
+            {
+              if (p is Alias && ((Persona) p).store.is_writeable == true)
+                {
+                  ((Alias) p).alias = value;
+                  alias_changed = true;
+                }
+            });
+
+          /* â€¦but if there are no writeable Personas, we have to fall back to
+           * writing it to every Persona. */
+          if (alias_changed == false)
+            {
+              this._persona_list.foreach ((p) =>
+                {
+                  if (p is Alias)
+                    ((Alias) p).alias = value;
+                });
+            }
+        }
+    }
 
   /**
    * Whether this Individual is a user-defined favourite.
    *
    * This property is `true` if any of this Individual's {@link Persona}s are
    * favourites).
-   *
-   * When set, the value is propagated to all of this Individual's
-   * {@link Persona}s.
    */
   public bool is_favourite
     {
       get { return this._is_favourite; }
 
-      /* Propagate the new favourite status to every Persona, but only if it's
-       * changed. */
       set
         {
           if (this._is_favourite == value)
             return;
 
+          debug ("Setting '%s' favourite status to %s", this.id,
+              value ? "TRUE" : "FALSE");
+
           this._is_favourite = value;
-          this._personas.foreach ((p) =>
+          this._persona_list.foreach ((p) =>
             {
               if (p is Favourite)
-                ((Favourite) p).is_favourite = value;
+                {
+                  SignalHandler.block_by_func (p,
+                      (void*) this.notify_is_favourite_cb, this);
+                  ((Favourite) p).is_favourite = value;
+                  SignalHandler.unblock_by_func (p,
+                      (void*) this.notify_is_favourite_cb, this);
+                }
             });
         }
     }
@@ -121,13 +214,12 @@ public class Folks.Individual : Object,
     {
       get { return this._groups; }
 
-      /* Propagate the list of new groups to every Persona in the individual
-       * which implements the Groups interface */
       set
         {
-          this._personas.foreach ((p) =>
+          this._groups = value;
+          this._persona_list.foreach ((p) =>
             {
-              if (p is Groups)
+              if (p is Groups && ((Persona) p).store.is_writeable == true)
                 ((Groups) p).groups = value;
             });
         }
@@ -138,66 +230,34 @@ public class Folks.Individual : Object,
    *
    * Changing the set of personas may cause updates to the aggregated properties
    * provided by the Individual, resulting in property notifications for them.
+   *
+   * Changing the set of personas will not cause permanent linking/unlinking of
+   * the added/removed personas to/from this Individual. To do that, call
+   * {@link IndividualAggregator.link_personas} or
+   * {@link IndividualAggregator.unlink_individual}, which will ensure the link
+   * changes are written to the appropriate backend.
    */
   public GLib.List<Persona> personas
     {
-      get { return this._personas; }
-
-      set
-        {
-          /* Disconnect from all our previous personas */
-          this._personas.foreach ((p) =>
-            {
-              var persona = (Persona) p;
-              var groups = (p is Groups) ? (Groups) p : null;
-
-              persona.notify["avatar"].disconnect (this.notify_avatar_cb);
-              persona.notify["presence-message"].disconnect (
-                  this.notify_presence_cb);
-              persona.notify["presence-type"].disconnect (
-                  this.notify_presence_cb);
-              persona.notify["is-favourite"].disconnect (
-                  this.notify_is_favourite_cb);
-              groups.group_changed.disconnect (this.persona_group_changed_cb);
-            });
-
-          this._personas = new GLib.List<Persona> ();
-          value.foreach ((l) =>
-            {
-              this._personas.prepend ((Persona) l);
-            });
-          this._personas.reverse ();
-
-          /* If all the personas have been removed, remove the individual */
-          if (this._personas.length () < 1)
-            {
-              this.removed ();
-              return;
-            }
-
-          /* TODO: base this upon our ID in permanent storage, once we have that
-           */
-          if (this.id == null && this._personas.data != null)
-            this.id = this._personas.data.iid;
+      get { return this._persona_list; }
+      set { this._set_personas (value, null); }
+    }
 
-          /* Connect to all the new personas */
-          this._personas.foreach ((p) =>
-            {
-              var persona = (Persona) p;
-              var groups = (p is Groups) ? (Groups) p : null;
-
-              persona.notify["avatar"].connect (this.notify_avatar_cb);
-              persona.notify["presence-message"].connect (
-                  this.notify_presence_cb);
-              persona.notify["presence-type"].connect (this.notify_presence_cb);
-              persona.notify["is-favourite"].connect (
-                  this.notify_is_favourite_cb);
-              groups.group_changed.connect (this.persona_group_changed_cb);
-            });
+  /**
+   * Emitted when one or more {@link Persona}s are added to or removed from
+   * the Individual.
+   *
+   * @param added a list of {@link Persona}s which have been added
+   * @param removed a list of {@link Persona}s which have been removed
+   *
+   * @since 0.1.15
+   */
+  public signal void personas_changed (GLib.List<Persona>? added,
+      GLib.List<Persona>? removed);
 
-          /* Update our aggregated fields and notify the changes */
-          this.update_fields ();
-        }
+  private void notify_alias_cb (Object obj, ParamSpec ps)
+    {
+      this.update_alias ();
     }
 
   private void notify_avatar_cb (Object obj, ParamSpec ps)
@@ -207,7 +267,6 @@ public class Folks.Individual : Object,
 
   private void persona_group_changed_cb (string group, bool is_member)
     {
-      this.change_group (group, is_member);
       this.update_groups ();
     }
 
@@ -223,12 +282,12 @@ public class Folks.Individual : Object,
    * @param group a freeform group identifier
    * @param is_member whether the Individual should be a member of the group
    */
-  public void change_group (string group, bool is_member)
+  public async void change_group (string group, bool is_member)
     {
-      this._personas.foreach ((p) =>
+      this._persona_list.foreach ((p) =>
         {
           if (p is Groups)
-            ((Groups) p).change_group (group, is_member);
+            ((Groups) p).change_group.begin (group, is_member);
         });
 
       /* don't notify, since it hasn't happened in the persona backing stores
@@ -256,76 +315,64 @@ public class Folks.Individual : Object,
    */
   public Individual (GLib.List<Persona>? personas)
     {
-      Object (personas: personas);
-
-      this.stores = new HashTable<PersonaStore, HashSet<Persona>> (direct_hash,
-          direct_equal);
-      this.stores_update ();
+      this._persona_set = new HashSet<Persona> (null, null);
+      this.stores = new HashMap<PersonaStore, uint> (null, null);
+      this.personas = personas;
     }
 
-  private void stores_update ()
+  private void store_removed_cb (PersonaStore store)
     {
-      this._personas.foreach ((p) =>
+      GLib.List<Persona> removed_personas = null;
+      Iterator<Persona> iter = this._persona_set.iterator ();
+      while (iter.next ())
         {
-          var persona = (Persona) p;
-          var store_is_new = false;
-          var persona_set = this.stores.lookup (persona.store);
-          if (persona_set == null)
-            {
-              persona_set = new HashSet<Persona> (direct_hash, direct_equal);
-              store_is_new = true;
-            }
-
-          persona_set.add (persona);
+          Persona persona = iter.get ();
 
-          if (store_is_new)
-            {
-              this.stores.insert (persona.store, persona_set);
+          removed_personas.prepend (persona);
+          this._persona_list.remove (persona);
+          iter.remove ();
+        }
 
-              persona.store.removed.connect (this.store_removed_cb);
-              persona.store.personas_removed.connect (
-                this.store_personas_removed_cb);
-            }
-        });
-    }
+      if (removed_personas != null)
+        this.personas_changed (null, removed_personas);
 
-  private void store_removed_cb (PersonaStore store)
-    {
-      var persona_set = this.stores.lookup (store);
-      if (persona_set != null)
-        {
-          foreach (var persona in persona_set)
-            {
-              this._personas.remove (persona);
-            }
-        }
       if (store != null)
-        this.stores.remove (store);
+        this.stores.unset (store);
 
-      if (this._personas.length () < 1 || this.stores.size () < 1)
+      if (this._persona_set.size < 1)
         {
-          this.removed ();
+          this.removed (null);
           return;
         }
 
       this.update_fields ();
     }
 
-  private void store_personas_removed_cb (PersonaStore store,
-      GLib.List<Persona> personas)
+  private void store_personas_changed_cb (PersonaStore store,
+      GLib.List<Persona>? added,
+      GLib.List<Persona>? removed,
+      string? message,
+      Persona? actor,
+      Groups.ChangeReason reason)
     {
-      var persona_set = this.stores.lookup (store);
-      personas.foreach ((data) =>
+      GLib.List<Persona> removed_personas = null;
+      removed.foreach ((data) =>
         {
-          var p = (Persona) data;
+          unowned Persona p = (Persona) data;
 
-          persona_set.remove (p);
-          this._personas.remove (p);
+          if (this._persona_set.remove (p))
+            {
+              removed_personas.prepend (p);
+              this._persona_list.remove (p);
+            }
         });
 
-      if (this._personas.length () < 1)
+      if (removed_personas != null)
+        this.personas_changed (null, removed_personas);
+
+      if (this._persona_set.size < 1)
         {
-          this.removed ();
+          this.removed (null);
           return;
         }
 
@@ -334,45 +381,12 @@ public class Folks.Individual : Object,
 
   private void update_fields ()
     {
-      /* Gather the first occurrence of each field. We assume that there is
-       * at least one persona in the list, since the Individual should've been
-       * destroyed before now otherwise. */
-      string alias = null;
-      var caps = CapabilitiesFlags.NONE;
-      this._personas.foreach ((p) =>
-        {
-          if (p is Alias)
-            {
-              var a = (Alias) p;
-
-              if (alias == null || alias.strip () == "")
-                alias = a.alias;
-            }
-
-          if (p is Capabilities)
-            caps |= ((Capabilities) p).capabilities;
-        });
-
-      if (alias == null)
-        {
-          /* We have to pick a UID, since none of the personas have an alias
-           * available. Pick the UID from the first persona in the list. */
-          alias = this._personas.data.uid;
-          warning ("No aliases available for individual; using UID instead: %s",
-                   alias);
-        }
-
-      /* only notify if the value has changed */
-      if (this.alias != alias)
-        this.alias = alias;
-
-      if (this.capabilities != caps)
-        this.capabilities = caps;
-
       this.update_groups ();
       this.update_presence ();
       this.update_is_favourite ();
       this.update_avatar ();
+      this.update_alias ();
+      this.update_trust_level ();
     }
 
   private void update_groups ()
@@ -388,11 +402,11 @@ public class Folks.Individual : Object,
        * "groups-changed" on the store (with the set of personas), to allow the
        * back-end to optimize it (like Telepathy will for MembersChanged for the
        * groups channel list) */
-      this._personas.foreach ((p) =>
+      this._persona_list.foreach ((p) =>
         {
           if (p is Groups)
             {
-              var persona = (Groups) p;
+              unowned Groups persona = (Groups) p;
 
               persona.groups.foreach ((k, v) =>
                 {
@@ -440,11 +454,11 @@ public class Folks.Individual : Object,
       var presence_type = Folks.PresenceType.UNSET;
 
       /* Choose the most available presence from our personas */
-      this._personas.foreach ((p) =>
+      this._persona_list.foreach ((p) =>
         {
           if (p is Presence)
             {
-              var presence = (Presence) p; 
+              unowned Presence presence = (Presence) p;
 
               if (Presence.typecmp (presence.presence_type, presence_type) > 0)
                 {
@@ -469,7 +483,9 @@ public class Folks.Individual : Object,
     {
       bool favourite = false;
 
-      this._personas.foreach ((p) =>
+      debug ("Running update_is_favourite() on '%s'", this.id);
+
+      this._persona_list.foreach ((p) =>
         {
           if (favourite == false && p is Favourite)
             {
@@ -479,16 +495,97 @@ public class Folks.Individual : Object,
             }
         });
 
-      /* Only notify if the value has changed */
+      /* Only notify if the value has changed. We have to set the private member
+       * and notify manually, or we'd end up propagating the new favourite
+       * status back down to all our Personas. */
       if (this._is_favourite != favourite)
-        this._is_favourite = favourite;
+        {
+          this._is_favourite = favourite;
+          this.notify_property ("is-favourite");
+        }
+    }
+
+  private void update_alias ()
+    {
+      string alias = null;
+      bool alias_is_display_id = false;
+
+      /* Search for an alias from a writeable Persona, and use it as our first
+       * choice if it's non-empty, since that's where the user-set alias is
+       * stored. */
+      foreach (Persona p in this._persona_list)
+        {
+          if (p is Alias && p.store.is_writeable == true)
+            {
+              unowned Alias a = (Alias) p;
+
+              if (a.alias != null && a.alias.strip () != "")
+                {
+                  alias = a.alias;
+                  break;
+                }
+            }
+        }
+
+      /* Since we can't find a non-empty alias from a writeable backend, try
+       * the aliases from other personas. Use a non-empty alias which isn't
+       * equal to the persona's display ID as our preference. If we can't find
+       * one of those, fall back to one which is equal to the display ID. */
+      if (alias == null)
+        {
+          foreach (Persona p in this._persona_list)
+            {
+              if (p is Alias)
+                {
+                  unowned Alias a = (Alias) p;
+
+                  if (a.alias == null || a.alias.strip () == "")
+                    continue;
+
+                  if (alias == null || alias_is_display_id == true)
+                    {
+                      /* We prefer to not have an alias which is the same as the
+                       * Persona's display-id, since having such an alias
+                       * implies that it's the default. However, we prefer using
+                       * such an alias to using the Persona's UID, which is our
+                       * ultimate fallback (below). */
+                      alias = a.alias;
+
+                      if (a.alias == p.display_id)
+                        alias_is_display_id = true;
+                      else if (alias != null)
+                        break;
+                    }
+                }
+            }
+        }
+
+      if (alias == null)
+        {
+          /* We have to pick a display ID, since none of the personas have an
+           * alias available. Pick the display ID from the first persona in the
+           * list. */
+          alias = this._persona_list.data.display_id;
+          debug ("No aliases available for individual; using display ID " +
+              "instead: %s", alias);
+        }
+
+      /* Only notify if the value has changed. We have to set the private member
+       * and notify manually, or we'd end up propagating the new alias back
+       * down to all our Personas, even if it's a fallback display ID or
+       * something else undesirable. */
+      if (this._alias != alias)
+        {
+          this._alias = alias;
+          this.notify_property ("alias");
+        }
     }
 
   private void update_avatar ()
     {
       File avatar = null;
 
-      this._personas.foreach ((p) =>
+      this._persona_list.foreach ((p) =>
         {
           if (avatar == null && p is Avatar)
             {
@@ -502,17 +599,19 @@ public class Folks.Individual : Object,
         this.avatar = avatar;
     }
 
-  /**
-   * Get a bitmask of the capabilities of this Individual.
-   *
-   * The capabilities is the union of the sets of capabilities of all the
-   * {@link Persona}s in the Individual.
-   *
-   * @return bitmask of the Individual's capabilities
-   */
-  public CapabilitiesFlags get_capabilities ()
+  private void update_trust_level ()
     {
-      return this.capabilities;
+      TrustLevel trust_level = TrustLevel.PERSONAS;
+
+      foreach (Persona p in this._persona_list)
+        {
+          if (p.store.trust_level == PersonaStoreTrust.NONE)
+            trust_level = TrustLevel.NONE;
+        }
+
+      /* Only notify if the value has changed */
+      if (this.trust_level != trust_level)
+        this.trust_level = trust_level;
     }
 
   /*
@@ -593,4 +692,128 @@ public class Folks.Individual : Object,
       Presence p = this;
       return p.is_online ();
     }
+
+  private void connect_to_persona (Persona persona)
+    {
+      persona.notify["alias"].connect (this.notify_alias_cb);
+      persona.notify["avatar"].connect (this.notify_avatar_cb);
+      persona.notify["presence-message"].connect (this.notify_presence_cb);
+      persona.notify["presence-type"].connect (this.notify_presence_cb);
+      persona.notify["is-favourite"].connect (this.notify_is_favourite_cb);
+
+      if (persona is Groups)
+        {
+          ((Groups) persona).group_changed.connect (
+              this.persona_group_changed_cb);
+        }
+    }
+
+  private void disconnect_from_persona (Persona persona)
+    {
+      persona.notify["alias"].disconnect (this.notify_alias_cb);
+      persona.notify["avatar"].disconnect (this.notify_avatar_cb);
+      persona.notify["presence-message"].disconnect (
+          this.notify_presence_cb);
+      persona.notify["presence-type"].disconnect (this.notify_presence_cb);
+      persona.notify["is-favourite"].disconnect (
+          this.notify_is_favourite_cb);
+
+      if (persona is Groups)
+        {
+          ((Groups) persona).group_changed.disconnect (
+              this.persona_group_changed_cb);
+        }
+    }
+
+  private void _set_personas (GLib.List<Persona>? persona_list,
+      Individual? replacement_individual)
+    {
+      HashSet<Persona> persona_set = new HashSet<Persona> (null, null);
+      GLib.List<Persona> added = null;
+      GLib.List<Persona> removed = null;
+
+      /* Determine which Personas have been added */
+      foreach (Persona p in persona_list)
+        {
+          if (!this._persona_set.contains (p))
+            {
+              added.prepend (p);
+
+              this._persona_set.add (p);
+              this.connect_to_persona (p);
+
+              /* Increment the Persona count for this PersonaStore */
+              unowned PersonaStore store = p.store;
+              uint num_from_store = this.stores.get (store);
+              if (num_from_store == 0)
+                {
+                  this.stores.set (store, num_from_store + 1);
+                }
+              else
+                {
+                  this.stores.set (store, 1);
+
+                  store.removed.connect (this.store_removed_cb);
+                  store.personas_changed.connect (
+                      this.store_personas_changed_cb);
+                }
+            }
+
+          persona_set.add (p);
+        }
+
+      /* Determine which Personas have been removed */
+      foreach (Persona p in this._persona_list)
+        {
+          if (!persona_set.contains (p))
+            {
+              removed.prepend (p);
+
+              /* Decrement the Persona count for this PersonaStore */
+              unowned PersonaStore store = p.store;
+              uint num_from_store = this.stores.get (store);
+              if (num_from_store > 1)
+                {
+                  this.stores.set (store, num_from_store - 1);
+                }
+              else
+                {
+                  store.removed.disconnect (this.store_removed_cb);
+                  store.personas_changed.disconnect (
+                      this.store_personas_changed_cb);
+
+                  this.stores.unset (store);
+                }
+
+              this.disconnect_from_persona (p);
+              this._persona_set.remove (p);
+            }
+        }
+
+      /* Update the Persona list. We just copy the list given to us to save
+       * repeated insertions/removals and also to ensure we retain the ordering
+       * of the Personas we were given. */
+      this._persona_list = persona_list.copy ();
+
+      this.personas_changed (added, removed);
+
+      /* If all the Personas have been removed, remove the Individual */
+      if (this._persona_set.size < 1)
+        {
+          this.removed (replacement_individual);
+            return;
+        }
+
+      /* TODO: Base this upon our ID in permanent storage, once we have that. */
+      if (this.id == null && this._persona_list.data != null)
+        this.id = this._persona_list.data.uid;
+
+      /* Update our aggregated fields and notify the changes */
+      this.update_fields ();
+    }
+
+  internal void replace (Individual replacement_individual)
+    {
+      this._set_personas (null, replacement_individual);
+    }
 }