A micro-blogging tool with multiple interfaces.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

1448 lines
51 KiB

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Mitter, a Maemo client for Twitter.
# Copyright (C) 2007, 2008 Julio Biason, Deepak Sarda
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import pygtk
pygtk.require('2.0')
import gtk
import gobject
gobject.threads_init()
gtk.gdk.threads_init()
import datetime
import re
import timesince
import logging
import mitterlib as util
from notify import Notify
from mitterlib.constants import gpl_3, version
from mitterlib.ui.utils import str_len
NAMESPACE = 'pygtk'
def options(options):
"""Add the options for this interface."""
options.add_group(NAMESPACE, 'GTK+ Interface')
options.add_option('--refresh-interval',
group=NAMESPACE,
option='refresh_interval',
help='Refresh interval',
type='int',
metavar='MINUTES',
default=5,
conflict_group='interface')
# Most of the options for non-cmd-options are useless, but I'm keeping
# them as documentation.
options.add_option(
group=NAMESPACE,
option='width',
help='Window width',
type='int',
metavar='PIXELS',
default=450,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=NAMESPACE,
option='height',
help='Window height',
type='int',
metavar='PIXELS',
default=300,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=NAMESPACE,
option='position_x',
help='Window position on the X axis',
type='int',
metavar='PIXELS',
default=5,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=NAMESPACE,
option='position_y',
help='Window position on the Y axis',
type='int',
metavar='PIXELS',
default=5,
conflict_group='interface',
is_cmd_option=False)
options.add_option(
group=NAMESPACE,
option='max_status_display',
help='Maximum number of elements to keep internally',
type='int',
metavar='MESSAGES',
default=60,
conflict_group='interface',
is_cmd_option=False) # TODO: Should it be config only?
# Constants
URL_RE = re.compile(
r'((?:(?:https?|ftp)://|www[-\w]*\.)[^\s\n\r]+[-\w+&@#%=~])', re.I)
_log = logging.getLogger('ui.pygtk')
class Columns:
(PIC, NAME, MESSAGE, USERNAME, ID, DATETIME, ALL_DATA) = range(7)
class _MainWindow(gtk.Window):
"""PyGTK main window."""
def __init__(self, controller):
super(_MainWindow, self).__init__()
self._controller = controller
self.connect('destroy', self._controller.destroy)
self.connect('delete-event', self._controller.delete_event)
grid = self._create_grid(None) # Where is the store?
(menu, toolbar, accelerators) = self._create_menu_and_toolbar()
update_field = self._create_update_box()
statusbar = self._create_statusbar()
update_box = gtk.VPaned()
update_box.pack1(grid, resize=True, shrink=False)
update_box.pack2(update_field, resize=False, shrink=True)
box = gtk.VBox(False, 1)
box.pack_start(menu, False, True, 0)
box.pack_start(update_box, True, True, 0)
box.pack_start(statusbar, False, False, 0)
self.add(box)
self.add_accel_group(accelerators)
return
def _create_grid(self, grid_store):
"""Add the displaying grid."""
# self.grid_store = gtk.ListStore(
# str,
# str,
# str,
# str,
# str,
# object,
# object)
# self.grid_store.set_sort_func(Columns.DATETIME, self._sort_by_time)
# self.grid_store.set_sort_column_id(Columns.DATETIME,
# gtk.SORT_DESCENDING)
self.grid = gtk.TreeView(grid_store)
self.grid.set_property('headers-visible', False)
self.grid.set_rules_hint(True) # change color for each row
user_renderer = gtk.CellRendererPixbuf()
user_column = gtk.TreeViewColumn('User', user_renderer)
user_column.set_cell_data_func(user_renderer,
self._cell_renderer_user)
self.grid.append_column(user_column)
message_renderer = gtk.CellRendererText()
message_renderer.set_property('wrap-mode', gtk.WRAP_WORD)
message_renderer.set_property('wrap-width', 200)
message_renderer.set_property('width', 10)
message_column = gtk.TreeViewColumn('Message',
message_renderer, text=1)
message_column.set_cell_data_func(message_renderer,
self._cell_renderer_message)
self.grid.append_column(message_column)
self.grid.set_resize_mode(gtk.RESIZE_IMMEDIATE)
#self.grid.connect('cursor-changed', self.check_post)
#self.grid.connect('row-activated', self.open_post)
#self.grid.connect('button-press-event', self.click_post)
#self.grid.connect('popup-menu',
# lambda view: self.show_post_popup(view, None))
scrolled_window = gtk.ScrolledWindow()
scrolled_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
scrolled_window.add(self.grid)
return scrolled_window
def _create_menu_and_toolbar(self):
"""Create the main menu and the toolbar."""
# tasks (used by the menu and toolbar)
refresh_action = gtk.Action('Refresh', '_Refresh',
'Update the listing', gtk.STOCK_REFRESH)
refresh_action.connect('activate', self.refresh)
quit_action = gtk.Action('Quit', '_Quit',
'Exit Mitter', gtk.STOCK_QUIT)
quit_action.connect('activate', self.quit)
settings_action = gtk.Action('Settings', '_Settings',
'Settings', gtk.STOCK_PREFERENCES)
settings_action.connect('activate', self.show_settings)
update_action = gtk.Action('Update', '_Update', 'Update your status',
gtk.STOCK_ADD)
update_action.set_property('sensitive', False)
update_action.connect('activate', self._update_status)
delete_action = gtk.Action('Delete', '_Delete', 'Delete a post',
gtk.STOCK_DELETE)
delete_action.set_property('sensitive', False)
delete_action.connect('activate', self.delete_tweet)
about_action = gtk.Action('About', '_About', 'About Mitter',
gtk.STOCK_ABOUT)
about_action.connect('activate', self.show_about)
shrink_url_action = gtk.Action('ShrinkURL', 'Shrink _URL',
'Shrink selected URL', gtk.STOCK_EXECUTE)
shrink_url_action.connect('activate', self.shrink_url)
mute_action = gtk.ToggleAction('MuteNotify', '_Mute Notifications',
'Mutes notifications on new tweets', gtk.STOCK_MEDIA_PAUSE)
mute_action.set_active(False)
post_action = gtk.Action('Posts', '_Posts', 'Post management', None)
file_action = gtk.Action('File', '_File', 'File', None)
edit_action = gtk.Action('Edit', '_Edit', 'Edit', None)
help_action = gtk.Action('Help', '_Help', 'Help', None)
# action group (will have all the actions, 'cause we are not actually
# grouping them, but Gtk requires them that way)
self.action_group = gtk.ActionGroup('MainMenu')
self.action_group.add_action_with_accel(refresh_action, 'F5')
# None = use the default accelerator, based on the STOCK used.
self.action_group.add_action_with_accel(quit_action, None)
self.action_group.add_action(settings_action)
self.action_group.add_action(delete_action)
self.action_group.add_action(post_action)
self.action_group.add_action(file_action)
self.action_group.add_action(edit_action)
self.action_group.add_action(help_action)
self.action_group.add_action(about_action)
self.action_group.add_action_with_accel(shrink_url_action, '<Ctrl>u')
self.action_group.add_action_with_accel(mute_action, '<Ctrl>m')
self.action_group.add_action_with_accel(update_action,
'<Ctrl>Return')
# definition of the UI
uimanager = gtk.UIManager()
uimanager.insert_action_group(self.action_group, 0)
ui = '''
<ui>
<toolbar name="MainToolbar">
<toolitem action="Refresh" />
<separator />
<toolitem action="Delete" />
<separator />
<toolitem action="Settings" />
<toolitem action="Quit" />
</toolbar>
<menubar name="MainMenu">
<menu action="File">
<menuitem action="Quit" />
</menu>
<menu action="Edit">
<menuitem action="Refresh" />
<menuitem action="Update" />
<menuitem action="Delete" />
<menuitem action="ShrinkURL" />
<menuitem action="MuteNotify" />
<separator />
<menuitem action="Settings" />
</menu>
<menu action="Help">
<menuitem action="About" />
</menu>
</menubar>
</ui>
'''
uimanager.add_ui_from_string(ui)
main_menu = uimanager.get_widget('/MainMenu')
main_toolbar = uimanager.get_widget('/MainToolbar')
return (main_menu, main_toolbar, uimanager.get_accel_group())
def _create_update_box(self):
"""Create the widgets related to the update box"""
self._update_text = gtk.TextView()
text_buffer = self._update_text.get_buffer()
text_buffer.connect('changed', self._controller._count_chars)
update_button = gtk.Button(stock=gtk.STOCK_ADD)
update_button.connect('clicked', self._controller._update_status)
update_box = gtk.HBox(False, 0)
update_box.pack_start(self._update_text, expand=True, fill=True,
padding=0)
update_box.pack_start(update_button, expand=False, fill=False,
padding=0)
info_box = gtk.HBox(False, 0)
self._char_count = gtk.Label()
self._char_count.set_text('(140)')
info_box.pack_start(gtk.Label('What are you doing?'))
info_box.pack_start(self._char_count)
update_area = gtk.VBox(True, 0)
update_area.pack_start(info_box)
update_area.pack_start(update_box)
return update_area
def _create_statusbar(self):
"""Create the statusbar."""
statusbar = gtk.Statusbar()
# TODO: Probaly set the context in the object.
return statusbar
# ------------------------------------------------------------
# Cell rendering functions
# ------------------------------------------------------------
def _cell_renderer_user(self, column, cell, store, position):
"""Callback for the user column. Used to created the pixbuf of the
userpic."""
pic = store.get_value(position, Columns.PIC)
if not pic in self._user_pics:
cell.set_property('pixbuf', self._default_pixmap)
# just make sure we download this pic too.
self.queue_pic(pic)
else:
cell.set_property('pixbuf', self._user_pics[pic])
return
def _cell_renderer_message(self, column, cell, store, position):
"""Callback for the message column. We need this to adjust the markup
property of the cell, as setting it as text won't do any markup
processing."""
user = store.get_value(position, Columns.NAME)
message = store.get_value(position, Columns.MESSAGE)
time = store.get_value(position, Columns.DATETIME)
username = store.get_value(position, Columns.USERNAME)
time = timesince.timesince(time)
# unescape escaped entities that pango is okay with
message = re.sub(r'&(?!(amp;|gt;|lt;|quot;|apos;))', r'&amp;', message)
# highlight URLs
message = url_re.sub(r'<span foreground="blue">\1</span>',
message)
# use a different highlight for the current user
message = re.sub(r'(@'+self.twitter.username+')',
r'<span foreground="#FF6633">\1</span>',
message)
markup = '<b>%s</b> <small>(%s)</small>:\n%s\n<small>%s</small>' % \
(user, username, message, time)
cell.set_property('markup', markup)
return
class _GtkController(object):
"""The interface controller."""
def __init__(self):
super(_GtkController, self).__init__()
return
def destroy(self, widget, user_data=None):
"""Called when the window is destroyed."""
_log.debug('Window destroy')
gtk.main_quit()
return True
def delete_event(self, widget, event, user_param=None):
_log.debug('Window delete')
gtk.main_quit()
return True
class Interface(object):
"""Linux/GTK interface for Mitter."""
def systray_cb(self, widget, user_param=None):
if self.window.get_property('visible') and self.window.is_active():
x, y = self.window.get_position()
self.prefs['position_x'] = x
self.prefs['position_y'] = y
self.window.hide()
else:
self.window.move(
self.prefs['position_x'],
self.prefs['position_y'])
self.window.deiconify()
self.window.present()
def create_settings_dialog(self):
"""Creates the settings dialog."""
self.settings_window = gtk.Dialog(title="Settings",
parent=self.window, flags=gtk.DIALOG_MODAL |
gtk.DIALOG_DESTROY_WITH_PARENT,
buttons=(gtk.STOCK_CANCEL, 0, gtk.STOCK_OK, 1))
self.settings_box = gtk.Table(rows=4, columns=2, homogeneous=False)
username_label = gtk.Label('Username:')
password_label = gtk.Label('Password:')
refresh_label = gtk.Label('Refresh interval (minutes):')
https_label = gtk.Label('Use secure connections (HTTPS):')
labels = [username_label, password_label, refresh_label, https_label]
for label in labels:
label.set_alignment(0, 0.5)
label.set_padding(2, 0)
self.username_field = gtk.Entry()
self.password_field = gtk.Entry()
self.password_field.set_visibility(False)
self.refresh_interval_field = gtk.SpinButton()
self.refresh_interval_field.set_range(1, 99)
self.refresh_interval_field.set_numeric(True)
self.refresh_interval_field.set_value(self.prefs['refresh_interval'])
self.refresh_interval_field.set_increments(1, 5)
self.https_field = gtk.CheckButton()
self.https_field.set_active(self.https)
self.settings_box.attach(username_label, 0, 1, 0, 1)
self.settings_box.attach(self.username_field, 1, 2, 0, 1)
self.settings_box.attach(password_label, 0, 1, 1, 2)
self.settings_box.attach(self.password_field, 1, 2, 1, 2)
self.settings_box.attach(refresh_label, 0, 1, 2, 3)
self.settings_box.attach(self.refresh_interval_field, 1, 2, 2, 3)
self.settings_box.attach(https_label, 0, 1, 3, 4)
self.settings_box.attach(self.https_field, 1, 2, 3, 4)
self.settings_box.show_all()
self.settings_window.vbox.pack_start(self.settings_box, True,
True, 0)
self.settings_window.connect('close', self.close_dialog)
self.settings_window.connect('response', self.update_preferences)
return
def show_about(self, widget):
"""Show the about dialog."""
about_window = gtk.AboutDialog()
about_window.set_name('Mitter')
about_window.set_version(version)
about_window.set_copyright('2007-2008 Mitter Contributors')
about_window.set_license(gpl_3)
about_window.set_website('http://mitter.googlecode.com')
about_window.set_website_label('Mitter on GoogleCode')
about_window.set_authors(['Julio Biason', 'Deepak Sarda', \
'Gerald Kaszuba'])
about_window.connect('close', self.close_dialog)
about_window.run()
about_window.hide()
# ------------------------------------------------------------
# Widget creation functions
# ------------------------------------------------------------
# ------------------------------------------------------------
# Grid cell content callback
# ------------------------------------------------------------
# Non-widget attached callbacks
def set_auto_refresh(self):
"""Configure auto-refresh of tweets every `interval` minutes"""
if self._refresh_id:
gobject.source_remove(self._refresh_id)
self._refresh_id = gobject.timeout_add(
self.prefs['refresh_interval']*60*1000,
self.refresh, None)
return
def update_friends_list(self):
"""Fetch the user's list of twitter friends and add it
to the friends_store for @reply autocompletion"""
_log.debug('Checking friends list...')
friends = self.twitter.friends_list(self.post_update_friends_list)
return
def post_update_friends_list(self, friends, error):
"""Function called after we fetch the friends list."""
_log.debug('Received the friends list')
if error == 401: # TODO: Constants for this?
# not authorized
_log.error('User is not authorized yet')
return
if error in (500, 502, 503):
_log.error('Twitter asked us to try getting friends list' \
' sometime later')
gobject.timeout_add(5*60*1000, self.update_friends_list)
return
if error:
# any error
# well, we just don't add any friends, then.
_log.error('Error getting friend list, leaving list empty')
return
# I'm not really sure if we need to set the thread locking here (as we
# are just updating the store), but better safe than sorry!
gtk.gdk.threads_enter()
# Sometimes due to twitter API quirks and moon phases, the friends
# list ends up getting populated more than once. So watch for
# duplicates....
known_friends = [row[0] for row in self.friends_store]
_log.debug('known_friends: %s' % " ".join(known_friends))
for friend in friends:
try:
screen_name = '@' + friend['screen_name'] + ': '
if screen_name not in known_friends:
_log.debug('Adding "%s" to the list' % (screen_name))
self.friends_store.append([screen_name])
except Exception, e:
# No `error` does not always mean twitter sent us good data
_log.error('Error processing friend list. %s' % str(e))
gtk.gdk.threads_leave()
_log.debug('friends list processing complete')
return
def prune_grid_store(self):
"""Prune the grid_store by removing the oldest rows."""
if len(self.grid_store) <= MAX_STATUS_DISPLAY:
return True # Required by gobject.idle_add() for this to be called
# again
_log.debug("prune_grid_store called")
gtk.gdk.threads_enter()
self.grid.freeze_child_notify()
self.grid.set_model(None)
# Since I don't know how to get the last row in grid_store,
# I'll reverse the list and then pop out the first row instead.
self.grid_store.set_sort_column_id(Columns.DATETIME,
gtk.SORT_ASCENDING)
iter = self.grid_store.get_iter_first()
while (len(self.grid_store) > MAX_STATUS_DISPLAY) and iter:
_log.debug("popping off tweet with id %s" %
self.grid_store.get_value(iter, Columns.ID))
self.grid_store.remove(iter) # iter is auto set to next row
self.grid_store.set_sort_column_id(Columns.DATETIME,
gtk.SORT_DESCENDING)
self.grid.set_model(self.grid_store)
self.grid.thaw_child_notify()
gtk.gdk.threads_leave()
return True
# Main window callbacks
def size_request(self, widget, requisition, data=None):
"""Callback when the window changes its sizes. We use it to set the
proper word-wrapping for the message column."""
self.prefs['width'], self.prefs['height'] = self.window.get_size()
# this is based on a mail of Kristian Rietveld, on gtk maillist
if not len(self.grid_store):
# nothing to rearrange
return
column = self.message_column
iter = self.grid_store.get_iter_first()
path = self.grid_store.get_path(iter)
column_rectangle = self.grid.get_cell_area(path, column)
width = column_rectangle.width
_log.debug('Width=%d' % (width))
# there should be only
renderers = column.get_cell_renderers()
for render in renderers:
_log.debug('Render update')
render.set_property('wrap-width', width)
while iter:
path = self.grid_store.get_path(iter)
self.grid_store.row_changed(path, iter)
iter = self.grid_store.iter_next(iter)
return
def quit(self, widget, user_data=None):
"""Callback when the window is destroyed (e.g. when the user closes
the application."""
# this is really annoying: if the threads are locked doing some IO
# requests, the application will not quit. Displaying this message is
# the only option we have right now.
_log.debug('quit callback invoked. exiting now...')
gtk.main_quit()
def notify_reset(self, widget, event, user_data=None):
if getattr(event, 'in_', False):
self._main_window.set_urgency_hint(False)
if self._systray:
self._systray.set_tooltip('Mitter: Click to toggle ' \
'window visibility.')
self._systray.set_from_file(self._app_icon)
self.unread_tweets = 0
return
def notify(self, new_tweets=0):
"""Set the window hint as urgent, so Mitter window will flash,
notifying the user about the new messages. Also send a notification
message with one of the new tweets."""
self.window.set_urgency_hint(True)
if self._systray and self.unread_tweets > 0:
self._systray.set_tooltip('Mitter: %s new' % self.unread_tweets)
self._systray.set_from_file(self._app_icon_alert)
if self.action_group.get_action('MuteNotify').get_active():
_log.debug('notifications are currently muted')
return
if new_tweets and len(self.grid_store) > 0:
iter = self.grid_store.get_iter_first()
while iter:
sender = self.grid_store.get_value(iter, Columns.USERNAME)
if sender == self.username_field.get_text():
iter = self.grid_store.iter_next(iter)
continue
else:
tweet = self.grid_store.get_value(iter, Columns.MESSAGE)
_log.debug('notify_broadcast with this tweet: %s' %
tweet)
break
if new_tweets > 1:
msg = '<b>%d</b> unread tweets including ' \
'this from <i>%s</i>:<br/>%s' % (self.unread_tweets,
sender, tweet)
else:
msg = 'One new tweet from <i>%s</i>:<br/>%s' % (sender,
tweet)
if self.systray:
gtk.gdk.threads_enter()
screen, rect, orientation = self.systray.get_geometry()
gtk.gdk.threads_leave()
self.notify_broadcast(msg, rect.x, rect.y)
return
# settings callbacks
def show_settings(self, widget, user_data=None):
"""Create and display the settings window."""
self.settings_window.show()
self.settings_window.run()
return
def close_dialog(self, user_data=None):
"""Hide the dialog window."""
return True
def update_preferences(self, widget, response_id=0, user_data=None):
"""
Update the user preferences when the user press the "OK" button in the
settings window."""
if response_id == 1:
self.statusbar.push(self.statusbar_context,
'Saving your profile...')
self.save_interface_prefs()
# update the (internal) twitter prefences too!
self.twitter.username = self.username_field.get_text()
self.twitter.password = self.password_field.get_text()
self.twitter.https = self.https_field.get_active()
refresh_interval = self.refresh_interval_field.get_value_as_int()
self.prefs['refresh_interval'] = refresh_interval
# update the list
self.refresh(None)
self.update_friends_list()
self.statusbar.pop(self.statusbar_context)
# update auto-refresh
self.set_auto_refresh()
self.settings_window.hide()
return True
# update status
def update_status(self, user_data=None):
"""Update the user status on Twitter."""
status = self.update_text.get_text()
status = status.strip()
if not str_len(status):
return
self.update_text.set_sensitive(False)
self.statusbar.push(self.statusbar_context, 'Updating your status...')
if str_len(status) > 140:
error_message = 'Your message has more than 140 characters and' \
' Twitter may truncate it. It would still be visible ' \
'on the website. Do you still wish to go ahead?'
if str_len(status) > 160:
error_message = 'Your message has more than 160 characters ' \
'and it is very likely Twitter will refuse it. You ' \
'can try shortening your URLs before posting. Do ' \
'you still wish to go ahead?'
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_QUESTION,
flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
message_format="Your status update message is too long.",
buttons=gtk.BUTTONS_YES_NO)
error_dialog.format_secondary_text(error_message)
response = error_dialog.run()
error_dialog.destroy()
if response == gtk.RESPONSE_NO:
self.statusbar.pop(self.statusbar_context)
self.update_text.set_sensitive(True)
self.window.set_focus(self.update_text)
return
data = self.twitter.update(status, self.post_update_status)
def post_update_status(self, data, error):
"""Function called after we receive the answer from the update
status."""
if error:
gtk.gdk.threads_enter()
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_ERROR,
message_format='Error updating status. Please try again.',
buttons=gtk.BUTTONS_OK)
error_dialog.connect("response", lambda *a:
error_dialog.destroy())
error_dialog.run()
gtk.gdk.threads_leave()
else:
if data:
# i wonder if this will really work
self.post_refresh([data], None, False)
else:
self.refresh(None, False)
gtk.gdk.threads_enter()
self.update_text.set_text("")
gtk.gdk.threads_leave()
gtk.gdk.threads_enter()
self.statusbar.pop(self.statusbar_context)
self.update_text.set_sensitive(True)
self.window.set_focus(self.update_text)
gtk.gdk.threads_leave()
return True
def shrink_url(self, widget, user_data=None):
bounds = self.update_text.get_selection_bounds()
if not bounds:
return
else:
start, end = bounds
longurl = self.update_text.get_chars(start, end).strip()
if not longurl:
return
_log.debug('shrink url request for: %s' % longurl)
self.update_text.set_sensitive(False)
self.statusbar.push(self.statusbar_context, 'Shrinking URL...')
self.twitter.download('http://is.gd/api.php?longurl=' + longurl,
self.post_shrink_url, longurl=longurl,
start=start, end=end)
def post_shrink_url(self, url, error, longurl, start, end):
if error:
_log.error("Exception in shrinking url. ' \
'Error code: %s" % error)
# error dialog
gtk.gdk.threads_enter()
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_ERROR,
message_format='Failed to shrink the URL %s' % longurl,
buttons=gtk.BUTTONS_OK)
error_dialog.connect("response", lambda *a:
error_dialog.destroy())
error_dialog.run()
gtk.gdk.threads_leave()
else:
_log.debug('Got shrunk url: %s' % url)
char = self.update_text.get_chars(start-1, start)
if start and not char.isspace():
url = ' '+url
char = self.update_text.get_chars(end, end+1)
if not char.isspace():
url = url+' '
gtk.gdk.threads_enter()
self.update_text.delete_text(start, end)
self.update_text.insert_text(url, start)
self.update_text.set_position(start+len(url))
gtk.gdk.threads_leave()
gtk.gdk.threads_enter()
self.statusbar.pop(self.statusbar_context)
self.update_text.set_sensitive(True)
self.update_text.grab_focus()
gtk.gdk.threads_leave()
# post related callbacks
def reply_tweet(self, widget, user_data=None):
"""Reply by putting the username in your input"""
cursor = self.grid.get_cursor()
if not cursor:
return
path = cursor[0]
iter = self.grid_store.get_iter(path)
username = self.grid_store.get_value(iter, Columns.USERNAME)
text_insert = '@%s: ' % (username)
_log.debug('Inserting reply text: %s' % (text_insert))
status = self.update_text.get_text()
status = text_insert + status
self.update_text.set_text(status)
self.window.set_focus(self.update_text)
self.update_text.set_position(len(status))
def retweet(self, widget, user_data=None):
"""Retweet by putting the string rt and username in your input"""
cursor = self.grid.get_cursor()
if not cursor:
return
path = cursor[0]
iter = self.grid_store.get_iter(path)
username = self.grid_store.get_value(iter, Columns.USERNAME)
msg = self.grid_store.get_value(iter, Columns.MESSAGE)
text_insert = 'RT @%s: %s' % (username, msg)
_log.debug('Inserting retweet text: %s' % (text_insert))
status = text_insert + self.update_text.get_text()
self.update_text.set_text(status)
self.window.set_focus(self.update_text)
self.update_text.set_position(str_len(status))
def delete_tweet(self, widget, user_data=None):
"""Delete a twit."""
cursor = self.grid.get_cursor()
if not cursor:
return
path = cursor[0]
iter = self.grid_store.get_iter(path)
tweet_id = int(self.grid_store.get_value(iter, Columns.ID))
_log.debug('Deleting tweet: %d' % (tweet_id))
self.statusbar.push(self.statusbar_context, 'Deleting tweet...')
self.twitter.tweet_destroy(tweet_id, self.post_delete_tweet,
tweet=tweet_id)
return
def post_delete_tweet(self, data, error, tweet):
"""Function called after we delete a tweet on the server."""
if error:
gtk.gdk.threads_enter()
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_ERROR,
message_format='Error deleting tweet. Please try again.',
buttons=gtk.BUTTONS_OK)
error_dialog.connect("response", lambda *a:
error_dialog.destroy())
error_dialog.run()
gtk.gdk.threads_leave()
else:
# locate that tweet in the store and remove it.
iter = self.grid_store.get_iter_first()
tweet = int(tweet)
while iter:
id = self.grid_store.get_value(iter, Columns.ID)
if int(id) == tweet:
self.grid_store.remove(iter)
break
iter = self.grid_store.iter_next(iter)
# update the interface
gtk.gdk.threads_enter()
self.statusbar.pop(self.statusbar_context)
self.grid.queue_draw()
gtk.gdk.threads_leave()
return
def check_post(self, treeview, user_data=None):
"""Callback when one of the rows is selected."""
cursor = treeview.get_cursor()
if not cursor:
return
path = cursor[0]
iter = self.grid_store.get_iter(path)
username = self.grid_store.get_value(iter, Columns.USERNAME)
delete_action = self.action_group.get_action('Delete')
if username == self.username_field.get_text():
delete_action.set_property('sensitive', True)
else:
delete_action.set_property('sensitive', False)
return
def open_post(self, treeview, path, view_column, user_data=None):
"""Callback when one of the rows in activated."""
iter = self.grid_store.get_iter(path)
username = self.grid_store.get_value(iter, Columns.USERNAME)
tweet_id = self.grid_store.get_value(iter, Columns.ID)
message = self.grid_store.get_value(iter, Columns.MESSAGE)
urls = url_re.search(message)
if urls:
# message contains a link; go to the link instead
url = urls.groups()[0]
else:
url = 'http://twitter.com/%s/statuses/%s/' % (username, tweet_id)
self.open_url(path, url)
def click_post(self, treeview, event, user_data=None):
"""Callback when a mouse click event occurs on one of the rows."""
if event.button != 3:
# Only right clicks are processed
return False
x = int(event.x)
y = int(event.y)
pth = treeview.get_path_at_pos(x, y)
if not pth:
# The click wasn't on a row
return False
path, col, cell_x, cell_y = pth
treeview.grab_focus()
treeview.set_cursor(path, col, 0)
self.show_post_popup(treeview, event)
return True
def show_post_popup(self, treeview, event, user_data=None):
"""Shows the popup context menu in the treeview"""
cursor = treeview.get_cursor()
if not cursor:
return
path = cursor[0]
row_iter = self.grid_store.get_iter(path)
popup_menu = gtk.Menu()
popup_menu.set_screen(self.window.get_screen())
# An open submenu with various choices underneath
open_menu_items = []
tweet = self.grid_store.get_value(row_iter, Columns.ALL_DATA)
urls = url_re.findall(tweet['text'])
for url in urls:
if len(url) > 20:
item_name = url[:20] + '...'
else:
item_name = url
item = gtk.MenuItem(item_name)
item.connect('activate', self.open_url, url)
open_menu_items.append(item)
if tweet['in_reply_to_status_id']:
# I wish twitter made it easy to construct target url
# without having to make another API call
reply_to = re.search(r'@(?P<user>\w+)', tweet['text'])
if reply_to:
url = 'http://twitter.com/%s/statuses/%s' % (
reply_to.group('user'),
tweet['in_reply_to_status_id'])
item = gtk.MenuItem('In reply to')
item.connect('activate', self.open_url, url)
open_menu_items.append(item)
item = gtk.MenuItem('This tweet')
username = self.grid_store.get_value(row_iter, Columns.USERNAME)
tweet_id = self.grid_store.get_value(row_iter, Columns.ID)
url = 'http://twitter.com/%s/statuses/%s/' % (username, tweet_id)
item.connect('activate', self.open_url, url)
open_menu_items.append(item)
open_menu = gtk.Menu()
for item in open_menu_items:
open_menu.append(item)
open_item = gtk.MenuItem("Open")
open_item.set_submenu(open_menu)
popup_menu.append(open_item)
# Reply, only if it's not yourself
item = gtk.MenuItem("Reply")
item.connect('activate', self.reply_tweet, "Reply")
if username == self.username_field.get_text():
item.set_property('sensitive', False)
popup_menu.append(item)
# Retweet, only if it's not yourself
item = gtk.MenuItem("Retweet")
item.connect('activate', self.retweet, "Retweet")
if username == self.username_field.get_text():
item.set_property('sensitive', False)
popup_menu.append(item)
item = gtk.MenuItem("Delete")
item.connect('activate', self.delete_tweet, "Delete")
if username != self.username_field.get_text():
item.set_property('sensitive', False)
popup_menu.append(item)
popup_menu.show_all()
if event:
b = event.button
t = event.time
else:
b = 1
t = 0
popup_menu.popup(None, None, None, b, t)
return True
# action callbacks
# (yes, settings should be here, but there are more settings-related
# callbacks, so let's keep them together somewhere else)
def open_url(self, source, url):
"""Simply opens specified url in new browser tab. We need source
parameter so that this function can be used as an event callback"""
_log.debug('opening url: %s' % url)
import webbrowser
webbrowser.open_new_tab(url)
self.window.set_focus(self.update_text)
def refresh(self, widget, notify=True):
"""Update the list of twits."""
if self.last_update:
self.statusbar.pop(self.statusbar_context)
self.last_update = datetime.datetime.now()
_log.debug('Updating list of tweets...')
self.statusbar.push(self.statusbar_context,
'Updating list of tweets...')
self.twitter.friends_timeline(self.post_refresh, notify=notify)
return True # required by gobject.timeout_add
def post_refresh(self, data, error, notify):
"""Function called when the system retrieves the list of new
tweets."""
_log.debug('Data: %s' % (str(data)))
if error == 401:
# Not authorized, popup the settings window
gtk.gdk.threads_enter()
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_ERROR,
message_format='Autorization error, check your login ' \
'information in the prefrences',
buttons=gtk.BUTTONS_OK)
error_dialog.connect("response", lambda *a:
error_dialog.destroy())
error_dialog.run()
gtk.gdk.threads_leave()
return
if not data:
gtk.gdk.threads_enter()
self.statusbar.pop(self.statusbar_context)
self.show_last_update()
_log.debug('No new data')
gtk.gdk.threads_leave()
return
known_tweets = [row[Columns.ID] for row in self.grid_store]
need_notify = False
new_tweets = 0
new_tweets_list = []
for tweet in data:
id = tweet['id']
if str(id) in known_tweets:
_log.debug('Tweet %s is already in the list' % (id))
continue
created_at = tweet['created_at']
display_name = tweet['user']['name']
username = tweet['user']['screen_name']
user_pic = tweet['user']['profile_image_url']
message = tweet['text']
new_tweets_list.append((user_pic, display_name, message, username,
id, created_at, tweet))
self.queue_pic(user_pic)
_log.debug('New tweet with id %s from %s' % (id, username))
if not username == self.username_field.get_text():
# we don't want to be notified about tweets from ourselves,
# but from everyone else it is fine.
new_tweets += 1
# add the new tweets in the store
gtk.gdk.threads_enter()
for data in new_tweets_list:
self.grid_store.append(data)
self.statusbar.pop(self.statusbar_context)
# there is new stuff, so we move to the top
p = self.grid_store.get_path(self.grid_store.get_iter_first())
self.grid.scroll_to_cell(p)
self.show_last_update()
_log.debug('Tweets updated')
gtk.gdk.threads_leave()
if new_tweets and notify:
self.unread_tweets += new_tweets
self.notify(new_tweets)
self.refresh_rate_limit()
self.prune_grid_store()
# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------
def clear_list(self):
"""Clear the list, so we can add more items."""
self.grid_store.clear()
return
def save_interface_prefs(self):
"""Using the save callback, save all this interface preferences."""
self.prefs['refresh_interval'] = \
self.refresh_interval_field.get_value_as_int()
x, y = self.window.get_position()
self.prefs['position_x'] = x
self.prefs['position_y'] = y
self.save_callback(self.username_field.get_text(),
self.password_field.get_text(),
self.https_field.get_active(),
NAMESPACE, self.prefs)
return
def refresh_rate_limit(self):
"""Request the rate limit and check if we are doing okay."""
self.twitter.rate_limit_status(self.post_refresh_rate_limit)
return
def post_refresh_rate_limit(self, data, error):
"""Callback for the refresh_rate_limit."""
if error or not data:
_log.error('Error fetching rate limit')
return
# Check if we are running low on our limit
reset_time = datetime.datetime.fromtimestamp(
int(data['reset_time_in_seconds']))
if reset_time < datetime.datetime.now():
# Clock differences can cause this
return
time_delta = reset_time - datetime.datetime.now()
mins_till_reset = time_delta.seconds/60 # Good enough!
needed_hits = mins_till_reset/self.prefs['refresh_interval']
remaining_hits = int(data['remaining_hits'])
_log.debug('remaining_hits: %s. reset in %s mins.'
% (remaining_hits, mins_till_reset))
if needed_hits > remaining_hits:
gtk.gdk.threads_enter()
error_dialog = gtk.MessageDialog(parent=self.window,
type=gtk.MESSAGE_WARNING,
flags=gtk.DIALOG_MODAL | gtk.DIALOG_DESTROY_WITH_PARENT,
message_format='Refresh rate too high',
buttons=gtk.BUTTONS_OK)
error_dialog.format_secondary_text(
"You have only %d twitter requests left until your " \
"request count is reset in %d minutes. But at your " \
"current refresh rate (every %d minutes), you will " \
"exhaust your limit within %d minutes. You should " \
"consider increasing the refresh interval in Mitter's " \
"Settings dialog." % (remaining_hits, mins_till_reset,
self.prefs['refresh_interval'],
remaining_hits * self.prefs['refresh_interval']))
error_dialog.connect("response", lambda *a:
error_dialog.destroy())
error_dialog.run()
gtk.gdk.threads_leave()
def show_last_update(self):
"""Add the last update time in the status bar."""
last_update = self.last_update.strftime('%H:%M')
next_update = (self.last_update +
datetime.timedelta(minutes=self.prefs[
'refresh_interval'])).strftime('%H:%M')
message = 'Last update %s, next update %s' % (last_update,
next_update)
self.statusbar.push(self.statusbar_context, message)
return
def queue_pic(self, pic):
"""Check if the pic is in the queue or already downloaded. If it is
not in any of those, add it to the download queue."""
if pic in self.user_pics:
return
if pic in self.pic_queue:
return
self.pic_queue.add(pic)
self.twitter.download(pic, self.post_pic_download, id=pic)
return
def post_pic_download(self, data, error, id):
"""Function called once we downloaded the user pic."""
_log.debug('Received pic %s' % (id))
if error or not data:
_log.debug('Error with the pic, not loading')
return
loader = gtk.gdk.PixbufLoader()
loader.write(data)
loader.close()
self.user_pics[id] = loader.get_pixbuf()
self.pic_queue.discard(id)
# finally, request the grid to redraw itself
gtk.gdk.threads_enter()
self.grid.queue_draw()
gtk.gdk.threads_leave()
return
# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------
def _sort_by_time(self, model, iter1, iter2, data=None):
"""The sort function where we sort by the datetime.datetime object"""
d1 = model.get_value(iter1, Columns.DATETIME)
d2 = model.get_value(iter2, Columns.DATETIME)
# Why do we get called with None values?!
if not d1:
return 1
if not d2:
return -1
if d1 < d2:
return -1
elif d1 > d2:
return 1
return 0
# ------------------------------------------------------------
# Widget creation functions
# ------------------------------------------------------------
def _systray_setup(self):
if not (self._app_icon and self._app_icon_alert):
self._systray = None
return
self._systray = gtk.StatusIcon()
self._systray.set_from_file(self._app_icon)
self._systray.connect('activate', self.systray_cb)
self._systray.set_tooltip('Mitter: Click to toggle window visibility.')
self._systray.set_visible(True)
return
# ------------------------------------------------------------
# Widget callback functions
# ------------------------------------------------------------
def _count_chars(self, text_buffer):
"""Count the number of chars in the edit field and update the
label that shows the available space."""
start = text_buffer.get_start_iter()
end = text_buffer.get_end_iter()
text = text_buffer.get_text(start, end, include_hidden_chars=False)
self._char_count.set_text('(%d)' % (140 - len(text)))
return True
def _update_status(self):
"""Update your status."""
_log.debug('Updating status.')
status = self._update_text.get_text()
status = status.strip()
if not str_len(status):
return
self.update_text.set_sensitive(False)
self.statusbar.push(self.statusbar_context, 'Updating your status...')
# ------------------------------------------------------------
# Required functions for all interfaces
# ------------------------------------------------------------
def __init__(self, connection, options):
"""Class initialization."""
self._connection = connection
self._options = options
self._user_pics = {}
self._pic_queue = set()
# Load images
self._app_icon = util.find_image('mitter.png')
self._app_icon_alert = util.find_image('mitter-new.png')
unknown_pixbuf = util.find_image('unknown.png')
if unknown_pixbuf:
self._default_pixmap = gtk.gdk.pixbuf_new_from_file(
unknown_pixbuf)
else:
self._default_pixmap = gtk.gdk.Pixbuf(gtk.gdk.COLORSPACE_RGB,
has_alpha=False, bits_per_sample=8, width=48, height=48)
self._main_window = _MainWindow(_GtkController())
self._main_window.set_title('Mitter')
self._main_window.set_size_request(10, 10) # very small minimal size
self._main_window.resize(self._options[NAMESPACE]['width'],
self._options[NAMESPACE]['height'])
self._main_window.move(self._options[NAMESPACE]['position_x'],
self._options[NAMESPACE]['position_y'])
if self._app_icon:
self._main_window.set_icon_from_file(self._app_icon)
#self._main_window()
#self._systray_setup()
# self.create_settings_dialog()
# self.username_field.set_text(default_username)
# self.password_field.set_text(default_password)
# self.https_field.set_active(self.https)
# notification helper
# self.notify_broadcast = Notify('mitter').notify
# start auto refresh activity
# self._refresh_id = None
# self.set_auto_refresh()
# self.window.set_focus(self.update_text)
return
def __call__(self):
"""Call function; displays the interface. This method should appear on
every interface."""
self._main_window.show_all()
gtk.main()