Ensure all methods that should be async are, to get API breaking out of the way.
[platform/upstream/folks.git] / folks / individual.vala
1 /*
2  * Copyright (C) 2010 Collabora Ltd.
3  *
4  * This library is free software: you can redistribute it and/or modify
5  * it under the terms of the GNU Lesser General Public License as published by
6  * the Free Software Foundation, either version 2.1 of the License, or
7  * (at your option) any later version.
8  *
9  * This library is distributed in the hope that it will be useful,
10  * but WITHOUT ANY WARRANTY; without even the implied warranty of
11  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12  * GNU Lesser General Public License for more details.
13  *
14  * You should have received a copy of the GNU Lesser General Public License
15  * along with this library.  If not, see <http://www.gnu.org/licenses/>.
16  *
17  * Authors:
18  *       Travis Reitter <travis.reitter@collabora.co.uk>
19  */
20
21 using Gee;
22 using GLib;
23 using Folks;
24
25 /**
26  * A physical person, aggregated from the various {@link Persona}s the person
27  * might have, such as their different IM addresses or vCard entries.
28  */
29 public class Folks.Individual : Object,
30     Alias,
31     Avatar,
32     Favourite,
33     Groups,
34     Presence
35 {
36   private HashTable<string, bool> _groups;
37   private GLib.List<Persona> _personas;
38   private HashTable<PersonaStore, HashSet<Persona>> stores;
39   private bool _is_favourite;
40   private string _alias;
41
42   /**
43    * {@inheritDoc}
44    */
45   public File avatar { get; private set; }
46
47   /**
48    * {@inheritDoc}
49    */
50   public Folks.PresenceType presence_type { get; private set; }
51
52   /**
53    * {@inheritDoc}
54    */
55   public string presence_message { get; private set; }
56
57   /**
58    * A unique identifier for the Individual.
59    *
60    * This uniquely identifies the Individual, and persists across
61    * {@link IndividualAggregator} instances.
62    *
63    * FIXME: Will this.id actually be the persistent ID for storage?
64    */
65   public string id { get; private set; }
66
67   /**
68    * Emitted when the last of the Individual's {@link Persona}s has been
69    * removed.
70    *
71    * At this point, the Individual is invalid, so any client referencing it
72    * should unreference it and remove it from their UI.
73    */
74   public signal void removed ();
75
76   /**
77    * {@inheritDoc}
78    */
79   public string alias
80     {
81       get { return this._alias; }
82
83       set
84         {
85           if (this._alias == value)
86             return;
87
88           this._alias = value;
89           this._personas.foreach ((p) =>
90             {
91               if (p is Alias)
92                 ((Alias) p).alias = value;
93             });
94         }
95     }
96
97   /**
98    * Whether this Individual is a user-defined favourite.
99    *
100    * This property is `true` if any of this Individual's {@link Persona}s are
101    * favourites).
102    *
103    * When set, the value is propagated to all of this Individual's
104    * {@link Persona}s.
105    */
106   public bool is_favourite
107     {
108       get { return this._is_favourite; }
109
110       /* Propagate the new favourite status to every Persona, but only if it's
111        * changed. */
112       set
113         {
114           if (this._is_favourite == value)
115             return;
116
117           this._is_favourite = value;
118           this._personas.foreach ((p) =>
119             {
120               if (p is Favourite)
121                 ((Favourite) p).is_favourite = value;
122             });
123         }
124     }
125
126   /**
127    * {@inheritDoc}
128    */
129   public HashTable<string, bool> groups
130     {
131       get { return this._groups; }
132
133       /* Propagate the list of new groups to every Persona in the individual
134        * which implements the Groups interface */
135       set
136         {
137           this._personas.foreach ((p) =>
138             {
139               if (p is Groups)
140                 ((Groups) p).groups = value;
141             });
142         }
143     }
144
145   /**
146    * The set of {@link Persona}s encapsulated by this Individual.
147    *
148    * Changing the set of personas may cause updates to the aggregated properties
149    * provided by the Individual, resulting in property notifications for them.
150    */
151   public GLib.List<Persona> personas
152     {
153       get { return this._personas; }
154
155       set
156         {
157           /* Disconnect from all our previous personas */
158           this._personas.foreach ((p) =>
159             {
160               unowned Persona persona = (Persona) p;
161
162               persona.notify["alias"].disconnect (this.notify_alias_cb);
163               persona.notify["avatar"].disconnect (this.notify_avatar_cb);
164               persona.notify["presence-message"].disconnect (
165                   this.notify_presence_cb);
166               persona.notify["presence-type"].disconnect (
167                   this.notify_presence_cb);
168               persona.notify["is-favourite"].disconnect (
169                   this.notify_is_favourite_cb);
170               persona.notify["groups"].disconnect (this.notify_groups_cb);
171
172               if (p is Groups)
173                 {
174                   ((Groups) p).group_changed.disconnect (
175                       this.persona_group_changed_cb);
176                 }
177             });
178
179           this._personas = new GLib.List<Persona> ();
180           value.foreach ((l) =>
181             {
182               this._personas.prepend ((Persona) l);
183             });
184           this._personas.reverse ();
185
186           /* If all the personas have been removed, remove the individual */
187           if (this._personas.length () < 1)
188             {
189               this.removed ();
190               return;
191             }
192
193           /* TODO: base this upon our ID in permanent storage, once we have that
194            */
195           if (this.id == null && this._personas.data != null)
196             this.id = this._personas.data.iid;
197
198           /* Connect to all the new personas */
199           this._personas.foreach ((p) =>
200             {
201               unowned Persona persona = (Persona) p;
202
203               persona.notify["alias"].connect (this.notify_alias_cb);
204               persona.notify["avatar"].connect (this.notify_avatar_cb);
205               persona.notify["presence-message"].connect (
206                   this.notify_presence_cb);
207               persona.notify["presence-type"].connect (this.notify_presence_cb);
208               persona.notify["is-favourite"].connect (
209                   this.notify_is_favourite_cb);
210               persona.notify["groups"].connect (this.notify_groups_cb);
211
212               if (p is Groups)
213                 {
214                   ((Groups) p).group_changed.connect (
215                       this.persona_group_changed_cb);
216                 }
217             });
218
219           /* Update our aggregated fields and notify the changes */
220           this.update_fields ();
221         }
222     }
223
224   private void notify_groups_cb (Object obj, ParamSpec ps)
225     {
226       this.update_groups ();
227     }
228
229   private void notify_alias_cb (Object obj, ParamSpec ps)
230     {
231       this.update_alias ();
232     }
233
234   private void notify_avatar_cb (Object obj, ParamSpec ps)
235     {
236       this.update_avatar ();
237     }
238
239   private void persona_group_changed_cb (string group, bool is_member)
240     {
241       this.change_group.begin (group, is_member);
242       this.update_groups ();
243     }
244
245   /**
246    * Add or remove the Individual from the specified group.
247    *
248    * If `is_member` is `true`, the Individual will be added to the `group`. If
249    * it is `false`, they will be removed from the `group`.
250    *
251    * The group membership change will propagate to every {@link Persona} in
252    * the Individual.
253    *
254    * @param group a freeform group identifier
255    * @param is_member whether the Individual should be a member of the group
256    */
257   public async void change_group (string group, bool is_member)
258     {
259       this._personas.foreach ((p) =>
260         {
261           if (p is Groups)
262             ((Groups) p).change_group.begin (group, is_member);
263         });
264
265       /* don't notify, since it hasn't happened in the persona backing stores
266        * yet; react to that directly */
267     }
268
269   private void notify_presence_cb (Object obj, ParamSpec ps)
270     {
271       this.update_presence ();
272     }
273
274   private void notify_is_favourite_cb (Object obj, ParamSpec ps)
275     {
276       this.update_is_favourite ();
277     }
278
279   /**
280    * Create a new Individual.
281    *
282    * The Individual can optionally be seeded with the {@link Persona}s in
283    * `personas`. Otherwise, it will have to have personas added using the
284    * {@link Folks.Individual.personas} property after construction.
285    *
286    * @return a new Individual
287    */
288   public Individual (GLib.List<Persona>? personas)
289     {
290       Object (personas: personas);
291
292       this.stores = new HashTable<PersonaStore, HashSet<Persona>> (direct_hash,
293           direct_equal);
294       this.stores_update ();
295     }
296
297   private void stores_update ()
298     {
299       this._personas.foreach ((p) =>
300         {
301           unowned Persona persona = (Persona) p;
302           var store_is_new = false;
303           var persona_set = this.stores.lookup (persona.store);
304           if (persona_set == null)
305             {
306               persona_set = new HashSet<Persona> (direct_hash, direct_equal);
307               store_is_new = true;
308             }
309
310           persona_set.add (persona);
311
312           if (store_is_new)
313             {
314               this.stores.insert (persona.store, persona_set);
315
316               persona.store.removed.connect (this.store_removed_cb);
317               persona.store.personas_changed.connect (
318                 this.store_personas_changed_cb);
319             }
320         });
321     }
322
323   private void store_removed_cb (PersonaStore store)
324     {
325       var persona_set = this.stores.lookup (store);
326       if (persona_set != null)
327         {
328           foreach (var persona in persona_set)
329             {
330               this._personas.remove (persona);
331               /* FIXME: bgo#624249 means GLib.List leaks item references.
332                * We probably eventually want to transition away from GLib.List
333                * and use Gee.LinkedList, but that would mean exposing libgee
334                * in the public API. */
335               g_object_unref (persona);
336             }
337         }
338       if (store != null)
339         this.stores.remove (store);
340
341       if (this._personas.length () < 1 || this.stores.size () < 1)
342         {
343           this.removed ();
344           return;
345         }
346
347       this.update_fields ();
348     }
349
350   private void store_personas_changed_cb (PersonaStore store,
351       GLib.List<Persona>? added,
352       GLib.List<Persona>? removed,
353       string? message,
354       Persona? actor,
355       Groups.ChangeReason reason)
356     {
357       var persona_set = this.stores.lookup (store);
358       removed.foreach ((data) =>
359         {
360           unowned Persona p = (Persona) data;
361
362           if (persona_set.remove (p))
363             {
364               this._personas.remove (p);
365               /* FIXME: bgo#624249 means GLib.List leaks item references */
366               g_object_unref (p);
367             }
368         });
369
370       if (this._personas.length () < 1)
371         {
372           this.removed ();
373           return;
374         }
375
376       this.update_fields ();
377     }
378
379   private void update_fields ()
380     {
381       this.update_groups ();
382       this.update_presence ();
383       this.update_is_favourite ();
384       this.update_avatar ();
385       this.update_alias ();
386     }
387
388   private void update_groups ()
389     {
390       var new_groups = new HashTable<string, bool> (str_hash, str_equal);
391
392       /* this._groups is null during initial construction */
393       if (this._groups == null)
394         this._groups = new HashTable<string, bool> (str_hash, str_equal);
395
396       /* FIXME: this should partition the personas by store (maybe we should
397        * keep that mapping in general in this class), and execute
398        * "groups-changed" on the store (with the set of personas), to allow the
399        * back-end to optimize it (like Telepathy will for MembersChanged for the
400        * groups channel list) */
401       this._personas.foreach ((p) =>
402         {
403           if (p is Groups)
404             {
405               unowned Groups persona = (Groups) p;
406
407               persona.groups.foreach ((k, v) =>
408                 {
409                   new_groups.insert ((string) k, true);
410                 });
411             }
412         });
413
414       new_groups.foreach ((k, v) =>
415         {
416           var group = (string) k;
417           if (this._groups.lookup (group) != true)
418             {
419               this._groups.insert (group, true);
420               this._groups.foreach ((k, v) =>
421                 {
422                   var g = (string) k;
423                   debug ("   %s", g);
424                 });
425
426               this.group_changed (group, true);
427             }
428         });
429
430       /* buffer the removals, so we don't remove while iterating */
431       var removes = new GLib.List<string> ();
432       this._groups.foreach ((k, v) =>
433         {
434           var group = (string) k;
435           if (new_groups.lookup (group) != true)
436             removes.prepend (group);
437         });
438
439       removes.foreach ((l) =>
440         {
441           var group = (string) l;
442           this._groups.remove (group);
443           this.group_changed (group, false);
444         });
445     }
446
447   private void update_presence ()
448     {
449       var presence_message = "";
450       var presence_type = Folks.PresenceType.UNSET;
451
452       /* Choose the most available presence from our personas */
453       this._personas.foreach ((p) =>
454         {
455           if (p is Presence)
456             {
457               unowned Presence presence = (Presence) p;
458
459               if (Presence.typecmp (presence.presence_type, presence_type) > 0)
460                 {
461                   presence_type = presence.presence_type;
462                   presence_message = presence.presence_message;
463                 }
464             }
465         });
466
467       if (presence_message == null)
468         presence_message = "";
469
470       /* only notify if the value has changed */
471       if (this.presence_message != presence_message)
472         this.presence_message = presence_message;
473
474       if (this.presence_type != presence_type)
475         this.presence_type = presence_type;
476     }
477
478   private void update_is_favourite ()
479     {
480       bool favourite = false;
481
482       this._personas.foreach ((p) =>
483         {
484           if (favourite == false && p is Favourite)
485             {
486               favourite = ((Favourite) p).is_favourite;
487               if (favourite == true)
488                 return;
489             }
490         });
491
492       /* Only notify if the value has changed */
493       if (this.is_favourite != favourite)
494         this.is_favourite = favourite;
495     }
496
497   private void update_alias ()
498     {
499       string alias = null;
500
501       this._personas.foreach ((p) =>
502         {
503           if (p is Alias)
504             {
505               unowned Alias a = (Alias) p;
506
507               if (alias == null && a.alias != null && a.alias.strip () != "")
508                 alias = a.alias;
509             }
510         });
511
512       if (alias == null)
513         {
514           /* We have to pick a UID, since none of the personas have an alias
515            * available. Pick the UID from the first persona in the list. */
516           alias = this._personas.data.uid;
517           warning ("No aliases available for individual; using UID instead: %s",
518                    alias);
519         }
520
521       /* only notify if the value has changed */
522       if (this.alias != alias)
523         this.alias = alias;
524     }
525
526   private void update_avatar ()
527     {
528       File avatar = null;
529
530       this._personas.foreach ((p) =>
531         {
532           if (avatar == null && p is Avatar)
533             {
534               avatar = ((Avatar) p).avatar;
535               return;
536             }
537         });
538
539       /* only notify if the value has changed */
540       if (this.avatar != avatar)
541         this.avatar = avatar;
542     }
543
544   /*
545    * GLib/C convenience functions (for built-in casting, etc.)
546    */
547
548   /**
549    * Get the Individual's alias.
550    *
551    * The alias is a user-chosen name for the Individual; how the user wants that
552    * Individual to be represented in UIs.
553    *
554    * @return the Individual's alias
555    */
556   public unowned string get_alias ()
557     {
558       return this.alias;
559     }
560
561   /**
562    * Get a mapping of group ID to whether the Individual is a member.
563    *
564    * Freeform group IDs are mapped to a boolean which is `true` if the
565    * Individual is a member of the group, and `false` otherwise.
566    *
567    * @return a mapping of group ID to membership status
568    */
569   public HashTable<string, bool> get_groups ()
570     {
571       Groups g = this;
572       return g.groups;
573     }
574
575   /**
576    * Get the Individual's current presence message.
577    *
578    * The presence message returned is from the same {@link Persona} which
579    * provided the presence type returned by
580    * {@link Individual.get_presence_type}.
581    *
582    * If none of the {@link Persona}s in the Individual have a presence message
583    * set, an empty string is returned.
584    *
585    * @return the Individual's presence message
586    */
587   public unowned string get_presence_message ()
588     {
589       return this.presence_message;
590     }
591
592   /**
593    * Get the Individual's current presence type.
594    *
595    * The presence type returned is from the same {@link Persona} which provided
596    * the presence message returned by {@link Individual.get_presence_message}.
597    *
598    * If none of the {@link Persona}s in the Individual have a presence type set,
599    * {@link PresenceType.UNSET} is returned.
600    *
601    * @return the Individual's presence type
602    */
603   public Folks.PresenceType get_presence_type ()
604     {
605       return this.presence_type;
606     }
607
608   /**
609    * Whether the Individual is online.
610    *
611    * This will be `true` if any of the Individual's {@link Persona}s have a
612    * presence type higher than {@link PresenceType.OFFLINE}, as determined by
613    * {@link Presence.typecmp}.
614    *
615    * @return `true` if the Individual is online, `false` otherwise
616    */
617   public bool is_online ()
618     {
619       Presence p = this;
620       return p.is_online ();
621     }
622 }