25423e15cca48832e593be82a50202aaecc13408
[gwibber.git] / gwibber / client.py
1 """
2
3 Gwibber Client v1.0
4 SegPhault (Ryan Paul) - 01/05/2008
5
6 """
7
8 import time, os, threading, logging, mx.DateTime, hashlib
9 from . import table
10 import gtk, gtk.glade, gobject, functools, traceback
11 import microblog
12 from . import gwui, config, gintegration, configui, resources
13 import xdg.BaseDirectory, urllib2, urlparse
14 import webbrowser
15 import actions
16 from . import urlshorter
17
18 # Setup Pidgin
19 from . import pidgin
20 microblog.PROTOCOLS["pidgin"] = pidgin
21
22 # i18n magic
23 import gettext
24 import locale
25
26 # urllib (quoting urls)
27 import urllib
28
29 # Set this way as in setup.cfg we have prefix=/usr/local
30 LOCALEDIR = "/usr/share/locale"
31 DOMAIN = "gwibber"
32
33 locale.setlocale(locale.LC_ALL, "")
34
35 for module in gtk.glade, gettext:
36   module.bindtextdomain(DOMAIN, LOCALEDIR)
37   module.textdomain(DOMAIN)
38
39 _ = gettext.lgettext
40
41 gtk.gdk.threads_init()
42
43 MAX_MESSAGE_LENGTH = 140
44
45 VERSION_NUMBER = "1.2.0"
46
47 def N_(message): return message
48
49 CONFIGURABLE_UI_ELEMENTS = {
50   "editor": N_("_Editor"),
51   "statusbar": N_("_Statusbar"),
52   "tray_icon": N_("Tray _Icon"),
53 }
54
55 CONFIGURABLE_ACCOUNT_ACTIONS = {
56   # Translators: these are checkbox
57   "receive": N_("_Receive Messages"),
58   "send": N_("_Send Messages"),
59   "search": N_("Search _Messages")
60   }
61
62 DEFAULT_PREFERENCES = {
63   "version": VERSION_NUMBER,
64   "show_notifications": True,
65   "refresh_interval": 2,
66   "minimize_to_tray": False,
67   "hide_taskbar_entry": False,
68   "spellcheck_enabled": True,
69   "theme": "gwilouche",
70   "urlshorter": "is.gd",
71   "retweet_style_via": False,
72   "global_retweet": False,
73   "override_font_options": False,
74   "default_font": "Sans 14",
75   "show_fullname_in_messages": True,
76 }
77
78 for _i in list(CONFIGURABLE_UI_ELEMENTS.keys()):
79   DEFAULT_PREFERENCES["show_%s" % _i] = True
80
81 try:
82   import indicate
83   import wnck
84 except:
85   indicate = None
86
87 class GwibberClient(gtk.Window):
88   def __init__(self):
89
90     self.dbus = gintegration.DBusManager(self)
91
92     gtk.Window.__init__(self, gtk.WINDOW_TOPLEVEL)
93     self.set_title(_("Gwibber"))
94     self.set_default_size(330, 500)
95     config.GCONF.add_dir(config.GCONF_PREFERENCES_DIR, config.gconf.CLIENT_PRELOAD_NONE)
96     self.preferences = config.Preferences()
97     self.last_update = None
98     self.last_focus_time = None
99     self.last_clear = None
100     self._reply_acct = None
101     self.indicator_items = {}
102     layout = gtk.VBox()
103
104     if self.preferences["facebook_beta"]:
105       from microblog import facebookstream
106       microblog.PROTOCOLS["facebook"] = facebookstream
107
108     gtk.rc_parse_string("""
109     style "tab-close-button-style" {
110       GtkWidget::focus-padding = 0
111       GtkWidget::focus-line-width = 0
112       xthickness = 0
113       ythickness = 0
114      }
115      widget "*.tab-close-button" style "tab-close-button-style"
116      """)
117
118     self.accounts = configui.AccountManager()
119     self.client = microblog.Client(self.accounts)
120     self.client.handle_error = self.handle_error
121     self.client.post_process_message = self.post_process_message
122
123     self.notification_bubbles = {}
124     self.message_target = None
125
126     self.errors = table.generate([
127       ["date", lambda t: t.time.strftime("%x")],
128       ["time", lambda t: t.time.strftime("%X")],
129       ["username"],
130       ["protocol"],
131       ["message", (gtk.CellRendererText(), {
132         "markup": lambda t: t.message})]
133     ])
134
135     self.connect("delete-event", self.on_window_close)
136     self.connect("focus-out-event", self.on_focus_out)
137     self.connect("focus-in-event", self.on_focus)
138
139     for key, value in list(DEFAULT_PREFERENCES.items()):
140       if self.preferences[key] == None: self.preferences[key] = value
141
142     if not resources.get_theme_path(self.preferences["theme"]):
143       self.preferences["theme"] = DEFAULT_PREFERENCES["theme"]
144
145     self.preferences["version"] = VERSION_NUMBER
146
147     self.timer = gobject.timeout_add(60000 * int(self.preferences["refresh_interval"]), self.update)
148     self.preferences.notify("refresh_interval", self.on_refresh_interval_changed)
149
150     for p in [
151         "theme",
152         "default_font",
153         "override_font_options",
154         "show_fullname_in_messages"]:
155       self.preferences.notify(p, self.on_theme_change)
156
157     gtk.icon_theme_add_builtin_icon("gwibber", 22,
158       gtk.gdk.pixbuf_new_from_file_at_size(
159         resources.get_ui_asset("gwibber.svg"), 24, 24))
160
161     self.set_icon_name("gwibber")
162     self.tray_icon = gtk.status_icon_new_from_icon_name("gwibber")
163     self.tray_icon.connect("activate", self.on_toggle_window_visibility)
164
165     self.tabs = gtk.Notebook()
166     self.tabs.set_property("homogeneous", False)
167     self.tabs.set_scrollable(True)
168     self.messages_view = self.add_msg_tab(self.client.receive, _("Messages"), show_icon = "go-home")
169     self.add_msg_tab(self.client.responses, _("Replies"), show_icon = "mail-reply-all", add_indicator=True)
170
171     saved_position = config.GCONF.get_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_position"), config.gconf.VALUE_INT)
172     if saved_position:
173       self.move(*saved_position)
174
175     saved_size = config.GCONF.get_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_size"), config.gconf.VALUE_INT)
176     if saved_size:
177       self.resize(*saved_size)
178
179     saved_queries = config.GCONF.get_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_searches"),
180       config.gconf.VALUE_STRING)
181
182     if saved_queries:
183       for query in saved_queries:
184         # XXX: suggest refactor of below code to avoid duplication of on_search code
185         if query.startswith("#"):
186           self.add_msg_tab(functools.partial(self.client.tag, query),
187             query.replace("#", ""), True, gtk.STOCK_INFO, False, query)
188         elif microblog.support.LINK_PARSE.match(query):
189           self.add_msg_tab(functools.partial(self.client.search_url, query),
190             urlparse.urlparse(query)[1], True, gtk.STOCK_FIND, True, query)
191         elif len(query) > 0:
192           title = _("Search") + " '" + query[:12] + "...'"
193           self.add_msg_tab(functools.partial(self.client.search, query),
194             title, True, gtk.STOCK_FIND, False, query)
195
196     #self.add_map_tab(self.client.friend_positions, "Location")
197
198     if gintegration.SPELLCHECK_ENABLED:
199       self.input = gintegration.sexy.SpellEntry()
200       self.input.set_checked(self.preferences["spellcheck_enabled"])
201     else: self.input = gtk.Entry()
202     self.input.connect("insert-text", self.on_add_text)
203     self.input.connect("populate-popup", self.on_input_context_menu)
204     self.input.connect("activate", self.on_input_activate)
205     self.input.connect("changed", self.on_input_change)
206     #self.input.set_max_length(MAX_MESSAGE_LENGTH)
207
208     self.cancel_button = gtk.Button(_("Cancel"))
209     self.cancel_button.connect("clicked", self.on_cancel_reply)
210
211     self.editor = gtk.HBox()
212     self.editor.pack_start(self.input)
213     self.editor.pack_start(self.cancel_button, False)
214
215     vb = gtk.VBox(spacing=5)
216     vb.pack_start(self.tabs, True, True)
217     vb.pack_start(self.editor, False, False)
218     vb.set_border_width(5)
219
220     warning_icon = gtk.image_new_from_stock(gtk.STOCK_DIALOG_WARNING, gtk.ICON_SIZE_MENU)
221     self.status_icon = gtk.EventBox()
222     self.status_icon.add(warning_icon)
223     self.status_icon.connect("button-press-event", self.on_errors_show)
224
225     self.statusbar = gtk.Statusbar()
226     self.statusbar.pack_start(self.status_icon, False, False)
227
228     layout.pack_start(self.setup_menus(), False)
229     layout.pack_start(vb, True, True)
230     layout.pack_start(self.statusbar, False)
231     self.add(layout)
232
233     if gintegration.can_notify:
234       # FIXME: Move this to DBusManager
235       import dbus
236
237       # http://galago-project.org/specs/notification/0.9/x408.html#signal-notification-closed
238       def on_notify_close(nId, reason = 1):
239         if nId in self.notification_bubbles:
240           del self.notification_bubbles[nId]
241
242       def on_notify_action(nId, action):
243         if action == "reply":
244           self.reply(self.notification_bubbles[nId])
245           self.window.show()
246           self.present()
247
248       bus = dbus.SessionBus()
249       bus.add_signal_receiver(on_notify_close,
250         dbus_interface="org.freedesktop.Notifications",
251         signal_name="NotificationClosed")
252
253       bus.add_signal_receiver(on_notify_action,
254         dbus_interface="org.freedesktop.Notifications",
255         signal_name="ActionInvoked")
256
257     if indicate:
258       self.indicate = indicate.indicate_server_ref_default()
259       self.indicate.set_type("message.gwibber")
260       self.indicate.set_desktop_file(resources.get_desktop_file())
261       self.indicate.connect("server-display", self.on_toggle_window_visibility)
262       self.indicate.show()
263
264     for i in list(CONFIGURABLE_UI_ELEMENTS.keys()):
265       config.GCONF.notify_add(config.GCONF_PREFERENCES_DIR + "/show_%s" % i,
266         lambda *a: self.apply_ui_element_settings())
267
268     config.GCONF.notify_add("/apps/gwibber/accounts", self.on_account_change)
269
270     self.preferences.notify("hide_taskbar_entry",
271       lambda *a: self.apply_ui_element_settings())
272
273     self.preferences.notify("spellcheck_enabled",
274       lambda *a: self.apply_ui_element_settings())
275
276     #for i in CONFIGURABLE_UI_SETTINGS:
277     #  config.GCONF.notify_add(config.GCONF_PREFERENCES_DIR + "/%s" % i,
278     #    lambda *a: self.apply_ui_drawing_settings())
279
280     def on_key_press(w, e):
281       if e.keyval == gtk.keysyms.F5:
282         self.update()
283         return True
284       if e.keyval == gtk.keysyms.Tab and e.state & gtk.gdk.CONTROL_MASK:
285         if len(self.tabs) == self.tabs.get_current_page() + 1:
286           self.tabs.set_current_page(0)
287         else: self.tabs.next_page()
288         return True
289       elif e.keyval in [ord(str(x)) for x in range(10)] and e.state & gtk.gdk.MOD1_MASK:
290         self.tabs.set_current_page(int(gtk.gdk.keyval_name(e.keyval))-1)
291         return True
292       elif e.keyval == gtk.keysyms.T and e.state & gtk.gdk.CONTROL_MASK:
293         self.on_theme_change()
294         return True
295       else:
296         return False
297
298       #else:
299       #  if not self.input.is_focus():
300       #    self.input.grab_focus()
301       #    self.input.set_position(-1)
302       #  return False
303
304     self.connect("key_press_event", on_key_press)
305
306     self.show_all()
307     self.apply_ui_element_settings()
308     self.cancel_button.hide()
309     self.status_icon.hide()
310     self.on_theme_change()
311
312     if not self.preferences["inhibit_startup_refresh"]:
313       self.update()
314
315   def on_add_text(self, entry, text, txtlen, pos):
316     if self.preferences["shorten_urls"]:
317       if text and text.startswith("http") and not " " in text \
318           and len(text) > 20:
319         # verify url is not already shortened
320         for us in urlshorter.PROTOCOLS.keys():
321           if text.startswith(urlshorter.PROTOCOLS[us].PROTOCOL_INFO["fqdn"]):
322             return
323         if text.startswith('http://twitpic.com'):
324           return
325
326         entry.stop_emission("insert-text")
327         try:
328           if not self.preferences["urlshorter"]:
329             self.preferences["urlshorter"] = "is.gd"
330           self.urlshorter = urlshorter.PROTOCOLS[self.preferences["urlshorter"]].URLShorter()
331           short = self.urlshorter.short(text)
332         except:
333           # Translators: this message appears in the Errors dialog
334           # Indicates with which action the error happened
335           self.handle_error({"username": "None", "protocol": urlshorter.PROTOCOLS[self.preferences["urlshorter"]].PROTOCOL_INFO["name"]},
336             traceback.format_exc(), _("Failed to shorten URL"))
337           self.preferences["shorten_urls"] = False
338           self.add_url(entry, text)
339           self.preferences["shorten_urls"] = True
340         else:
341           self.add_url(entry, short)
342
343   def add_url(self, entry, text):
344     # check if current text is longer than MAX_MESSAGE_LENGTH
345     c_text_len=(len(unicode(entry.get_text(), "utf-8")) + len(text))
346     # if so increase input.max_length and allow insertion
347     if c_text_len > MAX_MESSAGE_LENGTH:
348        self.input.set_max_length(c_text_len)
349     entry.insert_text(text, entry.get_position())
350     gobject.idle_add(lambda: entry.set_position(entry.get_position() + len(text)))
351
352   def on_focus(self, w, change):
353     for key, item in self.indicator_items.items():
354       #self.indicate.remove_indicator(item)
355       item.hide()
356     self.indicator_items = {}
357
358   def on_focus_out(self, widget, event):
359     if self.last_update:
360       self.last_focus_time = self.last_update
361     else:
362       self.last_focus_time= mx.DateTime.gmt()
363     return True
364
365   def on_search(self, *a):
366     dialog = gtk.MessageDialog(None,
367       gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT, gtk.MESSAGE_QUESTION,
368       gtk.BUTTONS_OK_CANCEL, None)
369
370     entry = gtk.Entry()
371     entry.connect("activate", lambda *a: dialog.response(gtk.RESPONSE_OK))
372
373     dialog.set_markup(_("Enter a search query:"))
374     dialog.vbox.pack_end(entry, True, True, 0)
375     dialog.show_all()
376     ret = dialog.run()
377     dialog.hide()
378
379     if ret == gtk.RESPONSE_OK:
380       query = entry.get_text()
381       view = None
382       if query.startswith("#"):
383         view = self.add_msg_tab(functools.partial(self.client.tag, query),
384           query.replace("#", ""), True, gtk.STOCK_INFO, True, query)
385       elif microblog.support.LINK_PARSE.match(query):
386         view = self.add_msg_tab(functools.partial(self.client.search_url, query),
387           urlparse.urlparse(query)[1], True, gtk.STOCK_FIND, True, query)
388       elif len(query) > 0:
389         title = _("Search") + " '" + query[:12] + "...'"
390         view = self.add_msg_tab(functools.partial(self.client.search, query),
391           title, True, gtk.STOCK_FIND, True, query)
392
393       if view:
394         self.update([view.get_parent()])
395
396   def add_scrolled_parent(self, view, text, show_close=False, show_icon=None, make_active=False, save=None):
397     scroll = gtk.ScrolledWindow()
398     scroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
399     scroll.add(view)
400     scroll.saved_query = save
401     view.scroll = scroll
402
403     img = gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_MENU)
404
405     btn = gtk.Button()
406     btn.set_image(img)
407     btn.set_relief(gtk.RELIEF_NONE)
408     btn.set_name("tab-close-button")
409
410     hb = gtk.HBox(spacing=2)
411     if show_icon:
412       hb.pack_start(gtk.image_new_from_icon_name(show_icon, gtk.ICON_SIZE_MENU))
413     hb.pack_start(gtk.Label(text))
414     if show_close: hb.pack_end(btn, False, False)
415     hb.show_all()
416
417     self.tabs.append_page(scroll, hb)
418     self.tabs.set_tab_reorderable(scroll, True)
419     self.tabs.show_all()
420     if make_active: self.tabs.set_current_page(self.tabs.page_num(scroll))
421
422     btn.connect("clicked", self.on_tab_close, scroll)
423     self.on_theme_change()
424
425   def add_msg_tab(self, data_handler, text, show_close=False, show_icon=None, make_active=False, save=None, add_indicator=False):
426     view = gwui.MessageView(self.preferences["theme"], self)
427     view.link_handler = self.on_link_clicked
428     view.data_retrieval_handler = data_handler
429     view.add_indicator = add_indicator
430
431     self.add_scrolled_parent(view, text, show_close, show_icon, make_active, save)
432     return view
433
434   def add_user_tab(self, data_handler, text, show_close=False, show_icon=None, make_active=False, save=None):
435     view = gwui.UserView(self.preferences["theme"], self)
436     view.link_handler = self.on_link_clicked
437     view.data_retrieval_handler = data_handler
438     view.add_indicator = False
439
440     self.add_scrolled_parent(view, text, show_close, show_icon, make_active, save)
441     return view
442
443   def add_map_tab(self, data_handler, text, show_close = True, show_icon = "applications-internet"):
444     view = gwui.MapView()
445     view.link_handler = self.on_link_clicked
446     view.data_retrieval_handler = data_handler
447     view.add_indicator = False
448
449     self.add_scrolled_parent(view, text, show_close, show_icon, make_active, save)
450     return view
451
452   def on_tab_close(self, w, e):
453     pagenum = self.tabs.page_num(e)
454     self.tabs.remove_page(pagenum)
455     e.destroy()
456
457   def on_tab_close_btn(self, w, *args):
458     n = self.tabs.get_current_page()
459     if (n > 1):
460       self.on_tab_close(w, self.tabs.get_nth_page(n))
461
462   def on_account_change(self, client, junk, entry, *args):
463     if "color" in entry.get_key():
464       for tab in self.tabs.get_children():
465         view = tab.get_child()
466         view.load_messages()
467
468   def on_window_close(self, w, e):
469     if self.preferences["minimize_to_tray"]:
470       self.preferences["show_tray_icon"] = True
471       self.on_toggle_window_visibility(w)
472       return True
473     else: self.on_quit()
474
475   def on_window_close_btn(self, w, *args):
476     self.on_window_close(w, None)
477
478   def on_cancel_reply(self, w, *args):
479     self.cancel_button.hide()
480     self.message_target = None
481     self._reply_acct = None
482     self.input.set_text("")
483     self.retweet_target_acct = None
484
485   def on_toggle_window_visibility(self, w):
486     if self.get_property("visible"):
487       self.last_position = self.get_position()
488       self.hide()
489     else:
490       self.present()
491       self.move(*self.last_position)
492
493   def on_indicator_activate(self, w):
494     tab_num = w.get_property("gwibber_tab")
495     if tab_num is not None:
496       self.tabs.set_current_page(int(tab_num))
497     visible = self.get_property("visible")
498     self.present()
499     if not visible:
500       self.move(*self.last_position)
501
502   def external_invoke(self):
503     logging.info("Invoked by external")
504     if not self.get_property("visible"):
505       logging.debug("Not visible..")
506       self.present()
507       self.move(*self.last_position)
508
509   def apply_ui_element_settings(self):
510     for i in list(CONFIGURABLE_UI_ELEMENTS.keys()):
511       if hasattr(self, i):
512         getattr(self, i).set_property(
513           "visible", self.preferences["show_%s" % i])
514
515     self.set_property("skip-taskbar-hint",
516       self.preferences["hide_taskbar_entry"])
517
518     if gintegration.SPELLCHECK_ENABLED:
519       self.input.set_checked(self.preferences["spellcheck_enabled"])
520
521   def on_refresh_interval_changed(self, *a):
522     gobject.source_remove(self.timer)
523     self.timer = gobject.timeout_add(
524       60000 * int(self.preferences["refresh_interval"]), self.update)
525
526   def reply(self, message):
527     acct = message.account
528     # store which account we replied to first so we know when not to allow further replies
529     if not self._reply_acct:
530         self._reply_acct = acct
531     if acct.supports(microblog.can.REPLY) and acct==self._reply_acct:
532       self.input.grab_focus()
533       if hasattr(message, 'is_private') and message.is_private:
534         self.input.set_text("d %s " % (message.sender_nick))
535       else:
536         # Allow replying to more than one person by clicking on the reply button.
537         current_text = self.input.get_text()
538         # If the current text ends with ": ", strip the ":", it's only taking up space
539         text = current_text[:-2] + " " if current_text.endswith(": ") else current_text
540         # do not add the nick if it's already in the list
541         if not text.count("@%s" % message.sender_nick):
542           self.input.set_text("%s@%s%s" % (text, message.sender_nick, self.preferences['reply_append_colon'] and ': ' or ' '))
543
544       self.input.set_position(-1)
545       self.message_target = message
546       self.cancel_button.show()
547
548     """
549     if acct["protocol"] in microblog.PROTOCOLS.keys():
550       if hasattr(message.client, "can_reply"):
551         view = self.add_msg_tab(lambda: self.client.thread(message), "Jaiku Replies", True)
552         view.load_messages()
553         view.load_preferences(self.get_account_config())
554     """
555
556   def on_message_action_menu(self, msg):
557     theme = gtk.icon_theme_get_default()
558     menu = gtk.Menu()
559     
560     for a in actions.MENU_ITEMS:
561       if a.include(self, msg):
562         mi = gtk.Action("gwibberMessage%s" % a.__name__, a.label, None, None).create_menu_item()
563         mi.get_image().set_from_icon_name(a.icon, gtk.ICON_SIZE_MENU)
564         mi.connect("activate", a.action, self, msg)
565         menu.append(mi)
566
567     menu.show_all()
568     menu.popup(None, None, None, 3, 0)
569   
570   def on_link_clicked(self, uri, view):
571     if uri.startswith("gwibber:"):
572       if uri.startswith("gwibber:reply"):
573         self.reply(view.message_store[int(uri.split("/")[-1])])
574         return True
575       elif uri.startswith("gwibber:menu"):
576         msg = view.message_store[int(uri.split("/")[-1])]
577         self.on_message_action_menu(msg)
578         return True
579       elif uri.startswith("gwibber:search"):
580         query = uri.split("/")[-1]
581         view = self.add_msg_tab(lambda: self.client.search(query), query, True, gtk.STOCK_FIND, True, query)
582         self.update([view.get_parent()])
583         return True
584       elif uri.startswith("gwibber:tag"):
585         query = uri.split("/")[-1]
586         view = self.add_msg_tab(lambda: self.client.tag(query),
587           query, True, gtk.STOCK_INFO, True, query)
588         self.update([view.get_parent()])
589         return True
590       elif uri.startswith("gwibber:group"):
591         query = uri.split("/")[-1]
592         view = self.add_msg_tab(lambda: self.client.group(query),
593           query, True, gtk.STOCK_INFO, True, query)
594         self.update([view.get_parent()])
595         return True
596       elif uri.startswith("gwibber:thread"):
597         msg = view.message_store[int(uri.split("/")[-1])]
598         if hasattr(msg, "original_title"): tab_label = msg.original_title
599         else: tab_label = msg.text
600         t = self.add_msg_tab(lambda: self.client.thread(msg),
601           microblog.support.truncate(tab_label), True, "mail-reply-all", True)
602         self.update([t.get_parent()])
603         return True
604       elif uri.startswith("gwibber:user"):
605         query = uri.split("/")[-1]
606         account_id = uri.split("/")[-2]
607         view = self.add_user_tab(lambda: self.client.user_messages(query, account_id),
608           query, True, gtk.STOCK_INFO, True)
609         self.update([view.get_parent()])
610         return True
611       elif uri.startswith("gwibber:retweet"):
612         self.retweet(view.message_store[int(uri.split("/")[-1])])
613         return True
614       elif uri.startswith("gwibber:forward"):
615         self.forward(view.message_store[int(uri.split("/")[-1])])
616         return True
617       elif uri.startswith("gwibber:follow"):
618         id = uri.split("/")[-1]
619         account_id = uri.split("/")[-2]
620         self.client.follow(id, account_id)
621         return True
622       elif uri.startswith("gwibber:unfollow"):
623         account_id = uri.split("/")[-2]
624         user_id = uri.split("/")[-1]
625         self.client.unfollow(user_id, account_id)
626         return True
627       elif uri.startswith("gwibber:read"):
628         msg = view.message_store[int(uri.split("/")[-1])]
629         acct = msg.account
630         if acct.supports(microblog.can.READ):
631           if(msg.client.read_message(msg)):
632             self.update([view.get_parent()])
633         else:
634           webbrowser.open (msg.url)
635         return True
636     else: return False
637
638   def on_input_context_menu(self, obj, menu):
639     menu.append(gtk.SeparatorMenuItem())
640     for acct in self.accounts:
641       if acct["protocol"] in list(microblog.PROTOCOLS.keys()):
642         if acct.supports(microblog.can.SEND):
643           mi = gtk.CheckMenuItem("%s (%s)" % (acct["username"],
644             acct.get_protocol().PROTOCOL_INFO["name"]))
645           acct.bind(mi, "send_enabled")
646           menu.append(mi)
647
648     menu.show_all()
649
650   def on_input_change(self, widget):
651     self.statusbar.pop(1)
652     if len(widget.get_text()) > 0:
653       # get current text length
654       i_textlen=len(unicode(widget.get_text(), "utf-8"))
655       # if current text is longer than MAX_MESSAGE_LENGTH
656       if i_textlen > MAX_MESSAGE_LENGTH:
657         # count chars above MAX_MESSAGE_LENGTH
658         chars=i_textlen - MAX_MESSAGE_LENGTH
659         self.statusbar.push(1, _("Characters remaining: %s" % -chars))
660       else:
661         # if current input.max_length if bigger then MAX_MESSAGE_LENGTH
662         # can reset back to MAX_MESSAGE_LENGTH to prevent typing
663         if self.input.get_max_length() > MAX_MESSAGE_LENGTH:
664           self.input.set_max_length(MAX_MESSAGE_LENGTH)
665         self.statusbar.push(1, _("Characters remaining: %s") % (
666         MAX_MESSAGE_LENGTH - i_textlen))
667
668   def on_theme_change(self, *args):
669     for tab in self.tabs:
670       view = tab.get_child()
671       view.load_theme(self.preferences["theme"])
672       if len(view.message_store) > 0:
673         view.load_messages()
674
675   def get_themes(self):
676     for base in xdg.BaseDirectory.xdg_data_dirs:
677       theme_root = os.path.join(base, "gwibber", "ui", "themes")
678       if os.path.exists(theme_root):
679
680         for p in os.listdir(theme_root):
681           if not p.startswith('.'):
682             theme_dir = os.path.join(theme_root, p)
683             if os.path.isdir(theme_dir):
684               yield theme_dir
685
686   def on_accounts_menu(self, amenu):
687     amenu.emit_stop_by_name("select")
688     menu = amenu.get_submenu()
689     for c in menu: menu.remove(c)
690
691     menuAccountsAdd = gtk.MenuItem(_("_Add"))
692     menu.append(menuAccountsAdd)
693
694     menuAccountsManage = gtk.MenuItem(_("_Manage"))
695     menuAccountsManage.connect("activate", lambda *a: self.accounts.show_account_list())
696     menu.append(menuAccountsManage)
697
698     mac = gtk.Menu()
699
700     for p in sorted(microblog.PROTOCOLS.keys()):
701       mi = gtk.MenuItem("%s" % microblog.PROTOCOLS[p].PROTOCOL_INFO["name"])
702       mi.connect("activate", self.accounts.on_account_create, p)
703       mac.append(mi)
704
705     menuAccountsAdd.set_submenu(mac)
706     menu.append(gtk.SeparatorMenuItem())
707
708     for acct in self.accounts:
709       if acct["protocol"] in list(microblog.PROTOCOLS.keys()):
710         sm = gtk.Menu()
711
712         for key in list(CONFIGURABLE_ACCOUNT_ACTIONS.keys()):
713           if acct.supports(getattr(microblog.can, key.upper())):
714             mi = gtk.CheckMenuItem(_(CONFIGURABLE_ACCOUNT_ACTIONS[key]))
715             acct.bind(mi, "%s_enabled" % key)
716             sm.append(mi)
717
718         sm.append(gtk.SeparatorMenuItem())
719
720         mi = gtk.ImageMenuItem(gtk.STOCK_PROPERTIES)
721         mi.connect("activate", lambda w, a: self.accounts.show_properties_dialog(a), acct)
722         sm.append(mi)
723
724         if hasattr(acct.get_protocol(), "account_name"):
725           aname = acct.get_protocol().account_name(acct)
726         elif acct["username"]: aname = acct["username"]
727         else: aname = None
728
729         mi = gtk.MenuItem("%s (%s)" % (aname, acct.get_protocol().PROTOCOL_INFO["name"]))
730         mi.set_submenu(sm)
731         menu.append(mi)
732     menu.show_all()
733     amenu.set_submenu(menu)
734
735   def setup_menus(self):
736     menuGwibber = gtk.Menu()
737     menuView = gtk.Menu()
738     menuAccounts = gtk.Menu()
739     menuHelp = gtk.Menu()
740
741     accelGroup = gtk.AccelGroup()
742     self.add_accel_group(accelGroup)
743
744     key, mod = gtk.accelerator_parse("Escape")
745     accelGroup.connect_group(key, mod, gtk.ACCEL_VISIBLE, self.on_cancel_reply)
746
747     def create_action(name, accel, stock, fn, parent = menuGwibber):
748       mi = gtk.Action("gwibber%s" % name, "%s" % name, None, stock)
749       gtk.accel_map_add_entry("<Gwibber>/%s" % name, *gtk.accelerator_parse(accel))
750       mi.set_accel_group(accelGroup)
751       mi.set_accel_path("<Gwibber>/%s" % name)
752       mi.connect("activate", fn)
753       parent.append(mi.create_menu_item())
754       return mi
755
756     actRefresh = create_action(_("_Refresh"), "<ctrl>R", gtk.STOCK_REFRESH, self.on_refresh)
757     actSearch = create_action(_("_Search"), "<ctrl>F", gtk.STOCK_FIND, self.on_search)
758     # XXX: actCloseWindow should be disabled (greyed out) when false self.preferences['minimize_to_tray'] as it is the same as quitting
759     actCloseWindow = create_action(_("_Close Window"), "<ctrl><shift>W", None, self.on_window_close_btn)
760     actCloseTab = create_action(_("_Close Tab"), "<ctrl>W", gtk.STOCK_CLOSE, self.on_tab_close_btn)
761     menuGwibber.append(gtk.SeparatorMenuItem())
762     actClear = create_action(_("C_lear Window"), "<ctrl><shift>L", gtk.STOCK_CLEAR, self.on_clear)
763     actClearTab = create_action(_("Clear _Tab"), "<ctrl>L", gtk.STOCK_CLEAR, self.on_clear_tab)
764     menuGwibber.append(gtk.SeparatorMenuItem())
765     actPreferences = create_action(_("_Preferences"), "<ctrl>P", gtk.STOCK_PREFERENCES, self.on_preferences)
766     menuGwibber.append(gtk.SeparatorMenuItem())
767     actQuit = create_action(_("_Quit"), "<ctrl>Q", gtk.STOCK_QUIT, self.on_quit)
768
769     #actThemeTest = gtk.Action("gwibberThemeTest", "_Theme Test", None, gtk.STOCK_PREFERENCES)
770     #actThemeTest.connect("activate", self.theme_preview_test)
771     #menuHelp.append(actThemeTest.create_menu_item())
772
773     actHelpOnline = gtk.Action("gwibberHelpOnline", _("Get Help Online..."), None, None)
774     actHelpOnline.connect("activate", lambda *a: gintegration.load_url("https://answers.launchpad.net/gwibber"))
775     actTranslate = gtk.Action("gwibberTranslate", _("Translate This Application..."), None, None)
776     actTranslate.connect("activate", lambda *a: gintegration.load_url("https://translations.launchpad.net/gwibber"))
777     actReportProblem = gtk.Action("gwibberReportProblem", _("Report a Problem"), None, None)
778     actReportProblem.connect("activate", lambda *a: gintegration.load_url("https://bugs.launchpad.net/gwibber/+filebug"))
779     actAbout = gtk.Action("gwibberAbout", _("_About"), None, gtk.STOCK_ABOUT)
780     actAbout.connect("activate", self.on_about)
781
782     menuHelp.append(actHelpOnline.create_menu_item())
783     menuHelp.append(actTranslate.create_menu_item())
784     menuHelp.append(actReportProblem.create_menu_item())
785     menuHelp.append(gtk.SeparatorMenuItem())
786     menuHelp.append(actAbout.create_menu_item())
787
788     for w, n in list(CONFIGURABLE_UI_ELEMENTS.items()):
789       mi = gtk.CheckMenuItem(_(n))
790       self.preferences.bind(mi, "show_%s" % w)
791       menuView.append(mi)
792
793     if gintegration.SPELLCHECK_ENABLED:
794       mi = gtk.CheckMenuItem(_("S_pellcheck"), True)
795       self.preferences.bind(mi, "spellcheck_enabled")
796       menuView.append(mi)
797
798     mi = gtk.MenuItem(_("E_rrors"))
799     mi.connect("activate", self.on_errors_show)
800     menuView.append(gtk.SeparatorMenuItem())
801     menuView.append(mi)
802
803     menuGwibberItem = gtk.MenuItem(_("_Gwibber"))
804     menuGwibberItem.set_submenu(menuGwibber)
805
806     menuViewItem = gtk.MenuItem(_("_View"))
807     menuViewItem.set_submenu(menuView)
808
809     menuAccountsItem = gtk.MenuItem(_("_Accounts"))
810     menuAccountsItem.set_submenu(menuAccounts)
811     menuAccountsItem.connect("select", self.on_accounts_menu)
812
813     menuHelpItem = gtk.MenuItem(_("_Help"))
814     menuHelpItem.set_submenu(menuHelp)
815
816     self.throbber = gtk.Image()
817     menuSpinner = gtk.ImageMenuItem("")
818     menuSpinner.set_right_justified(True)
819     menuSpinner.set_sensitive(False)
820     menuSpinner.set_image(self.throbber)
821
822     actDisplayBubbles = gtk.CheckMenuItem(_("Display bubbles"))
823     self.preferences.bind(actDisplayBubbles, "show_notifications")
824     menuTray = gtk.Menu()
825     menuTray.append(actDisplayBubbles)
826     menuTray.append(actRefresh.create_menu_item())
827     menuTray.append(gtk.SeparatorMenuItem())
828     menuTray.append(actPreferences.create_menu_item())
829     menuTray.append(actAbout.create_menu_item())
830     menuTray.append(gtk.SeparatorMenuItem())
831     menuTray.append(actQuit.create_menu_item())
832     menuTray.show_all()
833
834     self.tray_icon.connect("popup-menu", lambda i, b, a: menuTray.popup(
835       None, None, gtk.status_icon_position_menu, b, a, self.tray_icon))
836
837     menubar = gtk.MenuBar()
838     menubar.append(menuGwibberItem)
839     menubar.append(menuViewItem)
840     menubar.append(menuAccountsItem)
841     menubar.append(menuHelpItem)
842     menubar.append(menuSpinner)
843     return menubar
844
845   def on_quit(self, *a):
846     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_position"),
847        config.gconf.VALUE_INT, list(self.get_position()))
848     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_size"),
849        config.gconf.VALUE_INT, list(self.get_size()))
850     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_searches"),
851       config.gconf.VALUE_STRING, [t.saved_query for t in self.tabs if t.saved_query])
852     gtk.main_quit()
853
854   def on_refresh(self, *a):
855     self.update()
856
857   def on_about(self, mi):
858     glade = gtk.glade.XML(resources.get_ui_asset("preferences.glade"))
859     dialog = glade.get_widget("about_dialog")
860     dialog.set_version(str(VERSION_NUMBER))
861     dialog.connect("response", lambda *a: dialog.hide())
862     dialog.show_all()
863
864   def on_clear(self, mi):
865     self.last_clear = mx.DateTime.gmt()
866     for tab in self.tabs.get_children():
867       view = tab.get_child()
868       view.execute_script("clearMessages()")
869
870   def on_clear_tab(self, mi):
871     self.last_clear = mx.DateTime.gmt()
872     n = self.tabs.get_current_page()
873     view = self.tabs.get_nth_page(n).get_child()
874     view.execute_script("clearMessages()")
875
876   def on_errors_show(self, *args):
877     self.status_icon.hide()
878     errorwin = gtk.Window()
879     errorwin.set_title(_("Errors"))
880     errorwin.set_border_width(10)
881     errorwin.resize(600, 300)
882
883     def on_row_activate(tree, path, col):
884       w = gtk.Window()
885       w.set_title(_("Debug Output"))
886       w.resize(800, 800)
887
888       text = gtk.TextView()
889       text.get_buffer().set_text(tree.get_selected().error)
890
891       scroll = gtk.ScrolledWindow()
892       scroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
893       scroll.add_with_viewport(text)
894
895       w.add(scroll)
896       w.show_all()
897
898     errors = table.View(self.errors.tree_style,
899       self.errors.tree_store, self.errors.tree_filter)
900     errors.connect("row-activated", on_row_activate)
901
902     scroll = gtk.ScrolledWindow()
903     scroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
904     scroll.add_with_viewport(errors)
905
906     buttons = gtk.HButtonBox()
907     buttons.set_layout(gtk.BUTTONBOX_END)
908
909     def on_click_button(w, stock):
910       if stock == gtk.STOCK_CLOSE:
911         errorwin.destroy()
912       elif stock == gtk.STOCK_CLEAR:
913         self.errors.tree_store.clear()
914
915     for stock in [gtk.STOCK_CLEAR, gtk.STOCK_CLOSE]:
916       b = gtk.Button(stock=stock)
917       b.connect("clicked", on_click_button, stock)
918       buttons.pack_start(b)
919
920     vb = gtk.VBox(spacing=5)
921     vb.pack_start(scroll)
922     vb.pack_start(buttons, False, False)
923
924     errorwin.add(vb)
925     errorwin.show_all()
926
927   def on_preferences(self, mi):
928     # reset theme to default if no longer available
929     if self.preferences['theme'] not in resources.get_themes():
930       config.GCONF.set_string("%s/%s" % (config.GCONF_PREFERENCES_DIR, "theme"), 'default')
931
932     glade = gtk.glade.XML(resources.get_ui_asset("preferences.glade"))
933     dialog = glade.get_widget("pref_dialog")
934     dialog.show_all()
935
936     for widget in [
937         "show_notifications",
938         "refresh_interval",
939         "minimize_to_tray",
940         "hide_taskbar_entry",
941         "shorten_urls",
942         "reply_append_colon",
943         "retweet_style_via",
944         "global_retweet",
945         "show_fullname_in_messages",
946         "override_font_options",
947         "default_font"]:
948       self.preferences.bind(glade.get_widget("pref_%s" % widget), widget)
949
950     def on_toggle_font_override(*a):
951       glade.get_widget("pref_default_font").set_sensitive(self.preferences["override_font_options"])
952     
953     glade.get_widget("pref_override_font_options").connect("toggled", on_toggle_font_override)
954     on_toggle_font_override()
955
956     self.preferences.bind(glade.get_widget("show_tray_icon"), "show_tray_icon")
957
958     theme_selector = gtk.combo_box_new_text()
959     for theme_name in sorted(resources.get_themes()): theme_selector.append_text(theme_name)
960     glade.get_widget("containerThemeSelector").pack_start(theme_selector, True, True)
961     
962     self.preferences.bind(theme_selector, "theme")
963     theme_selector.show_all()
964
965     # add combo box with url shorter services
966     urlshorter_selector = gtk.combo_box_new_text()
967     for us in sorted(urlshorter.PROTOCOLS.keys()):
968       urlshorter_name = urlshorter.PROTOCOLS[us].PROTOCOL_INFO["name"]
969       urlshorter_selector.append_text(urlshorter_name)
970     glade.get_widget("containerURLShorterSelector").pack_start(urlshorter_selector, True, True)
971     self.preferences.bind(urlshorter_selector, "urlshorter")
972     urlshorter_selector.show_all()
973
974     glade.get_widget("button_close").connect("clicked", lambda *a: dialog.destroy())
975
976   def handle_error(self, acct, err, msg = None):
977     self.status_icon.show()
978     self.errors += {
979       "time": mx.DateTime.gmt(),
980       "username": acct["username"],
981       "protocol": acct["protocol"],
982       "message": "%s\n<i><span foreground='red'>%s</span></i>" % (msg, microblog.support.xml_escape(err.split("\n")[-2])),
983       "error": err,
984     }
985
986   def on_input_activate(self, e):
987     text = self.input.get_text().strip()
988     if text:
989       # don't allow submission if text length is greater than allowed
990       if len(text.decode("utf-8")) > MAX_MESSAGE_LENGTH:
991          return
992       # check if reply and target accordingly
993       if self.message_target:
994         account = self.message_target.account
995         if account:
996           # temporarily send_enable the account to allow reply to be posted
997           old_send_enabled_value = account["send_enabled"] 
998           account["send_enabled"] = True
999           if account.supports(microblog.can.THREAD_REPLY) and hasattr(self.message_target, "id"):
1000             result = self.client.send_thread(text, self.message_target, [account["protocol"]])
1001           else:
1002             result = self.client.reply(text, [account["protocol"]])
1003           account["send_enabled"] = old_send_enabled_value
1004       elif self.retweet_target_acct:
1005           account = self.retweet_target_acct
1006           old_send_enabled_value = account["send_enabled"] 
1007           account["send_enabled"] = True
1008           result = self.client.send(text, [account["protocol"],] )
1009           account["send_enabled"] = old_send_enabled_value
1010       # else standard post
1011       else:
1012         result = self.client.send(text, list(microblog.PROTOCOLS.keys()))
1013
1014       # Strip empties out of the result
1015       result = [x for x in result if x]
1016
1017       # if we get returned message info for the posts we should be able
1018       # to display them to the user immediately
1019       if result:
1020         for msg in result:
1021           if hasattr(msg, 'text'):
1022             self.post_process_message(msg)
1023             msg.is_new = msg.is_unread = False
1024         self.flag_duplicates(result)
1025         self.messages_view.message_store = result + self.messages_view.message_store
1026         self.messages_view.load_messages()
1027
1028       self.on_cancel_reply(None)
1029
1030   def post_process_message(self, message):
1031     if hasattr(message, "image"):
1032       message.image_url = message.image
1033       message.image_path = gwui.image_cache(message.image_url)
1034       message.image = "file://%s" % message.image_path
1035
1036     def remove_url(s):
1037       return ' '.join([x for x in s.strip('.').split()
1038         if not x.startswith('http://') and not x.startswith("https://") ])
1039
1040     if message.text.strip() == "": message.gId = None
1041     else: message.gId = hashlib.sha1(remove_url(message.text)[:140]).hexdigest()
1042
1043     message.aId = message.account.id
1044     message.dupes = []
1045
1046     if self.last_focus_time:
1047       message.is_unread = (message.time > self.last_focus_time) or (hasattr(message, "is_unread") and message.is_unread)
1048
1049     if self.last_update:
1050       message.is_new = message.time > self.last_update
1051     else: message.is_new = False
1052
1053     message.time_string = microblog.support.generate_time_string(message.time)
1054
1055     if not hasattr(message, "html_string"):
1056       message.html_string = '<span class="text">%s</span>' % \
1057         microblog.support.LINK_PARSE.sub('<a href="\\1">\\1</a>', message.text)
1058
1059     message.can_reply = message.account.supports(microblog.can.REPLY)
1060     return message
1061
1062   def show_notification_bubbles(self, messages):
1063     new_messages = []
1064     for message in messages:
1065       if message.is_new and self.preferences["show_notifications"] and \
1066         message.first_seen and gintegration.can_notify and \
1067           message.username != message.sender_nick:
1068           new_messages.append(message)
1069
1070     new_messages.reverse()
1071     gtk.gdk.threads_enter()
1072     if len(new_messages) > 0:
1073         for index, message in enumerate(new_messages):
1074             body = microblog.support.xml_escape(message.text)
1075             image = hasattr(message, "image_path") and message.image_path or ''
1076             expire_timeout = 5000 + (index*2000) # default to 5 second timeout and increase by 2 second for each notification
1077             n = gintegration.notify(message.sender, body, image, ["reply", "Reply"], expire_timeout)
1078             self.notification_bubbles[n] = message
1079     gtk.gdk.threads_leave()
1080
1081   def flag_duplicates(self, data):
1082     seen = {} 
1083     for n, message in enumerate(data):
1084       if hasattr(message, "gId") and message.gId:
1085         message.is_duplicate = message.gId in seen
1086         message.first_seen = False
1087         if message.is_duplicate:
1088           data[seen[message.gId]].dupes.append(message)
1089
1090         if not message.is_duplicate:
1091           message.first_seen = True
1092           seen[message.gId] = n
1093       else:
1094         message.is_duplicate = False
1095         message.first_seen = True
1096
1097   def is_gwibber_active(self):
1098     screen = wnck.screen_get_default()
1099     screen.force_update()
1100     w = screen.get_active_window()
1101     if w: return self.window.xid == w.get_xid()
1102     return False
1103     
1104   def manage_indicator_items(self, data, tab_num=None):
1105     if not self.is_gwibber_active():
1106       for msg in data:
1107         if hasattr(msg, "first_seen") and msg.first_seen and \
1108             hasattr(msg, "is_unread") and msg.is_unread and \
1109             hasattr(msg, "gId") and msg.gId not in self.indicator_items:
1110           indicator = indicate.IndicatorMessage()
1111           indicator.set_property("subtype", "im")
1112           indicator.set_property("sender", msg.sender_nick)
1113           indicator.set_property("body", msg.text)
1114           indicator.set_property_time("time", msg.time.gmticks())
1115           if hasattr(msg, "image_path"):
1116             pb = gtk.gdk.pixbuf_new_from_file(msg.image_path)
1117             indicator.set_property_icon("icon", pb)
1118
1119           if tab_num is not None:
1120             indicator.set_property("gwibber_tab", str(tab_num))
1121           indicator.connect("user-display", self.on_indicator_activate)
1122
1123           self.indicator_items[msg.gId] = indicator
1124           indicator.show()
1125
1126   def update_view_contents(self, view):
1127     view.load_messages()
1128
1129   def update(self, tabs = None):
1130     self.throbber.set_from_animation(
1131       gtk.gdk.PixbufAnimation(resources.get_ui_asset("progress.gif")))
1132     self.target_tabs = tabs
1133
1134     def process():
1135       try:
1136         next_update = mx.DateTime.gmt()
1137         if not self.target_tabs:
1138           self.target_tabs = self.tabs.get_children()
1139
1140         for tab in self.target_tabs:
1141           view = tab.get_child()
1142           if view:
1143             view.message_store = [m for m in
1144               view.data_retrieval_handler() if not self.last_clear or m.time > self.last_clear
1145               and m.time <= mx.DateTime.gmt()]
1146             self.flag_duplicates(view.message_store)
1147             gtk.gdk.threads_enter()
1148             gobject.idle_add(self.update_view_contents, view)
1149             
1150             if indicate and hasattr(view, "add_indicator") and view.add_indicator:
1151               self.manage_indicator_items(view.message_store, tab_num=self.tabs.page_num(tab))
1152
1153             gtk.gdk.threads_leave()
1154             self.show_notification_bubbles(view.message_store)
1155
1156         self.statusbar.pop(0)
1157         self.statusbar.push(0, _("Last update: %s") % time.strftime("%X"))
1158         self.last_update = next_update
1159
1160       finally: gobject.idle_add(self.throbber.clear)
1161
1162     t = threading.Thread(target=process)
1163     t.setDaemon(True)
1164     t.start()
1165
1166     return True
1167
1168 if __name__ == '__main__':
1169   w = GwibberClient()
1170   gtk.main()
1171