* Getting more of the login outside of the UI thread
[gc-dialer] / src / gc_views.py
1 #!/usr/bin/python2.5
2
3 """
4 DialCentral - Front end for Google's Grand Central service.
5 Copyright (C) 2008  Mark Bergman bergman AT merctech DOT com
6
7 This library is free software; you can redistribute it and/or
8 modify it under the terms of the GNU Lesser General Public
9 License as published by the Free Software Foundation; either
10 version 2.1 of the License, or (at your option) any later version.
11
12 This library is distributed in the hope that it will be useful,
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
15 Lesser General Public License for more details.
16
17 You should have received a copy of the GNU Lesser General Public
18 License along with this library; if not, write to the Free Software
19 Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
20 """
21
22 from __future__ import with_statement
23
24 import threading
25 import warnings
26
27 import gobject
28 import gtk
29
30 import gtk_toolbox
31 import null_backend
32
33
34 def make_ugly(prettynumber):
35         """
36         function to take a phone number and strip out all non-numeric
37         characters
38
39         >>> make_ugly("+012-(345)-678-90")
40         '01234567890'
41         """
42         import re
43         uglynumber = re.sub('\D', '', prettynumber)
44         return uglynumber
45
46
47 def make_pretty(phonenumber):
48         """
49         Function to take a phone number and return the pretty version
50         pretty numbers:
51                 if phonenumber begins with 0:
52                         ...-(...)-...-....
53                 if phonenumber begins with 1: ( for gizmo callback numbers )
54                         1 (...)-...-....
55                 if phonenumber is 13 digits:
56                         (...)-...-....
57                 if phonenumber is 10 digits:
58                         ...-....
59         >>> make_pretty("12")
60         '12'
61         >>> make_pretty("1234567")
62         '123-4567'
63         >>> make_pretty("2345678901")
64         '(234)-567-8901'
65         >>> make_pretty("12345678901")
66         '1 (234)-567-8901'
67         >>> make_pretty("01234567890")
68         '+012-(345)-678-90'
69         """
70         if phonenumber is None or phonenumber is "":
71                 return ""
72
73         phonenumber = make_ugly(phonenumber)
74
75         if len(phonenumber) < 3:
76                 return phonenumber
77
78         if phonenumber[0] == "0":
79                 prettynumber = ""
80                 prettynumber += "+%s" % phonenumber[0:3]
81                 if 3 < len(phonenumber):
82                         prettynumber += "-(%s)" % phonenumber[3:6]
83                         if 6 < len(phonenumber):
84                                 prettynumber += "-%s" % phonenumber[6:9]
85                                 if 9 < len(phonenumber):
86                                         prettynumber += "-%s" % phonenumber[9:]
87                 return prettynumber
88         elif len(phonenumber) <= 7:
89                 prettynumber = "%s-%s" % (phonenumber[0:3], phonenumber[3:])
90         elif len(phonenumber) > 8 and phonenumber[0] == "1":
91                 prettynumber = "1 (%s)-%s-%s" % (phonenumber[1:4], phonenumber[4:7], phonenumber[7:])
92         elif len(phonenumber) > 7:
93                 prettynumber = "(%s)-%s-%s" % (phonenumber[0:3], phonenumber[3:6], phonenumber[6:])
94         return prettynumber
95
96
97 class MergedAddressBook(object):
98         """
99         Merger of all addressbooks
100         """
101
102         def __init__(self, addressbookFactories, sorter = None):
103                 self.__addressbookFactories = addressbookFactories
104                 self.__addressbooks = None
105                 self.__sort_contacts = sorter if sorter is not None else self.null_sorter
106
107         def clear_caches(self):
108                 self.__addressbooks = None
109                 for factory in self.__addressbookFactories:
110                         factory.clear_caches()
111
112         def get_addressbooks(self):
113                 """
114                 @returns Iterable of (Address Book Factory, Book Id, Book Name)
115                 """
116                 yield self, "", ""
117
118         def open_addressbook(self, bookId):
119                 return self
120
121         def contact_source_short_name(self, contactId):
122                 if self.__addressbooks is None:
123                         return ""
124                 bookIndex, originalId = contactId.split("-", 1)
125                 return self.__addressbooks[int(bookIndex)].contact_source_short_name(originalId)
126
127         @staticmethod
128         def factory_name():
129                 return "All Contacts"
130
131         def get_contacts(self):
132                 """
133                 @returns Iterable of (contact id, contact name)
134                 """
135                 if self.__addressbooks is None:
136                         self.__addressbooks = list(
137                                 factory.open_addressbook(id)
138                                 for factory in self.__addressbookFactories
139                                 for (f, id, name) in factory.get_addressbooks()
140                         )
141                 contacts = (
142                         ("-".join([str(bookIndex), contactId]), contactName)
143                                 for (bookIndex, addressbook) in enumerate(self.__addressbooks)
144                                         for (contactId, contactName) in addressbook.get_contacts()
145                 )
146                 sortedContacts = self.__sort_contacts(contacts)
147                 return sortedContacts
148
149         def get_contact_details(self, contactId):
150                 """
151                 @returns Iterable of (Phone Type, Phone Number)
152                 """
153                 if self.__addressbooks is None:
154                         return []
155                 bookIndex, originalId = contactId.split("-", 1)
156                 return self.__addressbooks[int(bookIndex)].get_contact_details(originalId)
157
158         @staticmethod
159         def null_sorter(contacts):
160                 """
161                 Good for speed/low memory
162                 """
163                 return contacts
164
165         @staticmethod
166         def basic_firtname_sorter(contacts):
167                 """
168                 Expects names in "First Last" format
169                 """
170                 contactsWithKey = [
171                         (contactName.rsplit(" ", 1)[0], (contactId, contactName))
172                                 for (contactId, contactName) in contacts
173                 ]
174                 contactsWithKey.sort()
175                 return (contactData for (lastName, contactData) in contactsWithKey)
176
177         @staticmethod
178         def basic_lastname_sorter(contacts):
179                 """
180                 Expects names in "First Last" format
181                 """
182                 contactsWithKey = [
183                         (contactName.rsplit(" ", 1)[-1], (contactId, contactName))
184                                 for (contactId, contactName) in contacts
185                 ]
186                 contactsWithKey.sort()
187                 return (contactData for (lastName, contactData) in contactsWithKey)
188
189         @staticmethod
190         def reversed_firtname_sorter(contacts):
191                 """
192                 Expects names in "Last, First" format
193                 """
194                 contactsWithKey = [
195                         (contactName.split(", ", 1)[-1], (contactId, contactName))
196                                 for (contactId, contactName) in contacts
197                 ]
198                 contactsWithKey.sort()
199                 return (contactData for (lastName, contactData) in contactsWithKey)
200
201         @staticmethod
202         def reversed_lastname_sorter(contacts):
203                 """
204                 Expects names in "Last, First" format
205                 """
206                 contactsWithKey = [
207                         (contactName.split(", ", 1)[0], (contactId, contactName))
208                                 for (contactId, contactName) in contacts
209                 ]
210                 contactsWithKey.sort()
211                 return (contactData for (lastName, contactData) in contactsWithKey)
212
213         @staticmethod
214         def guess_firstname(name):
215                 if ", " in name:
216                         return name.split(", ", 1)[-1]
217                 else:
218                         return name.rsplit(" ", 1)[0]
219
220         @staticmethod
221         def guess_lastname(name):
222                 if ", " in name:
223                         return name.split(", ", 1)[0]
224                 else:
225                         return name.rsplit(" ", 1)[-1]
226
227         @classmethod
228         def advanced_firstname_sorter(cls, contacts):
229                 contactsWithKey = [
230                         (cls.guess_firstname(contactName), (contactId, contactName))
231                                 for (contactId, contactName) in contacts
232                 ]
233                 contactsWithKey.sort()
234                 return (contactData for (lastName, contactData) in contactsWithKey)
235
236         @classmethod
237         def advanced_lastname_sorter(cls, contacts):
238                 contactsWithKey = [
239                         (cls.guess_lastname(contactName), (contactId, contactName))
240                                 for (contactId, contactName) in contacts
241                 ]
242                 contactsWithKey.sort()
243                 return (contactData for (lastName, contactData) in contactsWithKey)
244
245
246 class PhoneTypeSelector(object):
247
248         ACTION_CANCEL = "cancel"
249         ACTION_SELECT = "select"
250         ACTION_DIAL = "dial"
251         ACTION_SEND_SMS = "sms"
252
253         def __init__(self, widgetTree, gcBackend):
254                 self._gcBackend = gcBackend
255                 self._widgetTree = widgetTree
256
257                 self._dialog = self._widgetTree.get_widget("phonetype_dialog")
258                 self._smsDialog = SmsEntryDialog(self._widgetTree, self._gcBackend)
259
260                 self._smsButton = self._widgetTree.get_widget("sms_button")
261                 self._smsButton.connect("clicked", self._on_phonetype_send_sms)
262
263                 self._dialButton = self._widgetTree.get_widget("dial_button")
264                 self._dialButton.connect("clicked", self._on_phonetype_dial)
265
266                 self._selectButton = self._widgetTree.get_widget("select_button")
267                 self._selectButton.connect("clicked", self._on_phonetype_select)
268
269                 self._cancelButton = self._widgetTree.get_widget("cancel_button")
270                 self._cancelButton.connect("clicked", self._on_phonetype_cancel)
271
272                 self._typemodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING)
273                 self._typeviewselection = None
274
275                 self._message = self._widgetTree.get_widget("phoneSelectionMessage")
276                 self._typeview = self._widgetTree.get_widget("phonetypes")
277                 self._typeview.connect("row-activated", self._on_phonetype_select)
278
279                 self._action = self.ACTION_CANCEL
280
281         def run(self, contactDetails, message = "", parent = None):
282                 self._action = self.ACTION_CANCEL
283                 self._typemodel.clear()
284                 self._typeview.set_model(self._typemodel)
285
286                 # Add the column to the treeview
287                 textrenderer = gtk.CellRendererText()
288                 numberColumn = gtk.TreeViewColumn("Phone Numbers", textrenderer, text=0)
289                 self._typeview.append_column(numberColumn)
290
291                 textrenderer = gtk.CellRendererText()
292                 typeColumn = gtk.TreeViewColumn("Phone Type", textrenderer, text=1)
293                 self._typeview.append_column(typeColumn)
294
295                 self._typeviewselection = self._typeview.get_selection()
296                 self._typeviewselection.set_mode(gtk.SELECTION_SINGLE)
297
298                 for phoneType, phoneNumber in contactDetails:
299                         display = " - ".join((phoneNumber, phoneType))
300                         display = phoneType
301                         row = (phoneNumber, display)
302                         self._typemodel.append(row)
303
304                 self._typeviewselection.select_iter(self._typemodel.get_iter_first())
305                 if message:
306                         self._message.set_markup(message)
307                         self._message.show()
308                 else:
309                         self._message.set_markup("")
310                         self._message.hide()
311
312                 if parent is not None:
313                         self._dialog.set_transient_for(parent)
314
315                 try:
316                         userResponse = self._dialog.run()
317                 finally:
318                         self._dialog.hide()
319
320                 if userResponse == gtk.RESPONSE_OK:
321                         phoneNumber = self._get_number()
322                         phoneNumber = make_ugly(phoneNumber)
323                 else:
324                         phoneNumber = ""
325                 if not phoneNumber:
326                         self._action = self.ACTION_CANCEL
327
328                 if self._action == self.ACTION_SEND_SMS:
329                         smsMessage = self._smsDialog.run(phoneNumber, message, parent)
330                         if not smsMessage:
331                                 phoneNumber = ""
332                                 self._action = self.ACTION_CANCEL
333                 else:
334                         smsMessage = ""
335
336                 self._typeviewselection.unselect_all()
337                 self._typeview.remove_column(numberColumn)
338                 self._typeview.remove_column(typeColumn)
339                 self._typeview.set_model(None)
340
341                 return self._action, phoneNumber, smsMessage
342
343         def _get_number(self):
344                 model, itr = self._typeviewselection.get_selected()
345                 if not itr:
346                         return ""
347
348                 phoneNumber = self._typemodel.get_value(itr, 0)
349                 return phoneNumber
350
351         def _on_phonetype_dial(self, *args):
352                 self._dialog.response(gtk.RESPONSE_OK)
353                 self._action = self.ACTION_DIAL
354
355         def _on_phonetype_send_sms(self, *args):
356                 self._dialog.response(gtk.RESPONSE_OK)
357                 self._action = self.ACTION_SEND_SMS
358
359         def _on_phonetype_select(self, *args):
360                 self._dialog.response(gtk.RESPONSE_OK)
361                 self._action = self.ACTION_SELECT
362
363         def _on_phonetype_cancel(self, *args):
364                 self._dialog.response(gtk.RESPONSE_CANCEL)
365                 self._action = self.ACTION_CANCEL
366
367
368 class SmsEntryDialog(object):
369
370         MAX_CHAR = 160
371
372         def __init__(self, widgetTree, gcBackend):
373                 self._gcBackend = gcBackend
374                 self._widgetTree = widgetTree
375                 self._dialog = self._widgetTree.get_widget("smsDialog")
376
377                 self._smsButton = self._widgetTree.get_widget("sendSmsButton")
378                 self._smsButton.connect("clicked", self._on_send)
379
380                 self._cancelButton = self._widgetTree.get_widget("cancelSmsButton")
381                 self._cancelButton.connect("clicked", self._on_cancel)
382
383                 self._letterCountLabel = self._widgetTree.get_widget("smsLetterCount")
384                 self._message = self._widgetTree.get_widget("smsMessage")
385                 self._smsEntry = self._widgetTree.get_widget("smsEntry")
386                 self._smsEntry.get_buffer().connect("changed", self._on_entry_changed)
387
388         def run(self, number, message = "", parent = None):
389                 if message:
390                         self._message.set_markup(message)
391                         self._message.show()
392                 else:
393                         self._message.set_markup("")
394                         self._message.hide()
395                 self._smsEntry.get_buffer().set_text("")
396                 self._update_letter_count()
397
398                 if parent is not None:
399                         self._dialog.set_transient_for(parent)
400
401                 try:
402                         userResponse = self._dialog.run()
403                 finally:
404                         self._dialog.hide()
405
406                 if userResponse == gtk.RESPONSE_OK:
407                         entryBuffer = self._smsEntry.get_buffer()
408                         enteredMessage = entryBuffer.get_text(entryBuffer.get_start_iter(), entryBuffer.get_end_iter())
409                         enteredMessage = enteredMessage[0:self.MAX_CHAR]
410                 else:
411                         enteredMessage = ""
412
413                 return enteredMessage
414
415         def _update_letter_count(self, *args):
416                 entryLength = self._smsEntry.get_buffer().get_char_count()
417                 charsLeft = self.MAX_CHAR - entryLength
418                 self._letterCountLabel.set_text(str(charsLeft))
419                 if charsLeft < 0:
420                         self._smsButton.set_sensitive(False)
421                 else:
422                         self._smsButton.set_sensitive(True)
423
424         def _on_entry_changed(self, *args):
425                 self._update_letter_count()
426
427         def _on_send(self, *args):
428                 self._dialog.response(gtk.RESPONSE_OK)
429
430         def _on_cancel(self, *args):
431                 self._dialog.response(gtk.RESPONSE_CANCEL)
432
433
434 class Dialpad(object):
435
436         def __init__(self, widgetTree, errorDisplay):
437                 self._errorDisplay = errorDisplay
438                 self._numberdisplay = widgetTree.get_widget("numberdisplay")
439                 self._dialButton = widgetTree.get_widget("dial")
440                 self._phonenumber = ""
441                 self._prettynumber = ""
442                 self._clearall_id = None
443
444                 callbackMapping = {
445                         "on_dial_clicked": self._on_dial_clicked,
446                         "on_digit_clicked": self._on_digit_clicked,
447                         "on_clear_number": self._on_clear_number,
448                         "on_back_clicked": self._on_backspace,
449                         "on_back_pressed": self._on_back_pressed,
450                         "on_back_released": self._on_back_released,
451                 }
452                 widgetTree.signal_autoconnect(callbackMapping)
453
454         def enable(self):
455                 self._dialButton.grab_focus()
456
457         def disable(self):
458                 pass
459
460         def number_selected(self, action, number, message):
461                 """
462                 @note Actual dial function is patched in later
463                 """
464                 raise NotImplementedError
465
466         def get_number(self):
467                 return self._phonenumber
468
469         def set_number(self, number):
470                 """
471                 Set the callback phonenumber
472                 """
473                 try:
474                         self._phonenumber = make_ugly(number)
475                         self._prettynumber = make_pretty(self._phonenumber)
476                         self._numberdisplay.set_label("<span size='30000' weight='bold'>%s</span>" % (self._prettynumber))
477                 except TypeError, e:
478                         self._errorDisplay.push_exception(e)
479
480         def clear(self):
481                 self.set_number("")
482
483         @staticmethod
484         def name():
485                 return "Dialpad"
486
487         def load_settings(self, config, section):
488                 pass
489
490         def save_settings(self, config, section):
491                 """
492                 @note Thread Agnostic
493                 """
494                 pass
495
496         def _on_dial_clicked(self, widget):
497                 action = PhoneTypeSelector.ACTION_DIAL
498                 phoneNumber = self.get_number()
499                 message = ""
500                 self.number_selected(action, phoneNumber, message)
501
502         def _on_clear_number(self, *args):
503                 self.clear()
504
505         def _on_digit_clicked(self, widget):
506                 self.set_number(self._phonenumber + widget.get_name()[-1])
507
508         def _on_backspace(self, widget):
509                 self.set_number(self._phonenumber[:-1])
510
511         def _on_clearall(self):
512                 self.clear()
513                 return False
514
515         def _on_back_pressed(self, widget):
516                 self._clearall_id = gobject.timeout_add(1000, self._on_clearall)
517
518         def _on_back_released(self, widget):
519                 if self._clearall_id is not None:
520                         gobject.source_remove(self._clearall_id)
521                 self._clearall_id = None
522
523
524 class AccountInfo(object):
525
526         def __init__(self, widgetTree, backend, errorDisplay):
527                 self._errorDisplay = errorDisplay
528                 self._backend = backend
529                 self._isPopulated = False
530
531                 self._callbackList = gtk.ListStore(gobject.TYPE_STRING)
532                 self._accountViewNumberDisplay = widgetTree.get_widget("gcnumber_display")
533                 self._callbackCombo = widgetTree.get_widget("callbackcombo")
534                 self._onCallbackentryChangedId = 0
535
536                 self._defaultCallback = ""
537
538         def enable(self):
539                 assert self._backend.is_authed()
540                 self._accountViewNumberDisplay.set_use_markup(True)
541                 self.set_account_number("")
542                 self._callbackList.clear()
543                 self._onCallbackentryChangedId = self._callbackCombo.get_child().connect("changed", self._on_callbackentry_changed)
544                 self.update(force=True)
545
546         def disable(self):
547                 self._callbackCombo.get_child().disconnect(self._onCallbackentryChangedId)
548
549                 self.clear()
550
551                 self._callbackList.clear()
552
553         def get_selected_callback_number(self):
554                 return make_ugly(self._callbackCombo.get_child().get_text())
555
556         def set_account_number(self, number):
557                 """
558                 Displays current account number
559                 """
560                 self._accountViewNumberDisplay.set_label("<span size='23000' weight='bold'>%s</span>" % (number))
561
562         def update(self, force = False):
563                 if not force and self._isPopulated:
564                         return
565                 self._populate_callback_combo()
566                 self.set_account_number(self._backend.get_account_number())
567
568         def clear(self):
569                 self._callbackCombo.get_child().set_text("")
570                 self.set_account_number("")
571                 self._isPopulated = False
572
573         @staticmethod
574         def name():
575                 return "Account Info"
576
577         def load_settings(self, config, section):
578                 self._defaultCallback = config.get(section, "callback")
579
580         def save_settings(self, config, section):
581                 """
582                 @note Thread Agnostic
583                 """
584                 callback = self.get_selected_callback_number()
585                 config.set(section, "callback", callback)
586
587         def _populate_callback_combo(self):
588                 self._isPopulated = True
589                 self._callbackList.clear()
590                 try:
591                         callbackNumbers = self._backend.get_callback_numbers()
592                 except RuntimeError, e:
593                         self._errorDisplay.push_exception(e)
594                         self._isPopulated = False
595                         return
596
597                 for number, description in callbackNumbers.iteritems():
598                         self._callbackList.append((make_pretty(number),))
599
600                 self._callbackCombo.set_model(self._callbackList)
601                 self._callbackCombo.set_text_column(0)
602                 #callbackNumber = self._backend.get_callback_number()
603                 callbackNumber = self._defaultCallback
604                 self._callbackCombo.get_child().set_text(make_pretty(callbackNumber))
605
606         def _set_callback_number(self, number):
607                 """
608                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
609                 """
610                 try:
611                         if not self._backend.is_valid_syntax(number):
612                                 self._errorDisplay.push_message("%s is not a valid callback number" % number)
613                         elif number == self._backend.get_callback_number():
614                                 warnings.warn("Callback number already is %s" % self._backend.get_callback_number(), UserWarning, 2)
615                         else:
616                                 self._backend.set_callback_number(number)
617                                 warnings.warn("Callback number set to %s" % self._backend.get_callback_number(), UserWarning, 2)
618                 except RuntimeError, e:
619                         self._errorDisplay.push_exception(e)
620
621         def _on_callbackentry_changed(self, *args):
622                 """
623                 @todo Potential blocking on web access, maybe we should defer this or put up a dialog?
624                 """
625                 text = self.get_selected_callback_number()
626                 self._set_callback_number(text)
627
628
629 class RecentCallsView(object):
630
631         NUMBER_IDX = 0
632         DATE_IDX = 1
633         ACTION_IDX = 2
634         FROM_IDX = 3
635
636         def __init__(self, widgetTree, backend, errorDisplay):
637                 self._errorDisplay = errorDisplay
638                 self._backend = backend
639
640                 self._isPopulated = False
641                 self._recentmodel = gtk.ListStore(
642                         gobject.TYPE_STRING, # number
643                         gobject.TYPE_STRING, # date
644                         gobject.TYPE_STRING, # action
645                         gobject.TYPE_STRING, # from
646                 )
647                 self._recentview = widgetTree.get_widget("recentview")
648                 self._recentviewselection = None
649                 self._onRecentviewRowActivatedId = 0
650
651                 textrenderer = gtk.CellRendererText()
652                 textrenderer.set_property("yalign", 0)
653                 self._dateColumn = gtk.TreeViewColumn("Date")
654                 self._dateColumn.pack_start(textrenderer, expand=True)
655                 self._dateColumn.add_attribute(textrenderer, "text", self.DATE_IDX)
656
657                 textrenderer = gtk.CellRendererText()
658                 textrenderer.set_property("yalign", 0)
659                 self._actionColumn = gtk.TreeViewColumn("Action")
660                 self._actionColumn.pack_start(textrenderer, expand=True)
661                 self._actionColumn.add_attribute(textrenderer, "text", self.ACTION_IDX)
662
663                 textrenderer = gtk.CellRendererText()
664                 textrenderer.set_property("yalign", 0)
665                 self._fromColumn = gtk.TreeViewColumn("From")
666                 self._fromColumn.pack_start(textrenderer, expand=True)
667                 self._fromColumn.add_attribute(textrenderer, "text", self.FROM_IDX)
668                 self._fromColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
669
670                 self._window = gtk_toolbox.find_parent_window(self._recentview)
671                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
672
673         def enable(self):
674                 assert self._backend.is_authed()
675                 self._recentview.set_model(self._recentmodel)
676
677                 self._recentview.append_column(self._dateColumn)
678                 self._recentview.append_column(self._actionColumn)
679                 self._recentview.append_column(self._fromColumn)
680                 self._recentviewselection = self._recentview.get_selection()
681                 self._recentviewselection.set_mode(gtk.SELECTION_SINGLE)
682
683                 self._onRecentviewRowActivatedId = self._recentview.connect("row-activated", self._on_recentview_row_activated)
684
685         def disable(self):
686                 self._recentview.disconnect(self._onRecentviewRowActivatedId)
687
688                 self.clear()
689
690                 self._recentview.remove_column(self._dateColumn)
691                 self._recentview.remove_column(self._actionColumn)
692                 self._recentview.remove_column(self._fromColumn)
693                 self._recentview.set_model(None)
694
695         def number_selected(self, action, number, message):
696                 """
697                 @note Actual dial function is patched in later
698                 """
699                 raise NotImplementedError
700
701         def update(self, force = False):
702                 if not force and self._isPopulated:
703                         return
704                 backgroundPopulate = threading.Thread(target=self._idly_populate_recentview)
705                 backgroundPopulate.setDaemon(True)
706                 backgroundPopulate.start()
707
708         def clear(self):
709                 self._isPopulated = False
710                 self._recentmodel.clear()
711
712         @staticmethod
713         def name():
714                 return "Recent Calls"
715
716         def load_settings(self, config, section):
717                 pass
718
719         def save_settings(self, config, section):
720                 """
721                 @note Thread Agnostic
722                 """
723                 pass
724
725         def _idly_populate_recentview(self):
726                 self._isPopulated = True
727                 self._recentmodel.clear()
728
729                 try:
730                         recentItems = self._backend.get_recent()
731                 except RuntimeError, e:
732                         self._errorDisplay.push_exception_with_lock(e)
733                         self._isPopulated = False
734                         recentItems = []
735
736                 for personName, phoneNumber, date, action in recentItems:
737                         if not personName:
738                                 personName = "Unknown"
739                         description = "%s (%s)" % (phoneNumber, personName)
740                         item = (phoneNumber, date, action.capitalize(), description)
741                         with gtk_toolbox.gtk_lock():
742                                 self._recentmodel.append(item)
743
744                 return False
745
746         def _on_recentview_row_activated(self, treeview, path, view_column):
747                 model, itr = self._recentviewselection.get_selected()
748                 if not itr:
749                         return
750
751                 number = self._recentmodel.get_value(itr, self.NUMBER_IDX)
752                 number = make_ugly(number)
753                 contactPhoneNumbers = [("Phone", number)]
754                 description = self._recentmodel.get_value(itr, self.FROM_IDX)
755
756                 action, phoneNumber, message = self._phoneTypeSelector.run(
757                         contactPhoneNumbers,
758                         message = description,
759                         parent = self._window,
760                 )
761                 if action == PhoneTypeSelector.ACTION_CANCEL:
762                         return
763                 assert phoneNumber
764
765                 self.number_selected(action, phoneNumber, message)
766                 self._recentviewselection.unselect_all()
767
768
769 class MessagesView(object):
770
771         NUMBER_IDX = 0
772         DATE_IDX = 1
773         HEADER_IDX = 2
774         MESSAGE_IDX = 3
775
776         def __init__(self, widgetTree, backend, errorDisplay):
777                 self._errorDisplay = errorDisplay
778                 self._backend = backend
779
780                 self._isPopulated = False
781                 self._messagemodel = gtk.ListStore(
782                         gobject.TYPE_STRING, # number
783                         gobject.TYPE_STRING, # date
784                         gobject.TYPE_STRING, # header
785                         gobject.TYPE_STRING, # message
786                 )
787                 self._messageview = widgetTree.get_widget("messages_view")
788                 self._messageviewselection = None
789                 self._onMessageviewRowActivatedId = 0
790
791                 textrenderer = gtk.CellRendererText()
792                 textrenderer.set_property("yalign", 0)
793                 self._dateColumn = gtk.TreeViewColumn("Date")
794                 self._dateColumn.pack_start(textrenderer, expand=True)
795                 self._dateColumn.add_attribute(textrenderer, "markup", self.DATE_IDX)
796
797                 textrenderer = gtk.CellRendererText()
798                 textrenderer.set_property("yalign", 0)
799                 self._headerColumn = gtk.TreeViewColumn("From")
800                 self._headerColumn.pack_start(textrenderer, expand=True)
801                 self._headerColumn.add_attribute(textrenderer, "markup", self.HEADER_IDX)
802
803                 textrenderer = gtk.CellRendererText()
804                 textrenderer.set_property("yalign", 0)
805                 self._messageColumn = gtk.TreeViewColumn("Messages")
806                 self._messageColumn.pack_start(textrenderer, expand=True)
807                 self._messageColumn.add_attribute(textrenderer, "markup", self.MESSAGE_IDX)
808                 self._messageColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
809
810                 self._window = gtk_toolbox.find_parent_window(self._messageview)
811                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
812
813         def enable(self):
814                 assert self._backend.is_authed()
815                 self._messageview.set_model(self._messagemodel)
816
817                 self._messageview.append_column(self._dateColumn)
818                 self._messageview.append_column(self._headerColumn)
819                 self._messageview.append_column(self._messageColumn)
820                 self._messageviewselection = self._messageview.get_selection()
821                 self._messageviewselection.set_mode(gtk.SELECTION_SINGLE)
822
823                 self._onMessageviewRowActivatedId = self._messageview.connect("row-activated", self._on_messageview_row_activated)
824
825         def disable(self):
826                 self._messageview.disconnect(self._onMessageviewRowActivatedId)
827
828                 self.clear()
829
830                 self._messageview.remove_column(self._dateColumn)
831                 self._messageview.remove_column(self._headerColumn)
832                 self._messageview.remove_column(self._messageColumn)
833                 self._messageview.set_model(None)
834
835         def number_selected(self, action, number, message):
836                 """
837                 @note Actual dial function is patched in later
838                 """
839                 raise NotImplementedError
840
841         def update(self, force = False):
842                 if not force and self._isPopulated:
843                         return
844                 backgroundPopulate = threading.Thread(target=self._idly_populate_messageview)
845                 backgroundPopulate.setDaemon(True)
846                 backgroundPopulate.start()
847
848         def clear(self):
849                 self._isPopulated = False
850                 self._messagemodel.clear()
851
852         @staticmethod
853         def name():
854                 return "Messages"
855
856         def load_settings(self, config, section):
857                 pass
858
859         def save_settings(self, config, section):
860                 """
861                 @note Thread Agnostic
862                 """
863                 pass
864
865         def _idly_populate_messageview(self):
866                 self._isPopulated = True
867                 self._messagemodel.clear()
868
869                 try:
870                         messageItems = self._backend.get_messages()
871                 except RuntimeError, e:
872                         self._errorDisplay.push_exception_with_lock(e)
873                         self._isPopulated = False
874                         messageItems = []
875
876                 for header, number, relativeDate, message in messageItems:
877                         number = make_ugly(number)
878                         row = (number, relativeDate, header, message)
879                         with gtk_toolbox.gtk_lock():
880                                 self._messagemodel.append(row)
881
882                 return False
883
884         def _on_messageview_row_activated(self, treeview, path, view_column):
885                 model, itr = self._messageviewselection.get_selected()
886                 if not itr:
887                         return
888
889                 contactPhoneNumbers = [("Phone", self._messagemodel.get_value(itr, self.NUMBER_IDX))]
890                 description = self._messagemodel.get_value(itr, self.MESSAGE_IDX)
891
892                 action, phoneNumber, message = self._phoneTypeSelector.run(
893                         contactPhoneNumbers,
894                         message = description,
895                         parent = self._window,
896                 )
897                 if action == PhoneTypeSelector.ACTION_CANCEL:
898                         return
899                 assert phoneNumber
900
901                 self.number_selected(action, phoneNumber, message)
902                 self._messageviewselection.unselect_all()
903
904
905 class ContactsView(object):
906
907         def __init__(self, widgetTree, backend, errorDisplay):
908                 self._errorDisplay = errorDisplay
909                 self._backend = backend
910
911                 self._addressBook = None
912                 self._addressBookFactories = [null_backend.NullAddressBook()]
913
914                 self._booksList = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
915                 self._booksSelectionBox = widgetTree.get_widget("addressbook_combo")
916
917                 self._isPopulated = False
918                 self._contactsmodel = gtk.ListStore(gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING)
919                 self._contactsviewselection = None
920                 self._contactsview = widgetTree.get_widget("contactsview")
921
922                 self._contactColumn = gtk.TreeViewColumn("Contact")
923                 displayContactSource = False
924                 if displayContactSource:
925                         textrenderer = gtk.CellRendererText()
926                         self._contactColumn.pack_start(textrenderer, expand=False)
927                         self._contactColumn.add_attribute(textrenderer, 'text', 0)
928                 textrenderer = gtk.CellRendererText()
929                 self._contactColumn.pack_start(textrenderer, expand=True)
930                 self._contactColumn.add_attribute(textrenderer, 'text', 1)
931                 textrenderer = gtk.CellRendererText()
932                 self._contactColumn.pack_start(textrenderer, expand=True)
933                 self._contactColumn.add_attribute(textrenderer, 'text', 4)
934                 self._contactColumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
935                 self._contactColumn.set_sort_column_id(1)
936                 self._contactColumn.set_visible(True)
937
938                 self._onContactsviewRowActivatedId = 0
939                 self._onAddressbookComboChangedId = 0
940                 self._window = gtk_toolbox.find_parent_window(self._contactsview)
941                 self._phoneTypeSelector = PhoneTypeSelector(widgetTree, self._backend)
942
943         def enable(self):
944                 assert self._backend.is_authed()
945
946                 self._contactsview.set_model(self._contactsmodel)
947                 self._contactsview.append_column(self._contactColumn)
948                 self._contactsviewselection = self._contactsview.get_selection()
949                 self._contactsviewselection.set_mode(gtk.SELECTION_SINGLE)
950
951                 self._booksList.clear()
952                 for (factoryId, bookId), (factoryName, bookName) in self.get_addressbooks():
953                         if factoryName and bookName:
954                                 entryName = "%s: %s" % (factoryName, bookName)
955                         elif factoryName:
956                                 entryName = factoryName
957                         elif bookName:
958                                 entryName = bookName
959                         else:
960                                 entryName = "Bad name (%d)" % factoryId
961                         row = (str(factoryId), bookId, entryName)
962                         self._booksList.append(row)
963
964                 self._booksSelectionBox.set_model(self._booksList)
965                 cell = gtk.CellRendererText()
966                 self._booksSelectionBox.pack_start(cell, True)
967                 self._booksSelectionBox.add_attribute(cell, 'text', 2)
968                 self._booksSelectionBox.set_active(0)
969
970                 self._onContactsviewRowActivatedId = self._contactsview.connect("row-activated", self._on_contactsview_row_activated)
971                 self._onAddressbookComboChangedId = self._booksSelectionBox.connect("changed", self._on_addressbook_combo_changed)
972
973         def disable(self):
974                 self._contactsview.disconnect(self._onContactsviewRowActivatedId)
975                 self._booksSelectionBox.disconnect(self._onAddressbookComboChangedId)
976
977                 self.clear()
978
979                 self._booksSelectionBox.clear()
980                 self._booksSelectionBox.set_model(None)
981                 self._contactsview.set_model(None)
982                 self._contactsview.remove_column(self._contactColumn)
983
984         def number_selected(self, action, number, message):
985                 """
986                 @note Actual dial function is patched in later
987                 """
988                 raise NotImplementedError
989
990         def get_addressbooks(self):
991                 """
992                 @returns Iterable of ((Factory Id, Book Id), (Factory Name, Book Name))
993                 """
994                 for i, factory in enumerate(self._addressBookFactories):
995                         for bookFactory, bookId, bookName in factory.get_addressbooks():
996                                 yield (i, bookId), (factory.factory_name(), bookName)
997
998         def open_addressbook(self, bookFactoryId, bookId):
999                 self._addressBook = self._addressBookFactories[bookFactoryId].open_addressbook(bookId)
1000                 self.update(force=True)
1001
1002         def update(self, force = False):
1003                 if not force and self._isPopulated:
1004                         return
1005                 backgroundPopulate = threading.Thread(target=self._idly_populate_contactsview)
1006                 backgroundPopulate.setDaemon(True)
1007                 backgroundPopulate.start()
1008
1009         def clear(self):
1010                 self._isPopulated = False
1011                 self._contactsmodel.clear()
1012
1013         def clear_caches(self):
1014                 for factory in self._addressBookFactories:
1015                         factory.clear_caches()
1016                 self._addressBook.clear_caches()
1017
1018         def append(self, book):
1019                 self._addressBookFactories.append(book)
1020
1021         def extend(self, books):
1022                 self._addressBookFactories.extend(books)
1023
1024         @staticmethod
1025         def name():
1026                 return "Contacts"
1027
1028         def load_settings(self, config, section):
1029                 pass
1030
1031         def save_settings(self, config, section):
1032                 """
1033                 @note Thread Agnostic
1034                 """
1035                 pass
1036
1037         def _idly_populate_contactsview(self):
1038                 self._isPopulated = True
1039                 self.clear()
1040
1041                 # completely disable updating the treeview while we populate the data
1042                 self._contactsview.freeze_child_notify()
1043                 self._contactsview.set_model(None)
1044
1045                 addressBook = self._addressBook
1046                 try:
1047                         contacts = addressBook.get_contacts()
1048                 except RuntimeError, e:
1049                         contacts = []
1050                         self._isPopulated = False
1051                         self._errorDisplay.push_exception_with_lock(e)
1052                 for contactId, contactName in contacts:
1053                         contactType = (addressBook.contact_source_short_name(contactId), )
1054                         self._contactsmodel.append(contactType + (contactName, "", contactId) + ("", ))
1055
1056                 # restart the treeview data rendering
1057                 self._contactsview.set_model(self._contactsmodel)
1058                 self._contactsview.thaw_child_notify()
1059                 return False
1060
1061         def _on_addressbook_combo_changed(self, *args, **kwds):
1062                 itr = self._booksSelectionBox.get_active_iter()
1063                 if itr is None:
1064                         return
1065                 factoryId = int(self._booksList.get_value(itr, 0))
1066                 bookId = self._booksList.get_value(itr, 1)
1067                 self.open_addressbook(factoryId, bookId)
1068
1069         def _on_contactsview_row_activated(self, treeview, path, view_column):
1070                 model, itr = self._contactsviewselection.get_selected()
1071                 if not itr:
1072                         return
1073
1074                 contactId = self._contactsmodel.get_value(itr, 3)
1075                 contactName = self._contactsmodel.get_value(itr, 1)
1076                 try:
1077                         contactDetails = self._addressBook.get_contact_details(contactId)
1078                 except RuntimeError, e:
1079                         contactDetails = []
1080                         self._errorDisplay.push_exception(e)
1081                 contactPhoneNumbers = [phoneNumber for phoneNumber in contactDetails]
1082
1083                 if len(contactPhoneNumbers) == 0:
1084                         return
1085
1086                 action, phoneNumber, message = self._phoneTypeSelector.run(
1087                         contactPhoneNumbers,
1088                         message = contactName,
1089                         parent = self._window,
1090                 )
1091                 if action == PhoneTypeSelector.ACTION_CANCEL:
1092                         return
1093                 assert phoneNumber
1094
1095                 self.number_selected(action, phoneNumber, message)
1096                 self._contactsviewselection.unselect_all()