fix: ability to filter update text box, Closes #30
[minibook.git] / minibook.py
blob1a4065be837f9f2affcf5cfcf30a176edf027e6b
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
44 import string
46 gobject.threads_init()
47 gtk.gdk.threads_init()
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 self.count_label.set_text('(%d)' \
563 % (MAX_MESSAGE_LENGTH - text.get_char_count()))
564 return True
566 def insert_cb(self, textbuffer, iter, text, length, *args):
568 Callback when text is typed/inserted into status update box
571 # Without this garbage comes in with text
572 text = text[:length]
573 # Stop default emission
574 textbuffer.emit_stop_by_name("insert_text")
575 # For some reason cannot pass on iter properly, send pos instead
576 pos = iter.get_offset()
577 self.insert(textbuffer, text, pos)
579 def insert(self, textbuffer, text, pos):
581 Text replacement function upon type into status update box
582 On receiving an "enter" initiate status update send
585 # If "Enter" pressed, submit status
586 if (text == "\n"):
587 self.sendupdate()
588 return True
590 # Replacement rules
591 # remove tabs
592 text = string.replace(text, "\t", "")
593 # convert multiple lines into a single line (e.g. when text pasted)
594 text = string.replace(text, "\n", " ")
596 # Insert text
597 start = textbuffer.get_start_iter()
598 end = textbuffer.get_end_iter()
599 orig_text = textbuffer.get_text(start, end)
600 new_text = orig_text[:pos] + text + orig_text[pos:]
602 # Avoid recursive calls triggered by set_text
603 textbuffer.handler_block(self.insert_sig)
604 # Insert new text and move cursor to end if insert
605 textbuffer.set_text(new_text)
606 iter = textbuffer.get_iter_at_offset(pos+len(text))
607 textbuffer.place_cursor(iter)
608 # Re-enable signal
609 textbuffer.handler_unblock(self.insert_sig)
610 return True
612 def set_auto_refresh(self):
614 Enable auto refresh statuses in pre-defined intervals
617 if self._refresh_id:
618 gobject.source_remove(self._refresh_id)
620 self._refresh_id = gobject.timeout_add(
621 self._prefs['auto_refresh_interval']*60*1000,
622 self.refresh)
623 _log.info("Auto-refresh enabled: %d minutes" \
624 % (self._prefs['auto_refresh_interval']))
626 def refresh(self, widget=None):
628 Queueing refresh in thread pool, subject to semaphores
631 _log.info('Queueing refresh now at %s' % (time.strftime('%H:%M:%S')))
632 self._threads.add_work(self.post_get_status_list,
633 self.except_get_status_list,
634 self.get_status_list)
635 return True
637 def status_format(self, column, cell, store, position):
639 Format how status update should look in list
642 uid = store.get_value(position, Columns.UID)
643 name = self.friendsname[uid]
644 status = store.get_value(position, Columns.STATUS)
645 posttime = store.get_value(position, Columns.DATETIME)
647 #replace characters that would choke the markup
648 status = re.sub(r'&', r'&amp;', status)
649 status = re.sub(r'<', r'&lt;', status)
650 status = re.sub(r'>', r'&gt;', status)
651 markup = ('<b>%s</b> %s\n(%s ago)' % \
652 (name, status, timesince.timesince(posttime)))
653 _log.debug('Marked up text: %s' % (markup))
654 cell.set_property('markup', markup)
655 return
657 def open_url(self, source, url):
659 Open url as new browser tab
662 _log.debug('Opening url: %s' % url)
663 import webbrowser
664 webbrowser.open_new_tab(url)
665 self.window.set_focus(self.entry)
667 def copy_status_to_clipboard(self, source, text):
669 Copy current status (together with poster name but without time)
670 to the clipboard
673 clipboard = gtk.Clipboard()
674 _log.debug('Copying to clipboard: %s' % (text))
675 clipboard.set_text(text)
677 def new_status_notify(self):
679 Handle notification upon new status updates
682 # Announce to the rest of the program
683 self.new_notify = True
685 # Set system tray icon to the notification version, if
686 # user is not looking at the window at the moment.
687 if not self.window.get_property('is-active'):
688 self._systray.set_from_pixbuf(self._app_icon_notify)
690 def remove_notify(self, widget, event, user=None):
692 Called when the window is shown to remove systray notification
695 # Remove systray notification if there's any and
696 # user checks window: expose_event AND is-active = true
697 if self.new_notify and self.window.is_active():
698 self.new_notify = False
699 self._systray.set_from_pixbuf(self._app_icon)
701 #--------------------
702 # Interface functions
703 #--------------------
704 def quit(self, widget):
706 Finish program
708 gtk.main_quit()
710 def systray_click(self, widget, user_param=None):
712 Callback when systray icon receives left-click
715 # Toggle visibility of main window
716 if self.window.get_property('visible'):
717 _log.debug('Hiding window')
718 x, y = self.window.get_position()
719 self._prefs['window_pos_x'] = x
720 self._prefs['window_pos_y'] = y
721 self.window.hide()
722 else:
723 x = self._prefs['window_pos_x']
724 y = self._prefs['window_pos_y']
725 _log.debug('Restoring window at (%d, %d)' % (x, y))
726 self.window.move(x, y)
727 self.window.deiconify()
728 self.window.present()
730 def create_grid(self):
732 Create list where each line consist of:
733 profile pic, status update, comments and likes count
736 # List for storing all relevant info
737 self.liststore = gtk.ListStore(gobject.TYPE_STRING,
738 gobject.TYPE_STRING,
739 gobject.TYPE_STRING,
740 gobject.TYPE_INT,
741 gobject.TYPE_STRING,
742 gobject.TYPE_STRING)
744 # Short items by time, newest first
745 self.sorter = gtk.TreeModelSort(self.liststore)
746 self.sorter.set_sort_column_id(Columns.DATETIME, gtk.SORT_DESCENDING)
747 self.treeview = gtk.TreeView(self.sorter)
749 # No headers
750 self.treeview.set_property('headers-visible', False)
751 # Alternating background colours for lines
752 self.treeview.set_rules_hint(True)
754 # Column showing profile picture
755 profilepic_renderer = gtk.CellRendererPixbuf()
756 profilepic_column = gtk.TreeViewColumn('Profilepic', \
757 profilepic_renderer)
758 profilepic_column.set_fixed_width(55)
759 profilepic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
760 profilepic_column.set_cell_data_func(profilepic_renderer,
761 self._cell_renderer_profilepic)
762 self.treeview.append_column(profilepic_column)
764 # Column showing status text
765 self.status_renderer = gtk.CellRendererText()
766 # wrapping: pango.WRAP_WORD == 0, don't need to import pango for that
767 self.status_renderer.set_property('wrap-mode', 0)
768 self.status_renderer.set_property('wrap-width', 320)
769 self.status_renderer.set_property('width', 320)
770 self.status_column = gtk.TreeViewColumn('Message', \
771 self.status_renderer, text=1)
772 self.status_column.set_cell_data_func(self.status_renderer, \
773 self.status_format)
774 self.treeview.append_column(self.status_column)
776 # Showing the number of comments
777 comments_renderer = gtk.CellRendererText()
778 comments_column = gtk.TreeViewColumn('Comments', \
779 comments_renderer, text=1)
780 comments_column.set_cell_data_func(comments_renderer, \
781 self._cell_renderer_comments)
782 self.treeview.append_column(comments_column)
784 # Showing the comments icon
785 commentspic_renderer = gtk.CellRendererPixbuf()
786 commentspic_column = gtk.TreeViewColumn('CommentsPic', \
787 commentspic_renderer)
788 commentspic_column.set_cell_data_func(commentspic_renderer, \
789 self._cell_renderer_commentspic)
790 commentspic_column.set_fixed_width(28)
791 commentspic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
792 self.treeview.append_column(commentspic_column)
794 # Showing the number of likes
795 likes_renderer = gtk.CellRendererText()
796 likes_column = gtk.TreeViewColumn('Likes', \
797 likes_renderer, text=1)
798 likes_column.set_cell_data_func(likes_renderer, \
799 self._cell_renderer_likes)
800 self.treeview.append_column(likes_column)
802 # Showing the likes icon
803 likespic_renderer = gtk.CellRendererPixbuf()
804 likespic_column = gtk.TreeViewColumn('Likespic', \
805 likespic_renderer)
806 likespic_column.set_cell_data_func(likespic_renderer, \
807 self._cell_renderer_likespic)
808 likespic_column.set_fixed_width(28)
809 likespic_column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
810 self.treeview.append_column(likespic_column)
812 self.treeview.set_resize_mode(gtk.RESIZE_IMMEDIATE)
814 self.treeview.connect('row-activated', self.open_status_web)
815 self.treeview.connect('button-press-event', self.click_status)
817 def create_menubar(self):
819 Showing the app's (very basic) menubar
822 refresh_action = gtk.Action('Refresh', '_Refresh',
823 'Get new status updates', gtk.STOCK_REFRESH)
824 refresh_action.connect('activate', self.refresh)
826 quit_action = gtk.Action('Quit', '_Quit',
827 'Exit %s' % (APPNAME), gtk.STOCK_QUIT)
828 quit_action.connect('activate', self.quit)
830 about_action = gtk.Action('About', '_About', 'About %s' % (APPNAME),
831 gtk.STOCK_ABOUT)
832 about_action.connect('activate', self.show_about)
834 self.action_group = gtk.ActionGroup('MainMenu')
835 self.action_group.add_action_with_accel(refresh_action, 'F5')
836 # accel = None to use the default acceletator
837 self.action_group.add_action_with_accel(quit_action, None)
838 self.action_group.add_action(about_action)
840 uimanager = gtk.UIManager()
841 uimanager.insert_action_group(self.action_group, 0)
842 ui = '''
843 <ui>
844 <menubar name="MainMenu">
845 <menuitem action="Quit" />
846 <separator />
847 <menuitem action="Refresh" />
848 <separator />
849 <menuitem action="About" />
850 </menubar>
851 </ui>
853 uimanager.add_ui_from_string(ui)
854 self.main_menu = uimanager.get_widget('/MainMenu')
855 return
857 def show_about(self, widget):
859 Show the about dialog
862 about_window = gtk.AboutDialog()
863 about_window.set_program_name(APPNAME)
864 about_window.set_version(VERSION)
865 about_window.set_copyright('2009 Gergely Imreh <imrehg@gmail.com>')
866 about_window.set_license(MIT)
867 about_window.set_website('http://imrehg.github.com/minibook/')
868 about_window.set_website_label('%s on GitHub' % (APPNAME))
869 about_window.connect('close', self.close_dialog)
870 about_window.run()
871 about_window.hide()
873 def close_dialog(self, user_data=None):
875 Hide the dialog window
878 return True
880 def open_status_web(self, treeview, path, view_column, user_data=None):
882 Callback to open status update in web browser when received left click
885 model = treeview.get_model()
886 if not model:
887 return
889 iter = model.get_iter(path)
890 uid = model.get_value(iter, Columns.UID)
891 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
892 status_url = ('http://www.facebook.com/profile.php?' \
893 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
894 self.open_url(path, status_url)
895 return
897 def click_status(self, treeview, event, user_data=None):
899 Callback when a mouse click event occurs on one of the rows
902 _log.debug('Clicked on status list')
903 if event.button != 3:
904 # Only right clicks are processed
905 return False
906 _log.debug('right-click received')
908 x = int(event.x)
909 y = int(event.y)
911 pth = treeview.get_path_at_pos(x, y)
912 if not pth:
913 return False
915 path, col, cell_x, cell_y = pth
916 treeview.grab_focus()
917 treeview.set_cursor(path, col, 0)
919 self.show_status_popup(treeview, event)
920 return True
922 def show_status_popup(self, treeview, event, user_data=None):
924 Show popup menu relevant to the clicked status update
927 _log.debug('Show popup menu')
928 cursor = treeview.get_cursor()
929 if not cursor:
930 return
931 model = treeview.get_model()
932 if not model:
933 return
935 path = cursor[0]
936 iter = model.get_iter(path)
938 popup_menu = gtk.Menu()
939 popup_menu.set_screen(self.window.get_screen())
941 open_menu_items = []
943 # Open this status update in browser
944 uid = model.get_value(iter, Columns.UID)
945 status_id = model.get_value(iter, Columns.STATUSID).split("_")[1]
946 url = ('http://www.facebook.com/profile.php?' \
947 'id=%s&v=feed&story_fbid=%s' % (uid, status_id))
948 item_name = 'This status'
949 item = gtk.MenuItem(item_name)
950 item.connect('activate', self.open_url, url)
951 open_menu_items.append(item)
953 # Open user's wall in browser
954 url = ('http://www.facebook.com/profile.php?' \
955 'id=%s' % (uid))
956 item_name = 'User wall'
957 item = gtk.MenuItem(item_name)
958 item.connect('activate', self.open_url, url)
959 open_menu_items.append(item)
961 # Open user's info in browser
962 url = ('http://www.facebook.com/profile.php?' \
963 'id=%s&v=info' % (uid))
964 item_name = 'User info'
965 item = gtk.MenuItem(item_name)
966 item.connect('activate', self.open_url, url)
967 open_menu_items.append(item)
969 # Open user's photos in browser
970 url = ('http://www.facebook.com/profile.php?' \
971 'id=%s&v=photos' % (uid))
972 item_name = 'User photos'
973 item = gtk.MenuItem(item_name)
974 item.connect('activate', self.open_url, url)
975 open_menu_items.append(item)
977 open_menu = gtk.Menu()
978 for item in open_menu_items:
979 open_menu.append(item)
981 # Menu item for "open in browser" menu
982 open_item = gtk.ImageMenuItem('Open in browser')
983 open_item.get_image().set_from_stock(gtk.STOCK_GO_FORWARD, \
984 gtk.ICON_SIZE_MENU)
985 open_item.set_submenu(open_menu)
986 popup_menu.append(open_item)
988 # Menu item to copy status message to clipboard
989 message = model.get_value(iter, Columns.STATUS)
990 name = self.friendsname[str(uid)]
991 text = ("%s %s" % (name, message))
992 copy_item = gtk.ImageMenuItem('Copy status')
993 copy_item.get_image().set_from_stock(gtk.STOCK_COPY, \
994 gtk.ICON_SIZE_MENU)
995 copy_item.connect('activate', self.copy_status_to_clipboard, text)
996 popup_menu.append(copy_item)
998 popup_menu.show_all()
1000 if event:
1001 b = event.button
1002 t = event.time
1003 else:
1004 b = 1
1005 t = 0
1007 popup_menu.popup(None, None, None, b, t)
1009 return True
1011 def _cell_renderer_profilepic(self, column, cell, store, position):
1013 Showing profile picture in status update list
1014 Use default picture if we don't (can't) have the user's
1015 If first time trying to display it try to download and display default
1018 uid = str(store.get_value(position, Columns.UID))
1019 if not uid in self._profilepics:
1020 profilepicurl = self.friendsprofilepic[uid]
1021 if profilepicurl:
1022 _log.debug('%s does not have profile picture stored, ' \
1023 'queuing fetch from %s' % (uid, profilepicurl))
1024 self._threads.add_work(self._post_dl_profile_pic,
1025 self._exception_dl_profile_pic,
1026 self._dl_profile_pic,
1027 uid,
1028 profilepicurl)
1029 else:
1030 _log.debug('%s does not have profile picture set, ' % (uid))
1032 self._profilepics[uid] = self._default_profilepic
1034 cell.set_property('pixbuf', self._profilepics[uid])
1036 return
1038 def _cell_renderer_comments(self, column, cell, store, position):
1040 Cell renderer for the number of comments
1043 comments = int(store.get_value(position, Columns.COMMENTS))
1044 if comments > 0:
1045 cell.set_property('text', str(comments))
1046 else:
1047 cell.set_property('text', '')
1049 def _cell_renderer_commentspic(self, column, cell, store, position):
1051 Cell renderer for comments picture if there are any comments
1054 comments = int(store.get_value(position, Columns.COMMENTS))
1055 if comments > 0:
1056 cell.set_property('pixbuf', self.commentspic)
1057 else:
1058 cell.set_property('pixbuf', None)
1060 def _cell_renderer_likes(self, column, cell, store, position):
1062 Cell renderer for number of likes
1065 likes = int(store.get_value(position, Columns.LIKES))
1066 if likes > 0:
1067 cell.set_property('text', str(likes))
1068 else:
1069 cell.set_property('text', '')
1071 def _cell_renderer_likespic(self, column, cell, store, position):
1073 Cell renderer for likess picture if there are any likes
1076 likes = int(store.get_value(position, Columns.LIKES))
1077 if likes > 0:
1078 cell.set_property('pixbuf', self.likespic)
1079 else:
1080 cell.set_property('pixbuf', None)
1082 #------------------
1083 # Main Window start
1084 #------------------
1085 def __init__(self, facebook):
1087 Creating main window and setting up relevant variables
1090 global spelling_support
1092 # Connect to facebook object
1093 self._facebook = facebook
1095 # Picture shown if cannot get a user's own profile picture
1096 unknown_user = 'pixmaps/unknown_user.png'
1097 if unknown_user:
1098 self._default_profilepic = gtk.gdk.pixbuf_new_from_file(
1099 unknown_user)
1100 else:
1101 self._default_profilepic = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
1102 has_alpha=False, bits_per_sample=8, width=50, height=50)
1104 # Icons for "comments" and "likes"
1105 self.commentspic = gtk.gdk.pixbuf_new_from_file('pixmaps/comments.png')
1106 self.likespic = gtk.gdk.pixbuf_new_from_file('pixmaps/likes.png')
1108 self.friendsname = {}
1109 self.friendsprofilepic = {}
1110 self._profilepics = {}
1111 # Semaphore to let only one status update proceed at a time
1112 self.update_sema = threading.BoundedSemaphore(value=1)
1114 # create a new window
1115 self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
1116 self.window.set_size_request(480, 250)
1117 self.window.set_title("Minibook")
1118 self.window.connect("delete_event", lambda w, e: gtk.main_quit())
1120 vbox = gtk.VBox(False, 0)
1121 self.window.add(vbox)
1123 # Menubar
1124 self.create_menubar()
1125 vbox.pack_start(self.main_menu, False, True, 0)
1127 # Status update display window
1128 self.create_grid()
1129 self.statuslist_window = gtk.ScrolledWindow()
1130 self.statuslist_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
1131 self.statuslist_window.add(self.treeview)
1132 vbox.pack_start(self.statuslist_window, True, True, 0)
1134 # Area around the status update entry box with labels and button
1135 label_box = gtk.HBox(False, 0)
1136 label = gtk.Label("What's on your mind?")
1137 self.count_label = gtk.Label("(%d)" % (MAX_MESSAGE_LENGTH))
1138 label_box.pack_start(label)
1139 label_box.pack_start(self.count_label)
1141 self.entry = gtk.TextView()
1142 text = self.entry.get_buffer()
1143 text.connect('changed', self.count)
1144 self.insert_sig = text.connect("insert_text", self.insert_cb)
1145 text_box = gtk.VBox(True, 0)
1146 text_box.pack_start(label_box)
1147 text_box.pack_start(self.entry, True, True, 4)
1149 update_button = gtk.Button(stock=gtk.STOCK_ADD)
1150 update_button.connect("clicked", lambda w: self.sendupdate())
1152 update_box = gtk.HBox(False, 0)
1153 update_box.pack_start(text_box, expand=True, fill=True,
1154 padding=0)
1155 update_box.pack_start(update_button, expand=False, fill=False,
1156 padding=0)
1158 vbox.pack_start(update_box, False, True, 0)
1160 # Statusbar
1161 self.statusbar = gtk.Statusbar()
1162 vbox.pack_start(self.statusbar, False, False, 0)
1163 self.statusbar_context = self.statusbar.get_context_id(
1164 '%s is here.' % (APPNAME))
1166 # Set up spell checking if it is available
1167 if spelling_support:
1168 try:
1169 spelling = gtkspell.Spell(self.entry, 'en')
1170 except:
1171 spelling_support = False
1173 # Show window
1174 self.window.show_all()
1176 # Set up systray icon
1177 _app_icon_file = 'pixmaps/minibook.png'
1178 _app_icon_notify_file = 'pixmaps/minibook_notify.png'
1179 self._app_icon = gtk.gdk.pixbuf_new_from_file(_app_icon_file)
1180 self._app_icon_notify = \
1181 gtk.gdk.pixbuf_new_from_file(_app_icon_notify_file)
1182 self._systray = gtk.StatusIcon()
1183 self._systray.set_from_pixbuf(self._app_icon)
1184 self._systray.set_tooltip('%s\n' \
1185 'Left-click: toggle window hiding' % (APPNAME))
1186 self._systray.connect('activate', self.systray_click)
1187 self._systray.set_visible(True)
1189 self.window.set_icon(self._app_icon)
1191 # Enable thread manager
1192 self._threads = _ThreadManager()
1194 # Start to set up preferences
1195 self._prefs = {}
1196 x, y = self.window.get_position()
1197 self._prefs['window_pos_x'] = x
1198 self._prefs['window_pos_y'] = y
1199 self._prefs['auto_refresh_interval'] = 5
1201 # Last update: never, start first one
1202 self._last_update = 0
1203 self._threads.add_work(self.post_get_friends_list,
1204 self.except_get_friends_list,
1205 self.get_friends_list)
1207 # Enable auto-refresh
1208 self._refresh_id = None
1209 self.set_auto_refresh()
1211 # Storing notification state
1212 self.new_notify = False
1213 # Used to remove systray notification on window show
1214 self.window.connect("event", self.remove_notify)
1217 def main(facebook):
1219 Main function
1222 gtk.gdk.threads_enter()
1223 gtk.main()
1224 gtk.gdk.threads_leave()
1225 _log.debug('Exiting')
1226 return 0
1228 if __name__ == "__main__":
1230 Set up facebook object, login and start main window
1233 # Currently cannot include the registered app's
1234 # api_key and secret_key, thus have to save them separately
1235 # Here those keys are loaded
1236 try:
1237 config_file = open("config", "r")
1238 api_key = config_file.readline()[:-1]
1239 secret_key = config_file.readline()[:-1]
1240 _log.debug('Config file loaded successfully')
1241 except Exception, e:
1242 _log.critical('Error while loading config file: %s' % (str(e)))
1243 exit(1)
1245 facebook = Facebook(api_key, secret_key)
1246 try:
1247 facebook.auth.createToken()
1248 except:
1249 # Like catch errors like
1250 # http://bugs.developers.facebook.com/show_bug.cgi?id=5474
1251 # and http://bugs.developers.facebook.com/show_bug.cgi?id=5472
1252 _log.critical("Error on Facebook's side, " \
1253 "try starting application later")
1254 exit(1)
1256 _log.debug('Showing Facebook login page in default browser.')
1257 facebook.login()
1259 # Delay dialog to allow for login in browser
1260 got_session = False
1261 while not got_session:
1262 dia = gtk.Dialog('minibook: login',
1263 None,
1264 gtk.DIALOG_MODAL | \
1265 gtk.DIALOG_DESTROY_WITH_PARENT | \
1266 gtk.DIALOG_NO_SEPARATOR,
1267 ("Logged in", gtk.RESPONSE_OK, \
1268 gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL))
1269 label = gtk.Label("%s is opening your web browser to " \
1270 "log in Facebook.\nWhen finished, click 'Logged in', " \
1271 "or you can cancel now." % (APPNAME))
1272 dia.vbox.pack_start(label, True, True, 10)
1273 label.show()
1274 dia.show()
1275 gtk.gdk.threads_enter()
1276 result = dia.run()
1277 gtk.gdk.threads_leave()
1278 # Cancel login and close app
1279 if result == gtk.RESPONSE_CANCEL:
1280 _log.debug('Exiting before Facebook login.')
1281 exit(0)
1282 dia.destroy()
1283 try:
1284 facebook.auth.getSession()
1285 got_session = True
1286 except:
1287 # Likely clicked "logged in" but not logged in yet, start over
1288 pass
1290 _log.info('Session Key: %s' % (facebook.session_key))
1291 _log.info('User\'s UID: %d' % (facebook.uid))
1293 # Start main window and app
1294 MainWindow(facebook)
1295 main(facebook)