systray icon notification, Closes #15
[minibook.git] / minibook.py
blob81ea61671c548828a55c1ce1667b8067181bab5b
1 #!/usr/bin/env python
2 """ Minibook: the Facebook(TM) status updater
3 (C) 2009 Gergely Imreh <imrehg@gmail.com>
4 """
6 VERSION = '0.1.0'
7 APPNAME = 'minibook'
8 MIT = """
9 Copyright (c) 2009 Gergely Imreh <imrehg@gmail.com>
11 Permission is hereby granted, free of charge, to any person obtaining a copy
12 of this software and associated documentation files (the "Software"), to deal
13 in the Software without restriction, including without limitation the rights
14 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15 copies of the Software, and to permit persons to whom the Software is
16 furnished to do so, subject to the following conditions:
18 The above copyright notice and this permission notice shall be included in
19 all copies or substantial portions of the Software.
21 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
27 THE SOFTWARE.
28 """
29 MAX_MESSAGE_LENGTH = 255
31 import pygtk
32 pygtk.require('2.0')
33 import gtk
34 import gobject
35 try:
36 from facebook import Facebook
37 except:
38 print "Pyfacebook is not available, cannot run."
39 exit(1)
41 import time
42 import re
43 import threading
45 gobject.threads_init()
46 gtk.gdk.threads_init()
47 gtk.gdk.threads_enter()
49 try:
50 import gtkspell
51 spelling_support = True
52 except:
53 spelling_support = False
55 import logging
56 import sys
57 import timesince
58 import urllib2
60 LEVELS = {'debug': logging.DEBUG,
61 'info': logging.INFO,
62 'warning': logging.WARNING,
63 'error': logging.ERROR,
64 'critical': logging.CRITICAL}
66 if len(sys.argv) > 1:
67 level_name = sys.argv[1]
68 level = LEVELS.get(level_name, logging.CRITICAL)
69 logging.basicConfig(level=level)
70 else:
71 logging.basicConfig(level=logging.CRITICAL)
73 _log = logging.getLogger('minibook')
76 class Columns:
77 (STATUSID, UID, STATUS, DATETIME, COMMENTS, LIKES) = range(6)
80 #-------------------------------------------------
81 # Threading related objects.
82 # Info http://edsiper.linuxchile.cl/blog/?p=152
83 # to mitigate TreeView + threads problems
84 # These classes are based on the code available at http://gist.github.com/51686
85 # (c) 2008, John Stowers <john.stowers@gmail.com>
86 #-------------------------------------------------
88 class _IdleObject(gobject.GObject):
89 """
90 Override gobject.GObject to always emit signals in the main thread
91 by emmitting on an idle handler
92 """
94 def __init__(self):
95 gobject.GObject.__init__(self)
97 def emit(self, *args):
98 gobject.idle_add(gobject.GObject.emit, self, *args)
101 #-------------------------------------------------
102 # Thread support
103 #-------------------------------------------------
105 class _WorkerThread(threading.Thread, _IdleObject):
107 A single working thread.
110 __gsignals__ = {
111 "completed": (
112 gobject.SIGNAL_RUN_LAST,
113 gobject.TYPE_NONE,
114 (gobject.TYPE_PYOBJECT, )),
115 "exception": (
116 gobject.SIGNAL_RUN_LAST,
117 gobject.TYPE_NONE,
118 (gobject.TYPE_PYOBJECT, ))}
120 def __init__(self, function, *args, **kwargs):
121 threading.Thread.__init__(self)
122 _IdleObject.__init__(self)
123 self._function = function
124 self._args = args
125 self._kwargs = kwargs
127 def run(self):
128 # call the function
129 _log.debug('Thread %s calling %s' % (self.name, str(self._function)))
131 args = self._args
132 kwargs = self._kwargs
134 try:
135 result = self._function(*args, **kwargs)
136 except Exception, exc:
137 self.emit("exception", exc)
138 return
140 _log.debug('Thread %s completed' % (self.name))
142 self.emit("completed", result)
143 return
146 class _ThreadManager(object):
148 Manager to add new threads and remove finished ones from queue
151 def __init__(self, max_threads=4):
153 Start the thread pool. The number of threads in the pool is defined
154 by `pool_size`, defaults to 4
157 self._max_threads = max_threads
158 self._thread_pool = []
159 self._running = []
160 self._thread_id = 0
162 return
164 def _remove_thread(self, widget, arg=None):
166 Called when the thread completes. Remove it from the thread list
167 and start the next thread (if there is any)
170 # not actually a widget. It's the object that emitted the signal, in
171 # this case, the _WorkerThread object.
172 thread_id = widget.name
174 _log.debug('Thread %s completed, %d threads in the queue' % (thread_id,
175 len(self._thread_pool)))
177 self._running.remove(thread_id)
179 if self._thread_pool:
180 if len(self._running) < self._max_threads:
181 next = self._thread_pool.pop()
182 _log.debug('Dequeuing thread %s', next.name)
183 self._running.append(next.name)
184 next.start()
186 return
188 def add_work(self, complete_cb, exception_cb, func, *args, **kwargs):
190 Add a work to the thread list
191 complete_cb: function to call when 'func' finishes
192 exception_cb: function to call when 'func' rises an exception
193 func: function to do the main part of the work
194 *args, **kwargs: arguments for func
197 thread = _WorkerThread(func, *args, **kwargs)
198 thread_id = '%s' % (self._thread_id)
200 thread.connect('completed', complete_cb)
201 thread.connect('completed', self._remove_thread)
202 thread.connect('exception', exception_cb)
203 thread.setName(thread_id)
205 if len(self._running) < self._max_threads:
206 self._running.append(thread_id)
207 thread.start()
208 else:
209 running_names = ', '.join(self._running)
210 _log.debug('Threads %s running, adding %s to the queue',
211 running_names, thread_id)
212 self._thread_pool.append(thread)
214 self._thread_id += 1
215 return
218 class MainWindow:
220 The main application interface, GUI and Facebook interfacing functions
224 #------------------------------
225 # Information sending functions
226 #------------------------------
227 def sendupdate(self):
229 Sending status update to FB, if the user entered any
232 textfield = self.entry.get_buffer()
233 start = textfield.get_start_iter()
234 end = textfield.get_end_iter()
235 entry_text = textfield.get_text(start, end)
236 if entry_text != "":
237 # Warn user if status message is too long. If insist, send text
238 if len(entry_text) > MAX_MESSAGE_LENGTH:
239 warning_message = ("Your message is longer than %d " \
240 "characters and if submitted it is likely to be " \
241 "truncated by Facebook as:\n\"%s...\"\nInstead of " \
242 "sending this update, do you want to return to editing?" \
243 % (MAX_MESSAGE_LENGTH, entry_text[0:251]))
244 warning_dialog = gtk.MessageDialog(parent=self.window,
245 type=gtk.MESSAGE_WARNING,
246 flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
247 message_format="Your status update is too long.",
248 buttons=gtk.BUTTONS_YES_NO)
249 warning_dialog.format_secondary_text(warning_message)
250 response = warning_dialog.run()
251 warning_dialog.destroy()
252 # If user said yes, don't send just return to editing
253 if response == gtk.RESPONSE_YES:
254 return
256 _log.info('Sending status update: %s\n' % entry_text)
257 self.statusbar.pop(self.statusbar_context)
258 self.statusbar.push(self.statusbar_context, \
259 "Sending your status update")
260 self._facebook.status.set([entry_text], [self._facebook.uid])
262 # Empty entry field and status bar
263 textfield.set_text("")
264 self.statusbar.pop(self.statusbar_context)
266 # wait a little before getting new updates, so FB can catch up
267 time.sleep(2)
268 self.refresh()
270 #------------------------------
271 # Information pulling functions
272 #------------------------------
273 def get_friends_list(self):
275 Fetching list of friends' names (and the current user's) to
276 store and use when status updates are displayed
277 Threading callbacks: post_get_friends_list, except_get_friends_list
280 self.statusbar.pop(self.statusbar_context)
281 self.statusbar.push(self.statusbar_context, \
282 "Fetching list of friends")
283 query = ("SELECT uid, name, pic_square FROM user "\
284 "WHERE (uid IN (SELECT uid2 FROM friend WHERE uid1 = %d) "\
285 "OR uid = %d)" % (self._facebook.uid, self._facebook.uid))
286 _log.debug("Friend list query: %s" % (query))
287 friends = self._facebook.fql.query([query])
288 return friends
290 def post_get_friends_list(self, widget, results):
292 Callback function when friends list is successfully pulled
293 Makes dictionary of uid->friendsname, and pulls new statuses
296 friends = results
297 for friend in friends:
298 # In "friend" table UID is integer
299 self.friendsname[str(friend['uid'])] = friend['name']
300 self.friendsprofilepic[str(friend['uid'])] = \
301 friend['pic_square']
303 # Not all friends can be pulled, depends on their privacy settings
304 _log.info('%s has altogether %d friends in the database.' \
305 % (self.friendsname[str(self._facebook.uid)],
306 len(self.friendsname.keys())))
307 self.statusbar.pop(self.statusbar_context)
309 self.refresh()
310 return
312 def except_get_friends_list(self, widget, exception):
314 Callback if there's an exception raised while fetching friends' names
317 _log.error("Get friends exception: %s" % (str(exception)))
318 self.statusbar.pop(self.statusbar_context)
319 self.statusbar.push(self.statusbar_context, \
320 "Error while fetching friends' list")
322 def get_status_list(self):
324 Fetching new statuses using FQL query for user's friends (and
325 their own) between last fetch and now
326 Threading callbacks: post_get_status_list, except_get_status_list
329 # Halt point, only one status update may proceed at a time
330 # .release() is called at all 3 possible update finish:
331 # except_get_status_list, _post_get_cl_list, _except_get_cl_list
332 self.update_sema.acquire()
334 self.statusbar.pop(self.statusbar_context)
335 self.statusbar.push(self.statusbar_context, \
336 "Fetching status updates")
338 # If not first update then get new statuses since then
339 # otherwise get them since 5 days ago (i.e. long time ago)
340 if self._last_update > 0:
341 since = self._last_update
342 else:
343 now = int(time.time())
344 since = now - 5*24*60*60
345 till = int(time.time())
347 _log.info("Fetching status updates published between %s and %s" \
348 % (time.strftime("%c", time.localtime(since)),
349 time.strftime("%c", time.localtime(till))))
351 # User "stream" table to get status updates because the older "status"
352 # has too many bugs and limitations
353 # Status update is a post that has no attachment nor target
354 query = ("SELECT source_id, created_time, post_id, message " \
355 "FROM stream "\
356 "WHERE ((source_id IN (SELECT uid2 FROM friend WHERE uid1 = %d) "\
357 "OR source_id = %d) "\
358 "AND created_time > %d AND created_time <= %d "\
359 "AND attachment = '' AND target_id = '') "\
360 "ORDER BY created_time DESC "\
361 "LIMIT 100"
362 % (self._facebook.uid, self._facebook.uid, since, till))
363 _log.debug('Status list query: %s' % (query))
365 status = self._facebook.fql.query([query])
367 _log.info('Received %d new status' % (len(status)))
368 return [status, till]
370 def post_get_status_list(self, widget, results):
372 Callback function when new status updates are successfully pulled
373 Adds statuses to listview and initiates pulling comments & likes
374 restults: [status_updates_array, till_time]
377 _log.debug('Status updates successfully pulled.')
378 updates = results[0]
379 till = results[1]
381 # There are new updates
382 if len(updates) > 0:
383 updates.reverse()
384 for up in updates:
385 # source_id is the UID, and in "stream" it is string, not int
386 self.liststore.prepend((up['post_id'],
387 up['source_id'],
388 up['message'],
389 up['created_time'],
390 '0',
391 '0'))
392 # Scroll to newest status in view
393 model = self.treeview.get_model()
394 first_iter = model.get_iter_first()
395 first_path = model.get_path(first_iter)
396 self.treeview.scroll_to_cell(first_path)
397 self.new_status_notify()
399 self.statusbar.pop(self.statusbar_context)
401 # pull comments and likes too
402 self._threads.add_work(self._post_get_cl_list,
403 self._except_get_cl_list,
404 self._get_cl_list,
405 till)
406 return
408 def except_get_status_list(self, widget, exception):
410 Callback if there's an exception raised while fetching status list
413 _log.error("Get status list exception: %s" % (str(exception)))
414 self.statusbar.pop(self.statusbar_context)
415 self.statusbar.push(self.statusbar_context, \
416 "Error while fetching status updates")
417 # Finish, give semaphore back in case anyone's waiting
418 self.update_sema.release()
420 ### image download function
421 def _dl_profile_pic(self, uid, url):
423 Download user profile pictures
424 Threading callbacks: _post_dl_profile_pic, _exception_dl_profile_pic
425 url: picture's url
428 request = urllib2.Request(url=url)
429 _log.debug('Starting request of %s' % (url))
430 response = urllib2.urlopen(request)
431 data = response.read()
432 _log.debug('Request completed')
434 return (uid, data)
436 ### Results from the picture request
437 def _post_dl_profile_pic(self, widget, data):
439 Callback when profile picture is successfully downloaded
440 Replaces default picture with the users profile pic in status list
443 (uid, data) = data
445 loader = gtk.gdk.PixbufLoader()
446 loader.write(data)
447 loader.close()
449 user_pic = loader.get_pixbuf()
450 # Replace default picture
451 self._profilepics[uid] = user_pic
453 # Redraw to get new picture
454 self.treeview.queue_draw()
455 return
457 def _exception_dl_profile_pic(self, widget, exception):
459 Callback when there's an excetion during downloading profile picture
462 _log.debug('Exception trying to get a profile picture.')
463 _log.debug(str(exception))
464 return
466 ### get comments and likes
467 def _get_cl_list(self, till):
469 Fetch comments & likes for the listed statuses
470 Threading callbacks: _post_get_cl_list, _except_get_cl_list
471 till: time between self.last_update and till
474 _log.info('Pulling comments & likes for listed status updates')
475 self.statusbar.pop(self.statusbar_context)
476 self.statusbar.push(self.statusbar_context, \
477 "Fetching comments & likes")
479 # Preparing list of status update post_id for FQL query
480 post_id = []
481 for row in self.liststore:
482 post_id.append('post_id = "%s"' % (row[Columns.STATUSID]))
483 all_id = ' OR '.join(post_id)
485 query = ('SELECT post_id, comments, likes FROM stream WHERE ((%s) ' \
486 'AND updated_time > %d AND updated_time <= %d)' % \
487 (all_id, self._last_update, till))
488 _log.debug('Comments & Likes query: %s' % (query))
490 cl_list = self._facebook.fql.query([query])
492 return (cl_list, till)
494 ### Results from the picture request
495 def _post_get_cl_list(self, widget, data):
497 Callback when successfully fetched new comments and likes
498 Ends up here if complete 'refresh' is successfull
501 list = data[0]
502 till = data[1]
504 likes_list = {}
505 comments_list = {}
507 for item in list:
508 status_id = item['post_id']
509 likes_list[status_id] = str(item['likes']['count'])
510 comments_list[status_id] = str(item['comments']['count'])
512 for row in self.liststore:
513 rowstatus = row[Columns.STATUSID]
514 # have to check if post really exists, deleted post still
515 # show up in "status" table sometimes, not sure in "stream"
516 if rowstatus in likes_list.keys():
517 row[Columns.LIKES] = likes_list[rowstatus]
518 row[Columns.COMMENTS] = comments_list[rowstatus]
519 else:
520 _log.debug("Possible deleted status update: " \
521 "uid: %s, status_id: %s, user: %s, text: %s, time: %s" \
522 % (row[Columns.UID], rowstatus, \
523 self.friendsname[str(row[Columns.UID])], \
524 row[Columns.STATUS], row[Columns.DATETIME]))
526 # Update time of last update since this finished just fine
527 self._last_update = till
528 _log.info('Finished updating status messages, comments and likes.')
529 self.statusbar.pop(self.statusbar_context)
531 # Last update time in human readable format
532 update_time = time.strftime("%H:%M", time.localtime(till))
533 self.statusbar.push(self.statusbar_context, \
534 "Last update at %s" % (update_time))
536 # Finish, give semaphore back in case anyone's waiting
537 self.update_sema.release()
538 return
540 def _except_get_cl_list(self, widget, exception):
542 Callback if there' an exception during comments and likes fetch
545 _log.error('Exception while getting comments and likes')
546 _log.error(str(exception))
547 self.statusbar.pop(self.statusbar_context)
548 self.statusbar.push(self.statusbar_context, \
549 "Error while fetching comments & likes")
550 # Finish, give semaphore back in case anyone's waiting
551 self.update_sema.release()
552 return
554 #-----------------
555 # Helper functions
556 #-----------------
557 def count(self, text):
559 Count remaining characters in status update text
562 start = text.get_start_iter()
563 end = text.get_end_iter()
564 thetext = text.get_text(start, end)
565 self.count_label.set_text('(%d)' \
566 % (MAX_MESSAGE_LENGTH - len(thetext)))
567 return True
569 def set_auto_refresh(self):
571 Enable auto refresh statuses in pre-defined intervals
574 if self._refresh_id:
575 gobject.source_remove(self._refresh_id)
577 self._refresh_id = gobject.timeout_add(
578 self._prefs['auto_refresh_interval']*60*1000,
579 self.refresh)
580 _log.info("Auto-refresh enabled: %d minutes" \
581 % (self._prefs['auto_refresh_interval']))
583 def refresh(self, widget=None):
585 Queueing refresh in thread pool, subject to semaphores
588 _log.info('Queueing refresh now at %s' % (time.strftime('%H:%M:%S')))
589 self._threads.add_work(self.post_get_status_list,
590 self.except_get_status_list,
591 self.get_status_list)
592 return True
594 def status_format(self, column, cell, store, position):
596 Format how status update should look in list
599 uid = store.get_value(position, Columns.UID)
600 name = self.friendsname[uid]
601 status = store.get_value(position, Columns.STATUS)
602 posttime = store.get_value(position, Columns.DATETIME)
604 #replace characters that would choke the markup
605 status = re.sub(r'&', r'&amp;', status)
606 status = re.sub(r'<', r'&lt;', status)
607 status = re.sub(r'>', r'&gt;', status)
608 markup = ('<b>%s</b> %s\n(%s ago)' % \
609 (name, status, timesince.timesince(posttime)))
610 _log.debug('Marked up text: %s' % (markup))
611 cell.set_property('markup', markup)
612 return
614 def open_url(self, source, url):
616 Open url as new browser tab
619 _log.debug('Opening url: %s' % url)
620 import webbrowser
621 webbrowser.open_new_tab(url)
622 self.window.set_focus(self.entry)
624 def copy_status_to_clipboard(self, source, text):
626 Copy current status (together with poster name but without time)
627 to the clipboard
630 clipboard = gtk.Clipboard()
631 _log.debug('Copying to clipboard: %s' % (text))
632 clipboard.set_text(text)
634 def new_status_notify(self):
636 Handle notification upon new status updates
639 # Announce to the rest of the program
640 self.new_notify = True
642 # Set system tray icon to the notification version, if
643 # user is not looking at the window at the moment.
644 if not self.window.get_property('is-active'):
645 self._systray.set_from_pixbuf(self._app_icon_notify)
647 def expose_notify(self, widget, event, user=None):
649 Called when the window is shown
652 # Remove systray notification if there's any and
653 # user checks window: expose_event AND is-active = true
654 if self.new_notify and self.window.get_property('is-active'):
655 self.new_notify = False
656 self._systray.set_from_pixbuf(self._app_icon)
658 #--------------------
659 # Interface functions
660 #--------------------
661 def quit(self, widget):
663 Finish program
665 gtk.main_quit()
667 def systray_click(self, widget, user_param=None):
669 Callback when systray icon receives left-click
672 # Toggle visibility of main window
673 if self.window.get_property('visible'):
674 _log.debug('Hiding window')
675 x, y = self.window.get_position()
676 self._prefs['window_pos_x'] = x
677 self._prefs['window_pos_y'] = y
678 self.window.hide()
679 else:
680 x = self._prefs['window_pos_x']
681 y = self._prefs['window_pos_y']
682 _log.debug('Restoring window at (%d, %d)' % (x, y))
683 self.window.move(x, y)
684 self.window.deiconify()
685 self.window.present()
687 def create_grid(self):
689 Create list where each line consist of:
690 profile pic, status update, comments and likes count
693 # List for storing all relevant info
694 self.liststore = gtk.ListStore(gobject.TYPE_STRING,
695 gobject.TYPE_STRING,
696 gobject.TYPE_STRING,
697 gobject.TYPE_INT,
698 gobject.TYPE_STRING,
699 gobject.TYPE_STRING)
701 # Short items by time, newest first
702 self.sorter = gtk.TreeModelSort(self.liststore)
703 self.sorter.set_sort_column_id(Columns.DATETIME, gtk.SORT_DESCENDING)
704 self.treeview = gtk.TreeView(self.sorter)
706 # No headers
707 self.treeview.set_property('headers-visible', False)
708 # Alternating background colours for lines
709 self.treeview.set_rules_hint(True)
711 # Column showing profile picture
712 profilepic_renderer = gtk.CellRendererPixbuf()
713 profilepic_column = gtk.TreeViewColumn('Profilepic', \
714 profilepic_renderer)
715 profilepic_column.set_fixed_width(55)
716 profilepic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
717 profilepic_column.set_cell_data_func(profilepic_renderer,
718 self._cell_renderer_profilepic)
719 self.treeview.append_column(profilepic_column)
721 # Column showing status text
722 self.status_renderer = gtk.CellRendererText()
723 # wrapping: pango.WRAP_WORD == 0, don't need to import pango for that
724 self.status_renderer.set_property('wrap-mode', 0)
725 self.status_renderer.set_property('wrap-width', 320)
726 self.status_renderer.set_property('width', 320)
727 self.status_column = gtk.TreeViewColumn('Message', \
728 self.status_renderer, text=1)
729 self.status_column.set_cell_data_func(self.status_renderer, \
730 self.status_format)
731 self.treeview.append_column(self.status_column)
733 # Showing the number of comments
734 comments_renderer = gtk.CellRendererText()
735 comments_column = gtk.TreeViewColumn('Comments', \
736 comments_renderer, text=1)
737 comments_column.set_cell_data_func(comments_renderer, \
738 self._cell_renderer_comments)
739 self.treeview.append_column(comments_column)
741 # Showing the comments icon
742 commentspic_renderer = gtk.CellRendererPixbuf()
743 commentspic_column = gtk.TreeViewColumn('CommentsPic', \
744 commentspic_renderer)
745 commentspic_column.set_cell_data_func(commentspic_renderer, \
746 self._cell_renderer_commentspic)
747 commentspic_column.set_fixed_width(28)
748 commentspic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
749 self.treeview.append_column(commentspic_column)
751 # Showing the number of likes
752 likes_renderer = gtk.CellRendererText()
753 likes_column = gtk.TreeViewColumn('Likes', \
754 likes_renderer, text=1)
755 likes_column.set_cell_data_func(likes_renderer, \
756 self._cell_renderer_likes)
757 self.treeview.append_column(likes_column)
759 # Showing the likes icon
760 likespic_renderer = gtk.CellRendererPixbuf()
761 likespic_column = gtk.TreeViewColumn('Likespic', \
762 likespic_renderer)
763 likespic_column.set_cell_data_func(likespic_renderer, \
764 self._cell_renderer_likespic)
765 likespic_column.set_fixed_width(28)
766 likespic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
767 self.treeview.append_column(likespic_column)
769 self.treeview.set_resize_mode(gtk.RESIZE_IMMEDIATE)
771 self.treeview.connect('row-activated', self.open_status_web)
772 self.treeview.connect('button-press-event', self.click_status)
774 def create_menubar(self):
776 Showing the app's (very basic) menubar
779 refresh_action = gtk.Action('Refresh', '_Refresh',
780 'Get new status updates', gtk.STOCK_REFRESH)
781 refresh_action.connect('activate', self.refresh)
783 quit_action = gtk.Action('Quit', '_Quit',
784 'Exit %s' % (APPNAME), gtk.STOCK_QUIT)
785 quit_action.connect('activate', self.quit)
787 about_action = gtk.Action('About', '_About', 'About %s' % (APPNAME),
788 gtk.STOCK_ABOUT)
789 about_action.connect('activate', self.show_about)
791 self.action_group = gtk.ActionGroup('MainMenu')
792 self.action_group.add_action_with_accel(refresh_action, 'F5')
793 # accel = None to use the default acceletator
794 self.action_group.add_action_with_accel(quit_action, None)
795 self.action_group.add_action(about_action)
797 uimanager = gtk.UIManager()
798 uimanager.insert_action_group(self.action_group, 0)
799 ui = '''
800 <ui>
801 <menubar name="MainMenu">
802 <menuitem action="Quit" />
803 <separator />
804 <menuitem action="Refresh" />
805 <separator />
806 <menuitem action="About" />
807 </menubar>
808 </ui>
810 uimanager.add_ui_from_string(ui)
811 self.main_menu = uimanager.get_widget('/MainMenu')
812 return
814 def show_about(self, widget):
816 Show the about dialog
819 about_window = gtk.AboutDialog()
820 about_window.set_program_name(APPNAME)
821 about_window.set_version(VERSION)
822 about_window.set_copyright('2009 Gergely Imreh <imrehg@gmail.com>')
823 about_window.set_license(MIT)
824 about_window.set_website('http://imrehg.github.com/minibook/')
825 about_window.set_website_label('%s on GitHub' % (APPNAME))
826 about_window.connect('close', self.close_dialog)
827 about_window.run()
828 about_window.hide()
830 def close_dialog(self, user_data=None):
832 Hide the dialog window
835 return True
837 def open_status_web(self, treeview, path, view_column, user_data=None):
839 Callback to open status update in web browser when received left click
842 model = treeview.get_model()
843 if not model:
844 return
846 iter = model.get_iter(path)
847 uid = model.get_value(iter, Columns.UID)
848 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
849 status_url = ('http://www.facebook.com/profile.php?' \
850 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
851 self.open_url(path, status_url)
852 return
854 def click_status(self, treeview, event, user_data=None):
856 Callback when a mouse click event occurs on one of the rows
859 _log.debug('Clicked on status list')
860 if event.button != 3:
861 # Only right clicks are processed
862 return False
863 _log.debug('right-click received')
865 x = int(event.x)
866 y = int(event.y)
868 pth = treeview.get_path_at_pos(x, y)
869 if not pth:
870 return False
872 path, col, cell_x, cell_y = pth
873 treeview.grab_focus()
874 treeview.set_cursor(path, col, 0)
876 self.show_status_popup(treeview, event)
877 return True
879 def show_status_popup(self, treeview, event, user_data=None):
881 Show popup menu relevant to the clicked status update
884 _log.debug('Show popup menu')
885 cursor = treeview.get_cursor()
886 if not cursor:
887 return
888 model = treeview.get_model()
889 if not model:
890 return
892 path = cursor[0]
893 iter = model.get_iter(path)
895 popup_menu = gtk.Menu()
896 popup_menu.set_screen(self.window.get_screen())
898 open_menu_items = []
900 # Open this status update in browser
901 uid = model.get_value(iter, Columns.UID)
902 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
903 url = ('http://www.facebook.com/profile.php?' \
904 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
905 item_name = 'This status'
906 item = gtk.MenuItem(item_name)
907 item.connect('activate', self.open_url, url)
908 open_menu_items.append(item)
910 # Open user's wall in browser
911 url = ('http://www.facebook.com/profile.php?' \
912 'id=%s' % (uid))
913 item_name = 'User wall'
914 item = gtk.MenuItem(item_name)
915 item.connect('activate', self.open_url, url)
916 open_menu_items.append(item)
918 # Open user's info in browser
919 url = ('http://www.facebook.com/profile.php?' \
920 'id=%s&v=info' % (uid))
921 item_name = 'User info'
922 item = gtk.MenuItem(item_name)
923 item.connect('activate', self.open_url, url)
924 open_menu_items.append(item)
926 # Open user's photos in browser
927 url = ('http://www.facebook.com/profile.php?' \
928 'id=%s&v=photos' % (uid))
929 item_name = 'User photos'
930 item = gtk.MenuItem(item_name)
931 item.connect('activate', self.open_url, url)
932 open_menu_items.append(item)
934 open_menu = gtk.Menu()
935 for item in open_menu_items:
936 open_menu.append(item)
938 # Menu item for "open in browser" menu
939 open_item = gtk.ImageMenuItem('Open in browser')
940 open_item.get_image().set_from_stock(gtk.STOCK_GO_FORWARD, \
941 gtk.ICON_SIZE_MENU)
942 open_item.set_submenu(open_menu)
943 popup_menu.append(open_item)
945 # Menu item to copy status message to clipboard
946 message = model.get_value(iter, Columns.STATUS)
947 name = self.friendsname[str(uid)]
948 text = ("%s %s" % (name, message))
949 copy_item = gtk.ImageMenuItem('Copy status')
950 copy_item.get_image().set_from_stock(gtk.STOCK_COPY, \
951 gtk.ICON_SIZE_MENU)
952 copy_item.connect('activate', self.copy_status_to_clipboard, text)
953 popup_menu.append(copy_item)
955 popup_menu.show_all()
957 if event:
958 b = event.button
959 t = event.time
960 else:
961 b = 1
962 t = 0
964 popup_menu.popup(None, None, None, b, t)
966 return True
968 def _cell_renderer_profilepic(self, column, cell, store, position):
970 Showing profile picture in status update list
971 Use default picture if we don't (can't) have the user's
972 If first time trying to display it try to download and display default
975 uid = str(store.get_value(position, Columns.UID))
976 if not uid in self._profilepics:
977 profilepicurl = self.friendsprofilepic[uid]
978 if profilepicurl:
979 _log.debug('%s does not have profile picture stored, ' \
980 'queuing fetch from %s' % (uid, profilepicurl))
981 self._threads.add_work(self._post_dl_profile_pic,
982 self._exception_dl_profile_pic,
983 self._dl_profile_pic,
984 uid,
985 profilepicurl)
986 else:
987 _log.debug('%s does not have profile picture set, ' % (uid))
989 self._profilepics[uid] = self._default_profilepic
991 cell.set_property('pixbuf', self._profilepics[uid])
993 return
995 def _cell_renderer_comments(self, column, cell, store, position):
997 Cell renderer for the number of comments
1000 comments = int(store.get_value(position, Columns.COMMENTS))
1001 if comments > 0:
1002 cell.set_property('text', str(comments))
1003 else:
1004 cell.set_property('text', '')
1006 def _cell_renderer_commentspic(self, column, cell, store, position):
1008 Cell renderer for comments picture if there are any comments
1011 comments = int(store.get_value(position, Columns.COMMENTS))
1012 if comments > 0:
1013 cell.set_property('pixbuf', self.commentspic)
1014 else:
1015 cell.set_property('pixbuf', None)
1017 def _cell_renderer_likes(self, column, cell, store, position):
1019 Cell renderer for number of likes
1022 likes = int(store.get_value(position, Columns.LIKES))
1023 if likes > 0:
1024 cell.set_property('text', str(likes))
1025 else:
1026 cell.set_property('text', '')
1028 def _cell_renderer_likespic(self, column, cell, store, position):
1030 Cell renderer for likess picture if there are any likes
1033 likes = int(store.get_value(position, Columns.LIKES))
1034 if likes > 0:
1035 cell.set_property('pixbuf', self.likespic)
1036 else:
1037 cell.set_property('pixbuf', None)
1039 #------------------
1040 # Main Window start
1041 #------------------
1042 def __init__(self, facebook):
1044 Creating main window and setting up relevant variables
1047 global spelling_support
1049 # Connect to facebook object
1050 self._facebook = facebook
1052 # Picture shown if cannot get a user's own profile picture
1053 unknown_user = 'pixmaps/unknown_user.png'
1054 if unknown_user:
1055 self._default_profilepic = gtk.gdk.pixbuf_new_from_file(
1056 unknown_user)
1057 else:
1058 self._default_profilepic = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
1059 has_alpha=False, bits_per_sample=8, width=50, height=50)
1061 # Icons for "comments" and "likes"
1062 self.commentspic = gtk.gdk.pixbuf_new_from_file('pixmaps/comments.png')
1063 self.likespic = gtk.gdk.pixbuf_new_from_file('pixmaps/likes.png')
1065 self.friendsname = {}
1066 self.friendsprofilepic = {}
1067 self._profilepics = {}
1068 # Semaphore to let only one status update proceed at a time
1069 self.update_sema = threading.BoundedSemaphore(value=1)
1071 # create a new window
1072 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
1073 self.window.set_size_request(480, 250)
1074 self.window.set_title("Minibook")
1075 self.window.connect("delete_event", lambda w, e: gtk.main_quit())
1077 vbox = gtk.VBox(False, 0)
1078 self.window.add(vbox)
1080 # Menubar
1081 self.create_menubar()
1082 vbox.pack_start(self.main_menu, False, True, 0)
1084 # Status update display window
1085 self.create_grid()
1086 self.statuslist_window = gtk.ScrolledWindow()
1087 self.statuslist_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
1088 self.statuslist_window.add(self.treeview)
1089 vbox.pack_start(self.statuslist_window, True, True, 0)
1091 # Area around the status update entry box with labels and button
1092 label_box = gtk.HBox(False, 0)
1093 label = gtk.Label("What's on your mind?")
1094 self.count_label = gtk.Label("(%d)" % (MAX_MESSAGE_LENGTH))
1095 label_box.pack_start(label)
1096 label_box.pack_start(self.count_label)
1098 self.entry = gtk.TextView()
1099 text = self.entry.get_buffer()
1100 text.connect('changed', self.count)
1101 text_box = gtk.VBox(True, 0)
1102 text_box.pack_start(label_box)
1103 text_box.pack_start(self.entry, True, True, 4)
1105 update_button = gtk.Button(stock=gtk.STOCK_ADD)
1106 update_button.connect("clicked", lambda w: self.sendupdate())
1108 update_box = gtk.HBox(False, 0)
1109 update_box.pack_start(text_box, expand=True, fill=True,
1110 padding=0)
1111 update_box.pack_start(update_button, expand=False, fill=False,
1112 padding=0)
1114 vbox.pack_start(update_box, False, True, 0)
1116 # Statusbar
1117 self.statusbar = gtk.Statusbar()
1118 vbox.pack_start(self.statusbar, False, False, 0)
1119 self.statusbar_context = self.statusbar.get_context_id(
1120 '%s is here.' % (APPNAME))
1122 # Set up spell checking if it is available
1123 if spelling_support:
1124 try:
1125 spelling = gtkspell.Spell(self.entry, 'en')
1126 except:
1127 spelling_support = False
1129 # Show window
1130 self.window.show_all()
1132 # Set up systray icon
1133 _app_icon_file = 'pixmaps/minibook.png'
1134 _app_icon_notify_file = 'pixmaps/minibook_notify.png'
1135 self._app_icon = gtk.gdk.pixbuf_new_from_file(_app_icon_file)
1136 self._app_icon_notify = \
1137 gtk.gdk.pixbuf_new_from_file(_app_icon_notify_file)
1138 self._systray = gtk.StatusIcon()
1139 self._systray.set_from_pixbuf(self._app_icon)
1140 self._systray.set_tooltip('%s\n' \
1141 'Left-click: toggle window hiding' % (APPNAME))
1142 self._systray.connect('activate', self.systray_click)
1143 self._systray.set_visible(True)
1145 self.window.set_icon(self._app_icon)
1147 # Enable thread manager
1148 self._threads = _ThreadManager()
1150 # Start to set up preferences
1151 self._prefs = {}
1152 x, y = self.window.get_position()
1153 self._prefs['window_pos_x'] = x
1154 self._prefs['window_pos_y'] = y
1155 self._prefs['auto_refresh_interval'] = 5
1157 # Last update: never, start first one
1158 self._last_update = 0
1159 self._threads.add_work(self.post_get_friends_list,
1160 self.except_get_friends_list,
1161 self.get_friends_list)
1163 # Enable auto-refresh
1164 self._refresh_id = None
1165 self.set_auto_refresh()
1167 # Storing notification state
1168 self.new_notify = False
1169 # Used to remove systray notification on window show
1170 self.window.connect("expose-event", self.expose_notify)
1172 def main(facebook):
1174 Main function
1177 gtk.main()
1178 gtk.gdk.threads_leave()
1179 _log.debug('Exiting')
1180 return 0
1182 if __name__ == "__main__":
1184 Set up facebook object, login and start main window
1187 # Currently cannot include the registered app's
1188 # api_key and secret_key, thus have to save them separately
1189 # Here those keys are loaded
1190 try:
1191 config_file = open("config", "r")
1192 api_key = config_file.readline()[:-1]
1193 secret_key = config_file.readline()[:-1]
1194 _log.debug('Config file loaded successfully')
1195 except Exception, e:
1196 _log.critical('Error while loading config file: %s' % (str(e)))
1197 exit(1)
1199 facebook = Facebook(api_key, secret_key)
1200 try:
1201 facebook.auth.createToken()
1202 except:
1203 # Like catch errors like
1204 # http://bugs.developers.facebook.com/show_bug.cgi?id=5474
1205 # and http://bugs.developers.facebook.com/show_bug.cgi?id=5472
1206 _log.critical("Error on Facebook's side, " \
1207 "try starting application later")
1208 exit(1)
1210 _log.debug('Showing Facebook login page in default browser.')
1211 facebook.login()
1213 # Delay dialog to allow for login in browser
1214 got_session = False
1215 while not got_session:
1216 dia = gtk.Dialog('minibook: login',
1217 None,
1218 gtk.DIALOG_MODAL | \
1219 gtk.DIALOG_DESTROY_WITH_PARENT | \
1220 gtk.DIALOG_NO_SEPARATOR,
1221 ("Logged in", gtk.RESPONSE_OK, \
1222 gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL))
1223 label = gtk.Label("%s is opening your web browser to " \
1224 "log in Facebook.\nWhen finished, click 'Logged in', " \
1225 "or you can cancel now." % (APPNAME))
1226 dia.vbox.pack_start(label, True, True, 10)
1227 label.show()
1228 dia.show()
1229 result = dia.run()
1230 # Cancel login and close app
1231 if result == gtk.RESPONSE_CANCEL:
1232 _log.debug('Exiting before Facebook login.')
1233 exit(0)
1234 dia.destroy()
1235 try:
1236 facebook.auth.getSession()
1237 got_session = True
1238 except:
1239 # Likely clicked "logged in" but not logged in yet, start over
1240 pass
1242 _log.info('Session Key: %s' % (facebook.session_key))
1243 _log.info('User\'s UID: %d' % (facebook.uid))
1245 # Start main window and app
1246 MainWindow(facebook)
1247 main(facebook)