use debug() instead of message()
[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    * @param replacement_individual the individual which has replaced this one
75    * due to linking, or `null` if this individual was removed for another reason
76    */
77   public signal void removed (Individual? replacement_individual);
78
79   /**
80    * {@inheritDoc}
81    */
82   public string alias
83     {
84       get { return this._alias; }
85
86       set
87         {
88           if (this._alias == value)
89             return;
90
91           this._alias = value;
92           this._personas.foreach ((p) =>
93             {
94               if (p is Alias && ((Persona) p).store.is_writeable == true)
95                 ((Alias) p).alias = value;
96             });
97         }
98     }
99
100   /**
101    * Whether this Individual is a user-defined favourite.
102    *
103    * This property is `true` if any of this Individual's {@link Persona}s are
104    * favourites).
105    */
106   public bool is_favourite
107     {
108       get { return this._is_favourite; }
109
110       set
111         {
112           if (this._is_favourite == value)
113             return;
114
115           this._is_favourite = value;
116           this._personas.foreach ((p) =>
117             {
118               if (p is Favourite && ((Persona) p).store.is_writeable == true)
119                 ((Favourite) p).is_favourite = value;
120             });
121         }
122     }
123
124   /**
125    * {@inheritDoc}
126    */
127   public HashTable<string, bool> groups
128     {
129       get { return this._groups; }
130
131       set
132         {
133           this._personas.foreach ((p) =>
134             {
135               if (p is Groups && ((Persona) p).store.is_writeable == true)
136                 ((Groups) p).groups = value;
137             });
138         }
139     }
140
141   /**
142    * The set of {@link Persona}s encapsulated by this Individual.
143    *
144    * Changing the set of personas may cause updates to the aggregated properties
145    * provided by the Individual, resulting in property notifications for them.
146    *
147    * Changing the set of personas will not cause permanent linking/unlinking of
148    * the added/removed personas to/from this Individual. To do that, call
149    * {@link IndividualAggregator.link_personas} or
150    * {@link IndividualAggregator.unlink_individual}, which will ensure the link
151    * changes are written to the appropriate backend.
152    */
153   public GLib.List<Persona> personas
154     {
155       get { return this._personas; }
156       set { this._set_personas (value, null); }
157     }
158
159   private void notify_groups_cb (Object obj, ParamSpec ps)
160     {
161       this.update_groups ();
162     }
163
164   private void notify_alias_cb (Object obj, ParamSpec ps)
165     {
166       this.update_alias ();
167     }
168
169   private void notify_avatar_cb (Object obj, ParamSpec ps)
170     {
171       this.update_avatar ();
172     }
173
174   private void persona_group_changed_cb (string group, bool is_member)
175     {
176       this.change_group.begin (group, is_member);
177       this.update_groups ();
178     }
179
180   /**
181    * Add or remove the Individual from the specified group.
182    *
183    * If `is_member` is `true`, the Individual will be added to the `group`. If
184    * it is `false`, they will be removed from the `group`.
185    *
186    * The group membership change will propagate to every {@link Persona} in
187    * the Individual.
188    *
189    * @param group a freeform group identifier
190    * @param is_member whether the Individual should be a member of the group
191    */
192   public async void change_group (string group, bool is_member)
193     {
194       this._personas.foreach ((p) =>
195         {
196           if (p is Groups)
197             ((Groups) p).change_group.begin (group, is_member);
198         });
199
200       /* don't notify, since it hasn't happened in the persona backing stores
201        * yet; react to that directly */
202     }
203
204   private void notify_presence_cb (Object obj, ParamSpec ps)
205     {
206       this.update_presence ();
207     }
208
209   private void notify_is_favourite_cb (Object obj, ParamSpec ps)
210     {
211       this.update_is_favourite ();
212     }
213
214   /**
215    * Create a new Individual.
216    *
217    * The Individual can optionally be seeded with the {@link Persona}s in
218    * `personas`. Otherwise, it will have to have personas added using the
219    * {@link Folks.Individual.personas} property after construction.
220    *
221    * @return a new Individual
222    */
223   public Individual (GLib.List<Persona>? personas)
224     {
225       Object (personas: personas);
226
227       this.stores = new HashTable<PersonaStore, HashSet<Persona>> (direct_hash,
228           direct_equal);
229       this.stores_update ();
230     }
231
232   private void stores_update ()
233     {
234       this._personas.foreach ((p) =>
235         {
236           unowned Persona persona = (Persona) p;
237           var store_is_new = false;
238           var persona_set = this.stores.lookup (persona.store);
239           if (persona_set == null)
240             {
241               persona_set = new HashSet<Persona> (direct_hash, direct_equal);
242               store_is_new = true;
243             }
244
245           persona_set.add (persona);
246
247           if (store_is_new)
248             {
249               this.stores.insert (persona.store, persona_set);
250
251               persona.store.removed.connect (this.store_removed_cb);
252               persona.store.personas_changed.connect (
253                 this.store_personas_changed_cb);
254             }
255         });
256     }
257
258   private void store_removed_cb (PersonaStore store)
259     {
260       var persona_set = this.stores.lookup (store);
261       if (persona_set != null)
262         {
263           foreach (var persona in persona_set)
264             {
265               this._personas.remove (persona);
266               /* FIXME: bgo#624249 means GLib.List leaks item references.
267                * We probably eventually want to transition away from GLib.List
268                * and use Gee.LinkedList, but that would mean exposing libgee
269                * in the public API. */
270               g_object_unref (persona);
271             }
272         }
273       if (store != null)
274         this.stores.remove (store);
275
276       if (this._personas.length () < 1 || this.stores.size () < 1)
277         {
278           this.removed (null);
279           return;
280         }
281
282       this.update_fields ();
283     }
284
285   private void store_personas_changed_cb (PersonaStore store,
286       GLib.List<Persona>? added,
287       GLib.List<Persona>? removed,
288       string? message,
289       Persona? actor,
290       Groups.ChangeReason reason)
291     {
292       var persona_set = this.stores.lookup (store);
293       removed.foreach ((data) =>
294         {
295           unowned Persona p = (Persona) data;
296
297           if (persona_set.remove (p))
298             {
299               this._personas.remove (p);
300               /* FIXME: bgo#624249 means GLib.List leaks item references */
301               g_object_unref (p);
302             }
303         });
304
305       if (this._personas.length () < 1)
306         {
307           this.removed (null);
308           return;
309         }
310
311       this.update_fields ();
312     }
313
314   private void update_fields ()
315     {
316       this.update_groups ();
317       this.update_presence ();
318       this.update_is_favourite ();
319       this.update_avatar ();
320       this.update_alias ();
321     }
322
323   private void update_groups ()
324     {
325       var new_groups = new HashTable<string, bool> (str_hash, str_equal);
326
327       /* this._groups is null during initial construction */
328       if (this._groups == null)
329         this._groups = new HashTable<string, bool> (str_hash, str_equal);
330
331       /* FIXME: this should partition the personas by store (maybe we should
332        * keep that mapping in general in this class), and execute
333        * "groups-changed" on the store (with the set of personas), to allow the
334        * back-end to optimize it (like Telepathy will for MembersChanged for the
335        * groups channel list) */
336       this._personas.foreach ((p) =>
337         {
338           if (p is Groups)
339             {
340               unowned Groups persona = (Groups) p;
341
342               persona.groups.foreach ((k, v) =>
343                 {
344                   new_groups.insert ((string) k, true);
345                 });
346             }
347         });
348
349       new_groups.foreach ((k, v) =>
350         {
351           var group = (string) k;
352           if (this._groups.lookup (group) != true)
353             {
354               this._groups.insert (group, true);
355               this._groups.foreach ((k, v) =>
356                 {
357                   var g = (string) k;
358                   debug ("   %s", g);
359                 });
360
361               this.group_changed (group, true);
362             }
363         });
364
365       /* buffer the removals, so we don't remove while iterating */
366       var removes = new GLib.List<string> ();
367       this._groups.foreach ((k, v) =>
368         {
369           var group = (string) k;
370           if (new_groups.lookup (group) != true)
371             removes.prepend (group);
372         });
373
374       removes.foreach ((l) =>
375         {
376           var group = (string) l;
377           this._groups.remove (group);
378           this.group_changed (group, false);
379         });
380     }
381
382   private void update_presence ()
383     {
384       var presence_message = "";
385       var presence_type = Folks.PresenceType.UNSET;
386
387       /* Choose the most available presence from our personas */
388       this._personas.foreach ((p) =>
389         {
390           if (p is Presence)
391             {
392               unowned Presence presence = (Presence) p;
393
394               if (Presence.typecmp (presence.presence_type, presence_type) > 0)
395                 {
396                   presence_type = presence.presence_type;
397                   presence_message = presence.presence_message;
398                 }
399             }
400         });
401
402       if (presence_message == null)
403         presence_message = "";
404
405       /* only notify if the value has changed */
406       if (this.presence_message != presence_message)
407         this.presence_message = presence_message;
408
409       if (this.presence_type != presence_type)
410         this.presence_type = presence_type;
411     }
412
413   private void update_is_favourite ()
414     {
415       bool favourite = false;
416
417       this._personas.foreach ((p) =>
418         {
419           if (favourite == false && p is Favourite)
420             {
421               favourite = ((Favourite) p).is_favourite;
422               if (favourite == true)
423                 return;
424             }
425         });
426
427       /* Only notify if the value has changed */
428       if (this.is_favourite != favourite)
429         this.is_favourite = favourite;
430     }
431
432   private void update_alias ()
433     {
434       string alias = null;
435       bool alias_is_display_id = false;
436
437       foreach (Persona p in this._personas)
438         {
439           if (p is Alias)
440             {
441               unowned Alias a = (Alias) p;
442
443               if (a.alias == null || a.alias.strip () == "")
444                 continue;
445
446               if (alias == null || alias_is_display_id == true)
447                 {
448                   /* We prefer to not have an alias which is the same as the
449                    * Persona's display-id, since having such an alias implies
450                    * that it's the default. However, we prefer using such an
451                    * alias to using the Persona's UID, which is our ultimate
452                    * fallback (below). */
453                   alias = a.alias;
454
455                   if (a.alias == p.display_id)
456                     alias_is_display_id = true;
457                   else if (alias != null)
458                     break;
459                 }
460             }
461         }
462
463       if (alias == null)
464         {
465           /* We have to pick a UID, since none of the personas have an alias
466            * available. Pick the UID from the first persona in the list. */
467           alias = this._personas.data.uid;
468           debug ("No aliases available for individual; using UID instead: %s",
469                    alias);
470         }
471
472       /* only notify if the value has changed */
473       if (this.alias != alias)
474         this.alias = alias;
475     }
476
477   private void update_avatar ()
478     {
479       File avatar = null;
480
481       this._personas.foreach ((p) =>
482         {
483           if (avatar == null && p is Avatar)
484             {
485               avatar = ((Avatar) p).avatar;
486               return;
487             }
488         });
489
490       /* only notify if the value has changed */
491       if (this.avatar != avatar)
492         this.avatar = avatar;
493     }
494
495   /*
496    * GLib/C convenience functions (for built-in casting, etc.)
497    */
498
499   /**
500    * Get the Individual's alias.
501    *
502    * The alias is a user-chosen name for the Individual; how the user wants that
503    * Individual to be represented in UIs.
504    *
505    * @return the Individual's alias
506    */
507   public unowned string get_alias ()
508     {
509       return this.alias;
510     }
511
512   /**
513    * Get a mapping of group ID to whether the Individual is a member.
514    *
515    * Freeform group IDs are mapped to a boolean which is `true` if the
516    * Individual is a member of the group, and `false` otherwise.
517    *
518    * @return a mapping of group ID to membership status
519    */
520   public HashTable<string, bool> get_groups ()
521     {
522       Groups g = this;
523       return g.groups;
524     }
525
526   /**
527    * Get the Individual's current presence message.
528    *
529    * The presence message returned is from the same {@link Persona} which
530    * provided the presence type returned by
531    * {@link Individual.get_presence_type}.
532    *
533    * If none of the {@link Persona}s in the Individual have a presence message
534    * set, an empty string is returned.
535    *
536    * @return the Individual's presence message
537    */
538   public unowned string get_presence_message ()
539     {
540       return this.presence_message;
541     }
542
543   /**
544    * Get the Individual's current presence type.
545    *
546    * The presence type returned is from the same {@link Persona} which provided
547    * the presence message returned by {@link Individual.get_presence_message}.
548    *
549    * If none of the {@link Persona}s in the Individual have a presence type set,
550    * {@link PresenceType.UNSET} is returned.
551    *
552    * @return the Individual's presence type
553    */
554   public Folks.PresenceType get_presence_type ()
555     {
556       return this.presence_type;
557     }
558
559   /**
560    * Whether the Individual is online.
561    *
562    * This will be `true` if any of the Individual's {@link Persona}s have a
563    * presence type higher than {@link PresenceType.OFFLINE}, as determined by
564    * {@link Presence.typecmp}.
565    *
566    * @return `true` if the Individual is online, `false` otherwise
567    */
568   public bool is_online ()
569     {
570       Presence p = this;
571       return p.is_online ();
572     }
573
574   private void _set_personas (GLib.List<Persona>? personas,
575       Individual? replacement_individual)
576     {
577       /* Disconnect from all our previous personas */
578       this._personas.foreach ((p) =>
579         {
580           unowned Persona persona = (Persona) p;
581
582           persona.notify["alias"].disconnect (this.notify_alias_cb);
583           persona.notify["avatar"].disconnect (this.notify_avatar_cb);
584           persona.notify["presence-message"].disconnect (
585               this.notify_presence_cb);
586           persona.notify["presence-type"].disconnect (this.notify_presence_cb);
587           persona.notify["is-favourite"].disconnect (
588               this.notify_is_favourite_cb);
589           persona.notify["groups"].disconnect (this.notify_groups_cb);
590
591           if (p is Groups)
592             {
593               ((Groups) p).group_changed.disconnect (
594                   this.persona_group_changed_cb);
595             }
596         });
597
598       this._personas = new GLib.List<Persona> ();
599       personas.foreach ((l) =>
600         {
601           this._personas.prepend ((Persona) l);
602         });
603       this._personas.reverse ();
604
605       /* If all the personas have been removed, remove the individual */
606       if (this._personas.length () < 1)
607         {
608           this.removed (replacement_individual);
609             return;
610         }
611
612       /* TODO: base this upon our ID in permanent storage, once we have that
613        */
614       if (this.id == null && this._personas.data != null)
615         this.id = this._personas.data.uid;
616
617       /* Connect to all the new personas */
618       this._personas.foreach ((p) =>
619         {
620           unowned Persona persona = (Persona) p;
621
622           persona.notify["alias"].connect (this.notify_alias_cb);
623           persona.notify["avatar"].connect (this.notify_avatar_cb);
624           persona.notify["presence-message"].connect (this.notify_presence_cb);
625           persona.notify["presence-type"].connect (this.notify_presence_cb);
626           persona.notify["is-favourite"].connect (this.notify_is_favourite_cb);
627           persona.notify["groups"].connect (this.notify_groups_cb);
628
629           if (p is Groups)
630             {
631               ((Groups) p).group_changed.connect (
632                   this.persona_group_changed_cb);
633             }
634         });
635
636       /* Update our aggregated fields and notify the changes */
637       this.update_fields ();
638     }
639
640   internal void replace (Individual replacement_individual)
641     {
642       this._set_personas (null, replacement_individual);
643     }
644 }