merged other changes.
[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
484   def on_toggle_window_visibility(self, w):
485     if self.get_property("visible"):
486       self.last_position = self.get_position()
487       self.hide()
488     else:
489       self.present()
490       self.move(*self.last_position)
491
492   def on_indicator_activate(self, w):
493     tab_num = w.get_property("gwibber_tab")
494     if tab_num is not None:
495       self.tabs.set_current_page(int(tab_num))
496     visible = self.get_property("visible")
497     self.present()
498     if not visible:
499       self.move(*self.last_position)
500
501   def external_invoke(self):
502     logging.info("Invoked by external")
503     if not self.get_property("visible"):
504       logging.debug("Not visible..")
505       self.present()
506       self.move(*self.last_position)
507
508   def apply_ui_element_settings(self):
509     for i in list(CONFIGURABLE_UI_ELEMENTS.keys()):
510       if hasattr(self, i):
511         getattr(self, i).set_property(
512           "visible", self.preferences["show_%s" % i])
513
514     self.set_property("skip-taskbar-hint",
515       self.preferences["hide_taskbar_entry"])
516
517     if gintegration.SPELLCHECK_ENABLED:
518       self.input.set_checked(self.preferences["spellcheck_enabled"])
519
520   def on_refresh_interval_changed(self, *a):
521     gobject.source_remove(self.timer)
522     self.timer = gobject.timeout_add(
523       60000 * int(self.preferences["refresh_interval"]), self.update)
524
525   def reply(self, message):
526     acct = message.account
527     # store which account we replied to first so we know when not to allow further replies
528     if not self._reply_acct:
529         self._reply_acct = acct
530     if acct.supports(microblog.can.REPLY) and acct==self._reply_acct:
531       self.input.grab_focus()
532       if hasattr(message, 'is_private') and message.is_private:
533         self.input.set_text("d %s " % (message.sender_nick))
534       else:
535         # Allow replying to more than one person by clicking on the reply button.
536         current_text = self.input.get_text()
537         # If the current text ends with ": ", strip the ":", it's only taking up space
538         text = current_text[:-2] + " " if current_text.endswith(": ") else current_text
539         # do not add the nick if it's already in the list
540         if not text.count("@%s" % message.sender_nick):
541           self.input.set_text("%s@%s%s" % (text, message.sender_nick, self.preferences['reply_append_colon'] and ': ' or ' '))
542
543       self.input.set_position(-1)
544       self.message_target = message
545       self.cancel_button.show()
546
547     """
548     if acct["protocol"] in microblog.PROTOCOLS.keys():
549       if hasattr(message.client, "can_reply"):
550         view = self.add_msg_tab(lambda: self.client.thread(message), "Jaiku Replies", True)
551         view.load_messages()
552         view.load_preferences(self.get_account_config())
553     """
554
555   def on_message_action_menu(self, msg):
556     theme = gtk.icon_theme_get_default()
557     menu = gtk.Menu()
558     
559     for a in actions.MENU_ITEMS:
560       if a.include(self, msg):
561         mi = gtk.Action("gwibberMessage%s" % a.__name__, a.label, None, None).create_menu_item()
562         mi.get_image().set_from_icon_name(a.icon, gtk.ICON_SIZE_MENU)
563         mi.connect("activate", a.action, self, msg)
564         menu.append(mi)
565
566     menu.show_all()
567     menu.popup(None, None, None, 3, 0)
568   
569   def on_link_clicked(self, uri, view):
570     if uri.startswith("gwibber:"):
571       if uri.startswith("gwibber:reply"):
572         self.reply(view.message_store[int(uri.split("/")[-1])])
573         return True
574       elif uri.startswith("gwibber:menu"):
575         msg = view.message_store[int(uri.split("/")[-1])]
576         self.on_message_action_menu(msg)
577         return True
578       elif uri.startswith("gwibber:search"):
579         query = uri.split("/")[-1]
580         view = self.add_msg_tab(lambda: self.client.search(query), query, True, gtk.STOCK_FIND, True, query)
581         self.update([view.get_parent()])
582         return True
583       elif uri.startswith("gwibber:tag"):
584         query = uri.split("/")[-1]
585         view = self.add_msg_tab(lambda: self.client.tag(query),
586           query, True, gtk.STOCK_INFO, True, query)
587         self.update([view.get_parent()])
588         return True
589       elif uri.startswith("gwibber:group"):
590         query = uri.split("/")[-1]
591         view = self.add_msg_tab(lambda: self.client.group(query),
592           query, True, gtk.STOCK_INFO, True, query)
593         self.update([view.get_parent()])
594         return True
595       elif uri.startswith("gwibber:thread"):
596         msg = view.message_store[int(uri.split("/")[-1])]
597         if hasattr(msg, "original_title"): tab_label = msg.original_title
598         else: tab_label = msg.text
599         t = self.add_msg_tab(lambda: self.client.thread(msg),
600           microblog.support.truncate(tab_label), True, "mail-reply-all", True)
601         self.update([t.get_parent()])
602         return True
603       elif uri.startswith("gwibber:user"):
604         query = uri.split("/")[-1]
605         account_id = uri.split("/")[-2]
606         view = self.add_user_tab(lambda: self.client.user_messages(query, account_id),
607           query, True, gtk.STOCK_INFO, True)
608         self.update([view.get_parent()])
609         return True
610       elif uri.startswith("gwibber:read"):
611         msg = view.message_store[int(uri.split("/")[-1])]
612         acct = msg.account
613         if acct.supports(microblog.can.READ):
614           if(msg.client.read_message(msg)):
615             self.update([view.get_parent()])
616         else:
617           webbrowser.open (msg.url)
618         return True
619     else: return False
620
621   def on_input_context_menu(self, obj, menu):
622     menu.append(gtk.SeparatorMenuItem())
623     for acct in self.accounts:
624       if acct["protocol"] in list(microblog.PROTOCOLS.keys()):
625         if acct.supports(microblog.can.SEND):
626           mi = gtk.CheckMenuItem("%s (%s)" % (acct["username"],
627             acct.get_protocol().PROTOCOL_INFO["name"]))
628           acct.bind(mi, "send_enabled")
629           menu.append(mi)
630
631     menu.show_all()
632
633   def on_input_change(self, widget):
634     self.statusbar.pop(1)
635     if len(widget.get_text()) > 0:
636       # get current text length
637       i_textlen=len(unicode(widget.get_text(), "utf-8"))
638       # if current text is longer than MAX_MESSAGE_LENGTH
639       if i_textlen > MAX_MESSAGE_LENGTH:
640         # count chars above MAX_MESSAGE_LENGTH
641         chars=i_textlen - MAX_MESSAGE_LENGTH
642         self.statusbar.push(1, _("Characters remaining: %s" % -chars))
643       else:
644         # if current input.max_length if bigger then MAX_MESSAGE_LENGTH
645         # can reset back to MAX_MESSAGE_LENGTH to prevent typing
646         if self.input.get_max_length() > MAX_MESSAGE_LENGTH:
647           self.input.set_max_length(MAX_MESSAGE_LENGTH)
648         self.statusbar.push(1, _("Characters remaining: %s") % (
649         self.input.get_max_length() - i_textlen))
650
651   def on_theme_change(self, *args):
652     for tab in self.tabs:
653       view = tab.get_child()
654       view.load_theme(self.preferences["theme"])
655       if len(view.message_store) > 0:
656         view.load_messages()
657
658   def get_themes(self):
659     for base in xdg.BaseDirectory.xdg_data_dirs:
660       theme_root = os.path.join(base, "gwibber", "ui", "themes")
661       if os.path.exists(theme_root):
662
663         for p in os.listdir(theme_root):
664           if not p.startswith('.'):
665             theme_dir = os.path.join(theme_root, p)
666             if os.path.isdir(theme_dir):
667               yield theme_dir
668
669   def on_accounts_menu(self, amenu):
670     amenu.emit_stop_by_name("select")
671     menu = amenu.get_submenu()
672     for c in menu: menu.remove(c)
673
674     menuAccountsAdd = gtk.MenuItem(_("_Add"))
675     menu.append(menuAccountsAdd)
676
677     menuAccountsManage = gtk.MenuItem(_("_Manage"))
678     menuAccountsManage.connect("activate", lambda *a: self.accounts.show_account_list())
679     menu.append(menuAccountsManage)
680
681     mac = gtk.Menu()
682
683     for p in sorted(microblog.PROTOCOLS.keys()):
684       mi = gtk.MenuItem("%s" % microblog.PROTOCOLS[p].PROTOCOL_INFO["name"])
685       mi.connect("activate", self.accounts.on_account_create, p)
686       mac.append(mi)
687
688     menuAccountsAdd.set_submenu(mac)
689     menu.append(gtk.SeparatorMenuItem())
690
691     for acct in self.accounts:
692       if acct["protocol"] in list(microblog.PROTOCOLS.keys()):
693         sm = gtk.Menu()
694
695         for key in list(CONFIGURABLE_ACCOUNT_ACTIONS.keys()):
696           if acct.supports(getattr(microblog.can, key.upper())):
697             mi = gtk.CheckMenuItem(_(CONFIGURABLE_ACCOUNT_ACTIONS[key]))
698             acct.bind(mi, "%s_enabled" % key)
699             sm.append(mi)
700
701         sm.append(gtk.SeparatorMenuItem())
702
703         mi = gtk.ImageMenuItem(gtk.STOCK_PROPERTIES)
704         mi.connect("activate", lambda w, a: self.accounts.show_properties_dialog(a), acct)
705         sm.append(mi)
706
707         if hasattr(acct.get_protocol(), "account_name"):
708           aname = acct.get_protocol().account_name(acct)
709         elif acct["username"]: aname = acct["username"]
710         else: aname = None
711
712         mi = gtk.MenuItem("%s (%s)" % (aname, acct.get_protocol().PROTOCOL_INFO["name"]))
713         mi.set_submenu(sm)
714         menu.append(mi)
715     menu.show_all()
716     amenu.set_submenu(menu)
717
718   def setup_menus(self):
719     menuGwibber = gtk.Menu()
720     menuView = gtk.Menu()
721     menuAccounts = gtk.Menu()
722     menuHelp = gtk.Menu()
723
724     accelGroup = gtk.AccelGroup()
725     self.add_accel_group(accelGroup)
726
727     key, mod = gtk.accelerator_parse("Escape")
728     accelGroup.connect_group(key, mod, gtk.ACCEL_VISIBLE, self.on_cancel_reply)
729
730     def create_action(name, accel, stock, fn, parent = menuGwibber):
731       mi = gtk.Action("gwibber%s" % name, "%s" % name, None, stock)
732       gtk.accel_map_add_entry("<Gwibber>/%s" % name, *gtk.accelerator_parse(accel))
733       mi.set_accel_group(accelGroup)
734       mi.set_accel_path("<Gwibber>/%s" % name)
735       mi.connect("activate", fn)
736       parent.append(mi.create_menu_item())
737       return mi
738
739     actRefresh = create_action(_("_Refresh"), "<ctrl>R", gtk.STOCK_REFRESH, self.on_refresh)
740     actSearch = create_action(_("_Search"), "<ctrl>F", gtk.STOCK_FIND, self.on_search)
741     # XXX: actCloseWindow should be disabled (greyed out) when false self.preferences['minimize_to_tray'] as it is the same as quitting
742     actCloseWindow = create_action(_("_Close Window"), "<ctrl><shift>W", None, self.on_window_close_btn)
743     actCloseTab = create_action(_("_Close Tab"), "<ctrl>W", gtk.STOCK_CLOSE, self.on_tab_close_btn)
744     menuGwibber.append(gtk.SeparatorMenuItem())
745     actClear = create_action(_("C_lear Window"), "<ctrl><shift>L", gtk.STOCK_CLEAR, self.on_clear)
746     actClearTab = create_action(_("Clear _Tab"), "<ctrl>L", gtk.STOCK_CLEAR, self.on_clear_tab)
747     menuGwibber.append(gtk.SeparatorMenuItem())
748     actPreferences = create_action(_("_Preferences"), "<ctrl>P", gtk.STOCK_PREFERENCES, self.on_preferences)
749     menuGwibber.append(gtk.SeparatorMenuItem())
750     actQuit = create_action(_("_Quit"), "<ctrl>Q", gtk.STOCK_QUIT, self.on_quit)
751
752     #actThemeTest = gtk.Action("gwibberThemeTest", "_Theme Test", None, gtk.STOCK_PREFERENCES)
753     #actThemeTest.connect("activate", self.theme_preview_test)
754     #menuHelp.append(actThemeTest.create_menu_item())
755
756     actHelpOnline = gtk.Action("gwibberHelpOnline", _("Get Help Online..."), None, None)
757     actHelpOnline.connect("activate", lambda *a: gintegration.load_url("https://answers.launchpad.net/gwibber"))
758     actTranslate = gtk.Action("gwibberTranslate", _("Translate This Application..."), None, None)
759     actTranslate.connect("activate", lambda *a: gintegration.load_url("https://translations.launchpad.net/gwibber"))
760     actReportProblem = gtk.Action("gwibberReportProblem", _("Report a Problem"), None, None)
761     actReportProblem.connect("activate", lambda *a: gintegration.load_url("https://bugs.launchpad.net/gwibber/+filebug"))
762     actAbout = gtk.Action("gwibberAbout", _("_About"), None, gtk.STOCK_ABOUT)
763     actAbout.connect("activate", self.on_about)
764
765     menuHelp.append(actHelpOnline.create_menu_item())
766     menuHelp.append(actTranslate.create_menu_item())
767     menuHelp.append(actReportProblem.create_menu_item())
768     menuHelp.append(gtk.SeparatorMenuItem())
769     menuHelp.append(actAbout.create_menu_item())
770
771     for w, n in list(CONFIGURABLE_UI_ELEMENTS.items()):
772       mi = gtk.CheckMenuItem(_(n))
773       self.preferences.bind(mi, "show_%s" % w)
774       menuView.append(mi)
775
776     if gintegration.SPELLCHECK_ENABLED:
777       mi = gtk.CheckMenuItem(_("S_pellcheck"), True)
778       self.preferences.bind(mi, "spellcheck_enabled")
779       menuView.append(mi)
780
781     mi = gtk.MenuItem(_("E_rrors"))
782     mi.connect("activate", self.on_errors_show)
783     menuView.append(gtk.SeparatorMenuItem())
784     menuView.append(mi)
785
786     menuGwibberItem = gtk.MenuItem(_("_Gwibber"))
787     menuGwibberItem.set_submenu(menuGwibber)
788
789     menuViewItem = gtk.MenuItem(_("_View"))
790     menuViewItem.set_submenu(menuView)
791
792     menuAccountsItem = gtk.MenuItem(_("_Accounts"))
793     menuAccountsItem.set_submenu(menuAccounts)
794     menuAccountsItem.connect("select", self.on_accounts_menu)
795
796     menuHelpItem = gtk.MenuItem(_("_Help"))
797     menuHelpItem.set_submenu(menuHelp)
798
799     self.throbber = gtk.Image()
800     menuSpinner = gtk.ImageMenuItem("")
801     menuSpinner.set_right_justified(True)
802     menuSpinner.set_sensitive(False)
803     menuSpinner.set_image(self.throbber)
804
805     actDisplayBubbles = gtk.CheckMenuItem(_("Display bubbles"))
806     self.preferences.bind(actDisplayBubbles, "show_notifications")
807     menuTray = gtk.Menu()
808     menuTray.append(actDisplayBubbles)
809     menuTray.append(actRefresh.create_menu_item())
810     menuTray.append(gtk.SeparatorMenuItem())
811     menuTray.append(actPreferences.create_menu_item())
812     menuTray.append(actAbout.create_menu_item())
813     menuTray.append(gtk.SeparatorMenuItem())
814     menuTray.append(actQuit.create_menu_item())
815     menuTray.show_all()
816
817     self.tray_icon.connect("popup-menu", lambda i, b, a: menuTray.popup(
818       None, None, gtk.status_icon_position_menu, b, a, self.tray_icon))
819
820     menubar = gtk.MenuBar()
821     menubar.append(menuGwibberItem)
822     menubar.append(menuViewItem)
823     menubar.append(menuAccountsItem)
824     menubar.append(menuHelpItem)
825     menubar.append(menuSpinner)
826     return menubar
827
828   def on_quit(self, *a):
829     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_position"),
830        config.gconf.VALUE_INT, list(self.get_position()))
831     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_size"),
832        config.gconf.VALUE_INT, list(self.get_size()))
833     config.GCONF.set_list("%s/%s" % (config.GCONF_PREFERENCES_DIR, "saved_searches"),
834       config.gconf.VALUE_STRING, [t.saved_query for t in self.tabs if t.saved_query])
835     gtk.main_quit()
836
837   def on_refresh(self, *a):
838     self.update()
839
840   def on_about(self, mi):
841     glade = gtk.glade.XML(resources.get_ui_asset("preferences.glade"))
842     dialog = glade.get_widget("about_dialog")
843     dialog.set_version(str(VERSION_NUMBER))
844     dialog.connect("response", lambda *a: dialog.hide())
845     dialog.show_all()
846
847   def on_clear(self, mi):
848     self.last_clear = mx.DateTime.gmt()
849     for tab in self.tabs.get_children():
850       view = tab.get_child()
851       view.execute_script("clearMessages()")
852
853   def on_clear_tab(self, mi):
854     self.last_clear = mx.DateTime.gmt()
855     n = self.tabs.get_current_page()
856     view = self.tabs.get_nth_page(n).get_child()
857     view.execute_script("clearMessages()")
858
859   def on_errors_show(self, *args):
860     self.status_icon.hide()
861     errorwin = gtk.Window()
862     errorwin.set_title(_("Errors"))
863     errorwin.set_border_width(10)
864     errorwin.resize(600, 300)
865
866     def on_row_activate(tree, path, col):
867       w = gtk.Window()
868       w.set_title(_("Debug Output"))
869       w.resize(800, 800)
870
871       text = gtk.TextView()
872       text.get_buffer().set_text(tree.get_selected().error)
873
874       scroll = gtk.ScrolledWindow()
875       scroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
876       scroll.add_with_viewport(text)
877
878       w.add(scroll)
879       w.show_all()
880
881     errors = table.View(self.errors.tree_style,
882       self.errors.tree_store, self.errors.tree_filter)
883     errors.connect("row-activated", on_row_activate)
884
885     scroll = gtk.ScrolledWindow()
886     scroll.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
887     scroll.add_with_viewport(errors)
888
889     buttons = gtk.HButtonBox()
890     buttons.set_layout(gtk.BUTTONBOX_END)
891
892     def on_click_button(w, stock):
893       if stock == gtk.STOCK_CLOSE:
894         errorwin.destroy()
895       elif stock == gtk.STOCK_CLEAR:
896         self.errors.tree_store.clear()
897
898     for stock in [gtk.STOCK_CLEAR, gtk.STOCK_CLOSE]:
899       b = gtk.Button(stock=stock)
900       b.connect("clicked", on_click_button, stock)
901       buttons.pack_start(b)
902
903     vb = gtk.VBox(spacing=5)
904     vb.pack_start(scroll)
905     vb.pack_start(buttons, False, False)
906
907     errorwin.add(vb)
908     errorwin.show_all()
909
910   def on_preferences(self, mi):
911     # reset theme to default if no longer available
912     if self.preferences['theme'] not in resources.get_themes():
913       config.GCONF.set_string("%s/%s" % (config.GCONF_PREFERENCES_DIR, "theme"), 'default')
914
915     glade = gtk.glade.XML(resources.get_ui_asset("preferences.glade"))
916     dialog = glade.get_widget("pref_dialog")
917     dialog.show_all()
918
919     for widget in [
920         "show_notifications",
921         "refresh_interval",
922         "minimize_to_tray",
923         "hide_taskbar_entry",
924         "shorten_urls",
925         "reply_append_colon",
926         "retweet_style_via",
927         "global_retweet",
928         "show_fullname_in_messages",
929         "override_font_options",
930         "default_font"]:
931       self.preferences.bind(glade.get_widget("pref_%s" % widget), widget)
932
933     def on_toggle_font_override(*a):
934       glade.get_widget("pref_default_font").set_sensitive(self.preferences["override_font_options"])
935     
936     glade.get_widget("pref_override_font_options").connect("toggled", on_toggle_font_override)
937     on_toggle_font_override()
938
939     self.preferences.bind(glade.get_widget("show_tray_icon"), "show_tray_icon")
940
941     theme_selector = gtk.combo_box_new_text()
942     for theme_name in sorted(resources.get_themes()): theme_selector.append_text(theme_name)
943     glade.get_widget("containerThemeSelector").pack_start(theme_selector, True, True)
944     
945     self.preferences.bind(theme_selector, "theme")
946     theme_selector.show_all()
947
948     # add combo box with url shorter services
949     urlshorter_selector = gtk.combo_box_new_text()
950     for us in sorted(urlshorter.PROTOCOLS.keys()):
951       urlshorter_name = urlshorter.PROTOCOLS[us].PROTOCOL_INFO["name"]
952       urlshorter_selector.append_text(urlshorter_name)
953     glade.get_widget("containerURLShorterSelector").pack_start(urlshorter_selector, True, True)
954     self.preferences.bind(urlshorter_selector, "urlshorter")
955     urlshorter_selector.show_all()
956
957     glade.get_widget("button_close").connect("clicked", lambda *a: dialog.destroy())
958
959   def handle_error(self, acct, err, msg = None):
960     self.status_icon.show()
961     self.errors += {
962       "time": mx.DateTime.gmt(),
963       "username": acct["username"],
964       "protocol": acct["protocol"],
965       "message": "%s\n<i><span foreground='red'>%s</span></i>" % (msg, microblog.support.xml_escape(err.split("\n")[-2])),
966       "error": err,
967     }
968
969   def on_input_activate(self, e):
970     text = self.input.get_text().strip()
971     if text:
972       # don't allow submission if text length is greater than allowed
973       if len(text.decode("utf-8")) > MAX_MESSAGE_LENGTH:
974          return
975       # check if reply and target accordingly
976       if self.message_target:
977         account = self.message_target.account
978         if account:
979           # temporarily send_enable the account to allow reply to be posted
980           is_send_enabled = account["send_enabled"]
981           account["send_enabled"] = True
982           if account.supports(microblog.can.THREAD_REPLY) and hasattr(self.message_target, "id"):
983             result = self.client.send_thread(text, self.message_target, [account["protocol"]])
984           else:
985             result = self.client.reply(text, [account["protocol"]])
986           # restore send_enabled choice after replying
987           account["send_enabled"] = is_send_enabled
988       # else standard post
989       else:
990         result = self.client.send(text, list(microblog.PROTOCOLS.keys()))
991
992       # Strip empties out of the result
993       result = [x for x in result if x]
994
995       # if we get returned message info for the posts we should be able
996       # to display them to the user immediately
997       if result:
998         for msg in result:
999           if hasattr(msg, 'text'):
1000             self.post_process_message(msg)
1001             msg.is_new = msg.is_unread = False
1002         self.flag_duplicates(result)
1003         self.messages_view.message_store = result + self.messages_view.message_store
1004         self.messages_view.load_messages()
1005
1006       self.on_cancel_reply(None)
1007
1008   def post_process_message(self, message):
1009     if hasattr(message, "image"):
1010       message.image_url = message.image
1011       message.image_path = gwui.image_cache(message.image_url)
1012       message.image = "file://%s" % message.image_path
1013
1014     def remove_url(s):
1015       return ' '.join([x for x in s.strip('.').split()
1016         if not x.startswith('http://') and not x.startswith("https://") ])
1017
1018     if message.text.strip() == "": message.gId = None
1019     else: message.gId = hashlib.sha1(remove_url(message.text)[:140]).hexdigest()
1020
1021     message.aId = message.account.id
1022     message.dupes = []
1023
1024     if self.last_focus_time:
1025       message.is_unread = (message.time > self.last_focus_time) or (hasattr(message, "is_unread") and message.is_unread)
1026
1027     if self.last_update:
1028       message.is_new = message.time > self.last_update
1029     else: message.is_new = False
1030
1031     message.time_string = microblog.support.generate_time_string(message.time)
1032
1033     if not hasattr(message, "html_string"):
1034       message.html_string = '<span class="text">%s</span>' % \
1035         microblog.support.LINK_PARSE.sub('<a href="\\1">\\1</a>', message.text)
1036
1037     message.can_reply = message.account.supports(microblog.can.REPLY)
1038     return message
1039
1040   def show_notification_bubbles(self, messages):
1041     new_messages = []
1042     for message in messages:
1043       if message.is_new and self.preferences["show_notifications"] and \
1044         message.first_seen and gintegration.can_notify and \
1045           message.username != message.sender_nick:
1046           new_messages.append(message)
1047
1048     new_messages.reverse()
1049     gtk.gdk.threads_enter()
1050     if len(new_messages) > 0:
1051         for index, message in enumerate(new_messages):
1052             body = microblog.support.xml_escape(message.text)
1053             image = hasattr(message, "image_path") and message.image_path or ''
1054             expire_timeout = 5000 + (index*2000) # default to 5 second timeout and increase by 2 second for each notification
1055             n = gintegration.notify(message.sender, body, image, ["reply", "Reply"], expire_timeout)
1056             self.notification_bubbles[n] = message
1057     gtk.gdk.threads_leave()
1058
1059   def flag_duplicates(self, data):
1060     seen = {} 
1061     for n, message in enumerate(data):
1062       if hasattr(message, "gId") and message.gId:
1063         message.is_duplicate = message.gId in seen
1064         message.first_seen = False
1065         if message.is_duplicate:
1066           data[seen[message.gId]].dupes.append(message)
1067
1068         if not message.is_duplicate:
1069           message.first_seen = True
1070           seen[message.gId] = n
1071       else:
1072         message.is_duplicate = False
1073         message.first_seen = True
1074
1075   def is_gwibber_active(self):
1076     screen = wnck.screen_get_default()
1077     screen.force_update()
1078     w = screen.get_active_window()
1079     if w: return self.window.xid == w.get_xid()
1080     return False
1081     
1082   def manage_indicator_items(self, data, tab_num=None):
1083     if not self.is_gwibber_active():
1084       for msg in data:
1085         if hasattr(msg, "first_seen") and msg.first_seen and \
1086             hasattr(msg, "is_unread") and msg.is_unread and \
1087             hasattr(msg, "gId") and msg.gId not in self.indicator_items:
1088           indicator = indicate.IndicatorMessage()
1089           indicator.set_property("subtype", "im")
1090           indicator.set_property("sender", msg.sender_nick)
1091           indicator.set_property("body", msg.text)
1092           indicator.set_property_time("time", msg.time.gmticks())
1093           if hasattr(msg, "image_path"):
1094             pb = gtk.gdk.pixbuf_new_from_file(msg.image_path)
1095             indicator.set_property_icon("icon", pb)
1096
1097           if tab_num is not None:
1098             indicator.set_property("gwibber_tab", str(tab_num))
1099           indicator.connect("user-display", self.on_indicator_activate)
1100
1101           self.indicator_items[msg.gId] = indicator
1102           indicator.show()
1103
1104   def update_view_contents(self, view):
1105     view.load_messages()
1106
1107   def update(self, tabs = None):
1108     self.throbber.set_from_animation(
1109       gtk.gdk.PixbufAnimation(resources.get_ui_asset("progress.gif")))
1110     self.target_tabs = tabs
1111
1112     def process():
1113       try:
1114         next_update = mx.DateTime.gmt()
1115         if not self.target_tabs:
1116           self.target_tabs = self.tabs.get_children()
1117
1118         for tab in self.target_tabs:
1119           view = tab.get_child()
1120           if view:
1121             view.message_store = [m for m in
1122               view.data_retrieval_handler() if not self.last_clear or m.time > self.last_clear
1123               and m.time <= mx.DateTime.gmt()]
1124             self.flag_duplicates(view.message_store)
1125             gtk.gdk.threads_enter()
1126             gobject.idle_add(self.update_view_contents, view)
1127             
1128             if indicate and hasattr(view, "add_indicator") and view.add_indicator:
1129               self.manage_indicator_items(view.message_store, tab_num=self.tabs.page_num(tab))
1130
1131             gtk.gdk.threads_leave()
1132             self.show_notification_bubbles(view.message_store)
1133
1134         self.statusbar.pop(0)
1135         self.statusbar.push(0, _("Last update: %s") % time.strftime("%X"))
1136         self.last_update = next_update
1137
1138       finally: gobject.idle_add(self.throbber.clear)
1139
1140     t = threading.Thread(target=process)
1141     t.setDaemon(True)
1142     t.start()
1143
1144     return True
1145
1146 if __name__ == '__main__':
1147   w = GwibberClient()
1148   gtk.main()
1149