From e7d2bc3705341aa8d6c1a807d88c86b408459bb5 Mon Sep 17 00:00:00 2001 From: Aleksey Lim Date: Wed, 29 Dec 2010 14:45:11 +0000 Subject: Cleanup to code - sugar-lint fixes - move chatbox code to separate modules to reuse in other activities - more robust smiley parsing - remove pippy code --- diff --git a/AUTHORS b/AUTHORS index 0ba414b..d5b3a2f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,8 +1,19 @@ -Justin Gallardo -Marco Pesenti Gritti +Contributors +============ +Aleksey Lim +C. Scott Ananian Dafydd Harries +Dan Winship Guillaume Desmottes +John (J5) Palmieri +Justin Gallardo +Marco Pesenti Gritti Morgan Collett -Dan Winship +Mukesh Gupta +Nirbheek Chauhan Simon Schampijer -C. Scott Ananian +Tomeu Vizoso + +Maintainers +----------- +Aleksey Lim diff --git a/HACKING b/HACKING new file mode 100644 index 0000000..021f49e --- /dev/null +++ b/HACKING @@ -0,0 +1,29 @@ +How to contribute +================= + +Useful notes how to contribute to the project. + +Before committing +----------------- +All source files need to be passed through `sugar-lint`_ command. +Follow sugar-lint home page instructions and especially +`"Lint files before committing"` section. + +Send patches +------------ +Create your patches using ``git format`` command and send them to all +maintainers from the :ref:`AUTHORS ` file. The easiest way it just +using ``git send-email`` command. Patches might be CCed to +sugar-devel@lists.sugarlabs.org to attract more people to review. + +Gitorious forks +--------------- +Another useful way to contribute, especially for big improvements, is creating +Gitorious forks and request them for merge to the trunk. + +* http://blog.gitorious.org/2009/05/09/weve-made-a-few-changes/ + (see `"Merge requests"` topic) +* http://blog.gitorious.org/2009/07/15/new-merge-request-functionality/ +* http://blog.gitorious.org/2009/11/06/awesome-code-review/ + +.. _sugar-lint: http://wiki.sugarlabs.org/go/Platform_Team/Sugar_Lint diff --git a/activity.py b/activity.py index 928e2fd..ba9847b 100644 --- a/activity.py +++ b/activity.py @@ -14,87 +14,438 @@ # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +import hippo +import gtk +import logging +import cjson +import math +from gettext import gettext as _ + +from telepathy.interfaces import ( + CHANNEL_INTERFACE, CHANNEL_INTERFACE_GROUP, CHANNEL_TYPE_TEXT, + CONN_INTERFACE_ALIASING) +from telepathy.constants import ( + CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES, + CHANNEL_TEXT_MESSAGE_TYPE_NORMAL) +from telepathy.client import Connection, Channel + +from sugar.graphics import style +from sugar.graphics.alert import NotifyAlert +from sugar.graphics.palette import Palette +from sugar.graphics.toolbarbox import ToolbarBox from sugar.activity import activity +from sugar.presence import presenceservice +from sugar.activity.widgets import ActivityButton, TitleEntry, KeepButton +from sugar.activity.widgets import StopButton, ShareButton, RadioMenuButton + +from chat import smilies +from chat.box import ChatBox + + +logger = logging.getLogger('chat-activity') + +SMILIES_COLUMNS = 5 + + +class Chat(activity.Activity): -class ViewSourceActivity(activity.Activity): - """Activity subclass which handles the 'view source' key.""" def __init__(self, handle): - super(ViewSourceActivity, self).__init__(handle) - self.__source_object_id = None # XXX: persist this across invocations? - self.connect('key-press-event', self._key_press_cb) - def _key_press_cb(self, widget, event): - import gtk - if gtk.gdk.keyval_name(event.keyval) == 'XF86Start': - self.view_source() - return True - return False - def view_source(self): - """Implement the 'view source' key by saving pippy_app.py to the - datastore, and then telling the Journal to view it.""" - if self.__source_object_id is None: - from sugar import profile - from sugar.datastore import datastore - from sugar.activity.activity import get_bundle_name, get_bundle_path - from gettext import gettext as _ - import os.path - jobject = datastore.create() - metadata = { - 'title': _('%s Source') % get_bundle_name(), - 'title_set_by_user': '1', - 'suggested_filename': 'pippy_app.py', - 'icon-color': profile.get_color().to_string(), - 'mime_type': 'text/x-python', - } - for k,v in metadata.items(): - jobject.metadata[k] = v # dict.update method is missing =( - jobject.file_path = os.path.join(get_bundle_path(), 'pippy_app.py') - datastore.write(jobject) - self.__source_object_id = jobject.object_id - jobject.destroy() - self.journal_show_object(self.__source_object_id) - def journal_show_object(self, object_id): - """Invoke journal_show_object from sugar.activity.activity if it - exists.""" + super(Chat, self).__init__(handle) + + smilies.init() + + self.entry = None + self.chatbox = None + + root = self.make_root() + self.set_canvas(root) + root.show_all() + self.entry.grab_focus() + + toolbar_box = ToolbarBox() + self.set_toolbar_box(toolbar_box) + toolbar_box.toolbar.insert(ActivityButton(self), -1) + toolbar_box.toolbar.insert(TitleEntry(self), -1) + + share_button = ShareButton(self) + toolbar_box.toolbar.insert(share_button, -1) + toolbar_box.toolbar.insert(KeepButton(self), -1) + + separator = gtk.SeparatorToolItem() + toolbar_box.toolbar.insert(separator, -1) + + self._smiley = RadioMenuButton(icon_name='smilies') + self._smiley.palette = Palette(_('Insert smiley')) + toolbar_box.toolbar.insert(self._smiley, -1) + + table = self._create_pallete_smiley_table() + table.show_all() + self._smiley.palette.set_content(table) + + separator = gtk.SeparatorToolItem() + separator.props.draw = False + separator.set_expand(True) + toolbar_box.toolbar.insert(separator, -1) + + toolbar_box.toolbar.insert(StopButton(self), -1) + toolbar_box.show_all() + + pservice = presenceservice.get_instance() + self.owner = pservice.get_owner() + # Chat is room or one to one: + self._chat_is_room = False + self.text_channel = None + + if self.shared_activity: + # we are joining the activity + self.connect('joined', self._joined_cb) + if self.get_shared(): + # we have already joined + self._joined_cb(self) + elif handle.uri: + # XMPP non-Sugar incoming chat, not sharable + share_button.props.visible = False + self._one_to_one_connection(handle.uri) + else: + # we are creating the activity + if not self.metadata or self.metadata.get('share-scope', + activity.SCOPE_PRIVATE) == activity.SCOPE_PRIVATE: + # if we are in private session + self._alert(_('Off-line'), _('Share, or invite someone.')) + self.connect('shared', self._shared_cb) + + def handle_view_source(self): + pass + + def _create_pallete_smiley_table(self): + row_count = int(math.ceil(len(smilies.THEME) / float(SMILIES_COLUMNS))) + table = gtk.Table(rows=row_count, columns=SMILIES_COLUMNS) + index = 0 + + for y in range(row_count): + for x in range(SMILIES_COLUMNS): + if index >= len(smilies.THEME): + break + + path, hint, codes = smilies.THEME[index] + image = gtk.image_new_from_file(path) + button = gtk.ToolButton(icon_widget=image) + button.set_tooltip(gtk.Tooltips(), codes[0] + ' ' + hint) + button.connect('clicked', self._add_smiley_to_entry, codes[0]) + table.attach(button, x, x + 1, y, y + 1) + button.show() + + index = index + 1 + + return table + + def _add_smiley_to_entry(self, button, text): + pos = self.entry.props.cursor_position + self.entry.props.buffer.insert_text(pos, text, -1) + self.entry.set_position(pos + len(text)) + self._smiley.palette.popdown(True) + + def _shared_cb(self, sender): + logger.debug('Chat was shared') + self._setup() + + def _one_to_one_connection(self, tp_channel): + """Handle a private invite from a non-Sugar XMPP client.""" + if self.shared_activity or self.text_channel: + return + bus_name, connection, channel = cjson.decode(tp_channel) + logger.debug('GOT XMPP: %s %s %s', bus_name, connection, + channel) + Connection( + bus_name, connection, ready_handler=lambda conn: \ + self._one_to_one_connection_ready_cb(bus_name, channel, conn)) + + def _one_to_one_connection_ready_cb(self, bus_name, channel, conn): + """Callback for Connection for one to one connection""" + text_channel = Channel(bus_name, channel) + self.text_channel = TextChannelWrapper(text_channel, conn) + self.text_channel.set_received_callback(self._received_cb) + self.text_channel.handle_pending_messages() + self.text_channel.set_closed_callback( + self._one_to_one_connection_closed_cb) + self._chat_is_room = False + self._alert(_('On-line'), _('Private Chat')) + + # XXX How do we detect the sender going offline? + self.entry.set_sensitive(True) + self.entry.grab_focus() + + def _one_to_one_connection_closed_cb(self): + """Callback for when the text channel closes.""" + self._alert(_('Off-line'), _('left the chat')) + + def _setup(self): + self.text_channel = TextChannelWrapper( + self.shared_activity.telepathy_text_chan, + self.shared_activity.telepathy_conn) + self.text_channel.set_received_callback(self._received_cb) + self._alert(_('On-line'), _('Connected')) + self.shared_activity.connect('buddy-joined', self._buddy_joined_cb) + self.shared_activity.connect('buddy-left', self._buddy_left_cb) + self._chat_is_room = True + self.entry.set_sensitive(True) + self.entry.grab_focus() + + def _joined_cb(self, sender): + """Joined a shared activity.""" + if not self.shared_activity: + return + logger.debug('Joined a shared chat') + for buddy in self.shared_activity.get_joined_buddies(): + self._buddy_already_exists(buddy) + self._setup() + + def _received_cb(self, buddy, text): + """Show message that was received.""" + if buddy: + if type(buddy) is dict: + nick = buddy['nick'] + else: + nick = buddy.props.nick + else: + nick = '???' + logger.debug('Received message from %s: %s', nick, text) + self.chatbox.add_text(buddy, text) + + def _alert(self, title, text=None): + alert = NotifyAlert(timeout=5) + alert.props.title = title + alert.props.msg = text + self.add_alert(alert) + alert.connect('response', self._alert_cancel_cb) + alert.show() + + def _alert_cancel_cb(self, alert, response_id): + self.remove_alert(alert) + + def _buddy_joined_cb(self, sender, buddy): + """Show a buddy who joined""" + if buddy == self.owner: + return + self.chatbox.add_text(buddy, + buddy.props.nick + ' ' + _('joined the chat'), + status_message=True) + + def _buddy_left_cb(self, sender, buddy): + """Show a buddy who joined""" + if buddy == self.owner: + return + self.chatbox.add_text(buddy, + buddy.props.nick + ' ' + _('left the chat'), + status_message=True) + + def _buddy_already_exists(self, buddy): + """Show a buddy already in the chat.""" + if buddy == self.owner: + return + self.chatbox.add_text(buddy, buddy.props.nick + ' ' + _('is here'), + status_message=True) + + def can_close(self): + """Perform cleanup before closing. + + Close text channel of a one to one XMPP chat. + + """ + if self._chat_is_room is False: + if self.text_channel is not None: + self.text_channel.close() + return True + + def make_root(self): + entry = gtk.Entry() + entry.modify_bg(gtk.STATE_INSENSITIVE, + style.COLOR_WHITE.get_gdk_color()) + entry.modify_base(gtk.STATE_INSENSITIVE, + style.COLOR_WHITE.get_gdk_color()) + entry.set_sensitive(False) + entry.connect('activate', self.entry_activate_cb) + self.entry = entry + + self.chatbox = ChatBox() + canvas = hippo.Canvas() + canvas.set_root(self.chatbox) + + hbox = gtk.HBox() + hbox.add(entry) + + box = gtk.VBox(homogeneous=False) + box.pack_start(canvas) + box.pack_start(hbox, expand=False) + + return box + + def entry_activate_cb(self, entry): + text = entry.props.text + logger.debug('Entry: %s' % text) + if text: + self.chatbox.add_text(self.owner, text) + entry.props.text = '' + if self.text_channel: + self.text_channel.send(text) + else: + logger.debug('Tried to send message but text channel ' + 'not connected.') + + def write_file(self, file_path): + """Store chat log in Journal. + + Handling the Journal is provided by Activity - we only need + to define this method. + """ + logger.debug('write_file: writing %s' % file_path) + self.chatbox.add_log_timestamp() + f = open(file_path, 'w') try: - from sugar.activity.activity import show_object_in_journal - show_object_in_journal(object_id) - except ImportError: - pass # no love from sugar. + f.write(self.chatbox.get_log()) + finally: + f.close() + self.metadata['mime_type'] = 'text/plain' -class VteActivity(ViewSourceActivity): - def __init__(self, handle): - import gtk, pango, vte - super(VteActivity, self).__init__(handle) - toolbox = activity.ActivityToolbox(self) - self.set_toolbox(toolbox) - toolbox.show() - - # creates vte widget - self._vte = vte.Terminal() - self._vte.set_size(30,5) - self._vte.set_size_request(200, 300) - font = 'Monospace 10' - self._vte.set_font(pango.FontDescription(font)) - self._vte.set_colors(gtk.gdk.color_parse ('#000000'), - gtk.gdk.color_parse ('#E7E7E7'), - []) - # ...and its scrollbar - vtebox = gtk.HBox() - vtebox.pack_start(self._vte) - vtesb = gtk.VScrollbar(self._vte.get_adjustment()) - vtesb.show() - vtebox.pack_start(vtesb, False, False, 0) - self.set_canvas(vtebox) - self.show_all() - - # now start subprocess. - self._vte.grab_focus() - bundle_path = activity.get_bundle_path() - # the 'sleep 1' works around a bug with the command dying before - # the vte widget manages to snarf the last bits of its output - self._pid = self._vte.fork_command \ - (command='/bin/sh', - argv=['/bin/sh','-c', - 'python %s/pippy_app.py; sleep 1' % bundle_path], - envv=["PYTHONPATH=%s/library" % bundle_path], - directory=bundle_path) + def read_file(self, file_path): + """Load a chat log from the Journal. + + Handling the Journal is provided by Activity - we only need + to define this method. + """ + logger.debug('read_file: reading %s' % file_path) + log = open(file_path).readlines() + last_line_was_timestamp = False + for line in log: + if line.endswith('\t\t\n'): + if last_line_was_timestamp is False: + timestamp = line.strip().split('\t')[0] + self.chatbox.add_separator(timestamp) + last_line_was_timestamp = True + else: + timestamp, nick, color, status, text = line.strip().split('\t') + status_message = bool(int(status)) + self.chatbox.add_text({'nick': nick, 'color': color}, + text, status_message) + last_line_was_timestamp = False + + +class TextChannelWrapper(object): + """Wrap a telepathy Text Channel to make usage simpler.""" + + def __init__(self, text_chan, conn): + """Connect to the text channel""" + self._activity_cb = None + self._activity_close_cb = None + self._text_chan = text_chan + self._conn = conn + self._logger = logging.getLogger( + 'chat-activity.TextChannelWrapper') + self._signal_matches = [] + m = self._text_chan[CHANNEL_INTERFACE].connect_to_signal( + 'Closed', self._closed_cb) + self._signal_matches.append(m) + + def send(self, text): + """Send text over the Telepathy text channel.""" + # XXX Implement CHANNEL_TEXT_MESSAGE_TYPE_ACTION + if self._text_chan is not None: + self._text_chan[CHANNEL_TYPE_TEXT].Send( + CHANNEL_TEXT_MESSAGE_TYPE_NORMAL, text) + + def close(self): + """Close the text channel.""" + self._logger.debug('Closing text channel') + try: + self._text_chan[CHANNEL_INTERFACE].Close() + except Exception: + self._logger.debug('Channel disappeared!') + self._closed_cb() + + def _closed_cb(self): + """Clean up text channel.""" + self._logger.debug('Text channel closed.') + for match in self._signal_matches: + match.remove() + self._signal_matches = [] + self._text_chan = None + if self._activity_close_cb is not None: + self._activity_close_cb() + + def set_received_callback(self, callback): + """Connect the function callback to the signal. + + callback -- callback function taking buddy and text args + """ + if self._text_chan is None: + return + self._activity_cb = callback + m = self._text_chan[CHANNEL_TYPE_TEXT].connect_to_signal('Received', + self._received_cb) + self._signal_matches.append(m) + + def handle_pending_messages(self): + """Get pending messages and show them as received.""" + for identity, timestamp, sender, type_, flags, text in \ + self._text_chan[ + CHANNEL_TYPE_TEXT].ListPendingMessages(False): + self._received_cb(identity, timestamp, sender, type_, flags, text) + + def _received_cb(self, identity, timestamp, sender, type_, flags, text): + """Handle received text from the text channel. + + Converts sender to a Buddy. + Calls self._activity_cb which is a callback to the activity. + """ + if self._activity_cb: + try: + self._text_chan[CHANNEL_INTERFACE_GROUP] + except Exception: + # One to one XMPP chat + nick = self._conn[ + CONN_INTERFACE_ALIASING].RequestAliases([sender])[0] + buddy = {'nick': nick, 'color': '#000000,#808080'} + else: + # Normal sugar MUC chat + # XXX: cache these + buddy = self._get_buddy(sender) + self._activity_cb(buddy, text) + self._text_chan[ + CHANNEL_TYPE_TEXT].AcknowledgePendingMessages([identity]) + else: + self._logger.debug('Throwing received message on the floor' + ' since there is no callback connected. See ' + 'set_received_callback') + + def set_closed_callback(self, callback): + """Connect a callback for when the text channel is closed. + + callback -- callback function taking no args + + """ + self._activity_close_cb = callback + + def _get_buddy(self, cs_handle): + """Get a Buddy from a (possibly channel-specific) handle.""" + # XXX This will be made redundant once Presence Service + # provides buddy resolution + # Get the Presence Service + pservice = presenceservice.get_instance() + # Get the Telepathy Connection + tp_name, tp_path = pservice.get_preferred_connection() + conn = Connection(tp_name, tp_path) + group = self._text_chan[CHANNEL_INTERFACE_GROUP] + my_csh = group.GetSelfHandle() + if my_csh == cs_handle: + handle = conn.GetSelfHandle() + elif group.GetGroupFlags() & \ + CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES: + handle = group.GetHandleOwners([cs_handle])[0] + else: + handle = cs_handle + + # XXX: deal with failure to get the handle owner + assert handle != 0 + + return pservice.get_buddy_by_telepathy_handle( + tp_name, tp_path, handle) diff --git a/activity/activity.info b/activity/activity.info index be41831..658b145 100644 --- a/activity/activity.info +++ b/activity/activity.info @@ -3,13 +3,13 @@ sweet = chat name = Chat summary = Text chat homepage = http://wiki.sugarlabs.org/go/Activities/Chat -license = GPLv2+ and (LGPLv3 or CC-BY-SAv3) +license = GPLv2+ icon = activity-icon -exec = sugar-activity pippy_app.Chat +exec = sugar-activity activity.Chat version = 68 stability = testing -# deprecated +# original activity.info options activity_version = %(version)s bundle_id = org.laptop.Chat diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/chat/__init__.py diff --git a/chat/box.py b/chat/box.py new file mode 100644 index 0000000..d201b20 --- /dev/null +++ b/chat/box.py @@ -0,0 +1,398 @@ +# Copyright 2007-2008 One Laptop Per Child +# Copyright 2009, Aleksey Lim +# Copyright 2010, Mukesh Gupta +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import re +import os +import time +import logging +from datetime import datetime +from gettext import gettext as _ +from os.path import join + +import gtk +import hippo +import pango +import cairo + +from sugar.graphics import style +from sugar.graphics.roundbox import CanvasRoundBox +from sugar.graphics.palette import Palette, CanvasInvoker +from sugar.presence import presenceservice +from sugar.graphics.menuitem import MenuItem +from sugar.activity.activity import get_activity_root, show_object_in_journal +from sugar.util import timestamp_to_elapsed_string +from sugar.datastore import datastore +from sugar import profile + +from chat import smilies + + +_URL_REGEXP = re.compile('((http|ftp)s?://)?' + '(([-a-zA-Z0-9]+[.])+[-a-zA-Z0-9]{2,}|([0-9]{1,3}[.]){3}[0-9]{1,3})' + '(:[1-9][0-9]{0,4})?(/[-a-zA-Z0-9/%~@&_+=;:,.?#]*[a-zA-Z0-9/])?') + + +class ChatBox(hippo.CanvasScrollbars): + + def __init__(self): + hippo.CanvasScrollbars.__init__(self) + + self.owner = presenceservice.get_instance().get_owner() + + # Auto vs manual scrolling: + self._scroll_auto = True + self._scroll_value = 0.0 + self._last_msg_sender = None + # Track last message, to combine several messages: + self._last_msg = None + self._chat_log = '' + + self._conversation = hippo.CanvasBox( + spacing=0, + background_color=style.COLOR_WHITE.get_int()) + + self.set_policy(hippo.ORIENTATION_HORIZONTAL, + hippo.SCROLLBAR_NEVER) + self.set_root(self._conversation) + + vadj = self.props.widget.get_vadjustment() + vadj.connect('changed', self._scroll_changed_cb) + vadj.connect('value-changed', self._scroll_value_changed_cb) + + def get_log(self): + return self._chat_log + + def add_text(self, buddy, text, status_message=False): + """Display text on screen, with name and colors. + + buddy -- buddy object or dict {nick: string, color: string} + (The dict is for loading the chat log from the journal, + when we don't have the buddy object any more.) + text -- string, what the buddy said + status_message -- boolean + False: show what buddy said + True: show what buddy did + + hippo layout: + .------------- rb ---------------. + | +name_vbox+ +----msg_vbox----+ | + | | | | | | + | | nick: | | +--msg_hbox--+ | | + | | | | | text | | | + | +---------+ | +------------+ | | + | | | | + | | +--msg_hbox--+ | | + | | | text | url | | | + | | +------------+ | | + | +----------------+ | + `--------------------------------' + """ + if not buddy: + buddy = self.owner + + if type(buddy) is dict: + # dict required for loading chat log from journal + nick = buddy['nick'] + color = buddy['color'] + else: + nick = buddy.props.nick + color = buddy.props.color + try: + color_stroke_html, color_fill_html = color.split(',') + except ValueError: + color_stroke_html, color_fill_html = ('#000000', '#888888') + + # Select text color based on fill color: + color_fill_rgba = style.Color(color_fill_html).get_rgba() + color_fill_gray = (color_fill_rgba[0] + color_fill_rgba[1] + + color_fill_rgba[2]) / 3 + color_stroke = style.Color(color_stroke_html).get_int() + color_fill = style.Color(color_fill_html).get_int() + + if color_fill_gray < 0.5: + text_color = style.COLOR_WHITE.get_int() + else: + text_color = style.COLOR_BLACK.get_int() + + self._add_log(nick, color, text, status_message) + + # Check for Right-To-Left languages: + if pango.find_base_dir(nick, -1) == pango.DIRECTION_RTL: + lang_rtl = True + else: + lang_rtl = False + + # Check if new message box or add text to previous: + new_msg = True + if self._last_msg_sender: + if not status_message: + if buddy == self._last_msg_sender: + # Add text to previous message + new_msg = False + + if not new_msg: + rb = self._last_msg + msg_vbox = rb.get_children()[1] + msg_hbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_HORIZONTAL) + msg_vbox.append(msg_hbox) + else: + rb = CanvasRoundBox(background_color=color_fill, + border_color=color_stroke, + padding=4) + rb.props.border_color = color_stroke # Bug #3742 + self._last_msg = rb + self._last_msg_sender = buddy + if not status_message: + name = hippo.CanvasText(text=nick + ': ', color=text_color) + name_vbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_VERTICAL) + name_vbox.append(name) + rb.append(name_vbox) + msg_vbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_VERTICAL) + rb.append(msg_vbox) + msg_hbox = hippo.CanvasBox( + orientation=hippo.ORIENTATION_HORIZONTAL) + msg_vbox.append(msg_hbox) + + if status_message: + self._last_msg_sender = None + + match = _URL_REGEXP.search(text) + while match: + # there is a URL in the text + starttext = text[:match.start()] + if starttext: + message = hippo.CanvasText( + text=starttext, + size_mode=hippo.CANVAS_SIZE_WRAP_WORD, + color=text_color, + xalign=hippo.ALIGNMENT_START) + msg_hbox.append(message) + url = text[match.start():match.end()] + + message = _CanvasLink( + text=url, + color=text_color) + attrs = pango.AttrList() + attrs.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, 32767)) + message.set_property("attributes", attrs) + message.connect('activated', self._link_activated_cb) + + # call interior magic which should mean just: + # CanvasInvoker().parent = message + CanvasInvoker(message) + + msg_hbox.append(message) + text = text[match.end():] + match = _URL_REGEXP.search(text) + + if text: + for word in smilies.parse(text): + if isinstance(word, cairo.ImageSurface): + item = hippo.CanvasImage( + image=word, + border=0, + border_color=style.COLOR_BUTTON_GREY.get_int(), + xalign=hippo.ALIGNMENT_CENTER, + yalign=hippo.ALIGNMENT_CENTER) + else: + item = hippo.CanvasText( + text=word, + size_mode=hippo.CANVAS_SIZE_WRAP_WORD, + color=text_color, + xalign=hippo.ALIGNMENT_START) + msg_hbox.append(item) + + # Order of boxes for RTL languages: + if lang_rtl: + msg_hbox.reverse() + if new_msg: + rb.reverse() + + if new_msg: + box = hippo.CanvasBox(padding=2) + box.append(rb) + self._conversation.append(box) + + def add_separator(self, timestamp): + """Add whitespace and timestamp between chat sessions.""" + time_with_current_year = (time.localtime(time.time())[0],) +\ + time.strptime(timestamp, "%b %d %H:%M:%S")[1:] + + timestamp_seconds = time.mktime(time_with_current_year) + if timestamp_seconds > time.time(): + time_with_previous_year = (time.localtime(time.time())[0]-1,) +\ + time.strptime(timestamp, "%b %d %H:%M:%S")[1:] + timestamp_seconds = time.mktime(time_with_previous_year) + + message = hippo.CanvasText( + text=timestamp_to_elapsed_string(timestamp_seconds), + color=style.COLOR_BUTTON_GREY.get_int(), + font_desc=style.FONT_NORMAL.get_pango_desc(), + xalign=hippo.ALIGNMENT_CENTER) + + box = hippo.CanvasBox(padding=2) + box.append(message) + self._conversation.append(box) + self.add_log_timestamp(timestamp) + + self._last_msg_sender = None + + def add_log_timestamp(self, existing_timestamp=None): + """Add a timestamp entry to the chat log.""" + if existing_timestamp is not None: + self._chat_log += '%s\t\t\n' % existing_timestamp + else: + self._chat_log += '%s\t\t\n' % ( + datetime.strftime(datetime.now(), '%b %d %H:%M:%S')) + + def _add_log(self, nick, color, text, status_message): + """Add the text to the chat log. + + nick -- string, buddy nickname + color -- string, buddy.props.color + text -- string, body of message + status_message -- boolean + """ + if not nick: + nick = '???' + if not color: + color = '#000000,#FFFFFF' + if not text: + text = '-' + if not status_message: + status_message = False + self._chat_log += '%s\t%s\t%s\t%d\t%s\n' % ( + datetime.strftime(datetime.now(), '%b %d %H:%M:%S'), + nick, color, status_message, text) + + def _scroll_value_changed_cb(self, adj, scroll=None): + """Turn auto scrolling on or off. + + If the user scrolled up, turn it off. + If the user scrolled to the bottom, turn it back on. + """ + if adj.get_value() < self._scroll_value: + self._scroll_auto = False + elif adj.get_value() == adj.upper - adj.page_size: + self._scroll_auto = True + + def _scroll_changed_cb(self, adj, scroll=None): + """Scroll the chat window to the bottom""" + if self._scroll_auto: + adj.set_value(adj.upper - adj.page_size) + self._scroll_value = adj.get_value() + + def _link_activated_cb(self, link): + url = _url_check_protocol(link.props.text) + self._show_via_journal(url) + + def _show_via_journal(self, url): + """Ask the journal to display a URL""" + logging.debug('Create journal entry for URL: %s', url) + jobject = datastore.create() + metadata = { + 'title': "%s: %s" % (_('URL from Chat'), url), + 'title_set_by_user': '1', + 'icon-color': profile.get_color().to_string(), + 'mime_type': 'text/uri-list', + } + for k, v in metadata.items(): + jobject.metadata[k] = v + file_path = join(get_activity_root(), 'instance', '%i_' % time.time()) + open(file_path, 'w').write(url + '\r\n') + os.chmod(file_path, 0755) + jobject.set_file_path(file_path) + datastore.write(jobject) + show_object_in_journal(jobject.object_id) + jobject.destroy() + os.unlink(file_path) + + +class _CanvasLink(hippo.CanvasLink): + + def __init__(self, **kwargs): + hippo.CanvasLink.__init__(self, **kwargs) + + def create_palette(self): + return _URLMenu(self.props.text) + + +class _URLMenu(Palette): + + def __init__(self, url): + Palette.__init__(self, url) + + self.owns_clipboard = False + self.url = _url_check_protocol(url) + + menu_item = MenuItem(_('Copy to Clipboard'), 'edit-copy') + menu_item.connect('activate', self._copy_to_clipboard_cb) + self.menu.append(menu_item) + menu_item.show() + + def create_palette(self): + pass + + def _copy_to_clipboard_cb(self, menuitem): + logging.debug('Copy %s to clipboard', self.url) + clipboard = gtk.clipboard_get() + targets = [("text/uri-list", 0, 0), + ("UTF8_STRING", 0, 1)] + + if not clipboard.set_with_data(targets, + self._clipboard_data_get_cb, + self._clipboard_clear_cb, + (self.url)): + logging.error('GtkClipboard.set_with_data failed!') + else: + self.owns_clipboard = True + + def _clipboard_data_get_cb(self, clipboard, selection, info, data): + logging.debug('_clipboard_data_get_cb data=%s target=%s', data, + selection.target) + if selection.target in ['text/uri-list']: + if not selection.set_uris([data]): + logging.debug('failed to set_uris') + else: + logging.debug('not uri') + if not selection.set_text(data): + logging.debug('failed to set_text') + + def _clipboard_clear_cb(self, clipboard, data): + logging.debug('clipboard_clear_cb') + self.owns_clipboard = False + + +def _url_check_protocol(url): + """Check that the url has a protocol, otherwise prepend https:// + + url -- string + + Returns url -- string + """ + protocols = ['http://', 'https://', 'ftp://', 'ftps://'] + no_protocol = True + for protocol in protocols: + if url.startswith(protocol): + no_protocol = False + if no_protocol: + url = 'http://' + url + return url diff --git a/chat/smilies.py b/chat/smilies.py new file mode 100644 index 0000000..c3cc244 --- /dev/null +++ b/chat/smilies.py @@ -0,0 +1,152 @@ +# Copyright 2010, Mukesh Gupta +# Copyright 2010, Aleksey Lim +# +# 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 2 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, write to the Free Software +# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +import os +from os.path import join, exists +from gettext import gettext as _ + +import gtk +import cairo + +from sugar.graphics import style +from sugar.activity.activity import get_activity_root, get_bundle_path + + +THEME = [ + ('smile', _('Smile'), [':-)', ':)']), + ('wink', _('Winking'), [';-)', ';)']), + ('confused', _('Confused'), [':-/', ':/']), + ('sad', _('Sad'), [':-(', ':(']), + ('grin', _('Grin'), [':-D', ':D']), + ('neutral', _('Neutral'), (':-|', ':|')), + ('shock', _('Shock'), [':-O', ':O', '=-O', '=O']), + ('cool', _('Cool'), ['B-)', 'B)', '8-)', '8)']), + ('tongue', _('Tongue'), [':-P', ':P']), + ('blush', _('Blushing'), [':">']), + ('weep', _('Weeping'), [":'-(", ":'("]), + ('angel', _('Angel'), ['O-)', 'O)', 'O:-)', 'O:)']), + ('shutup', _("Don't tell anyone"), (':-$', ':-$')), + ('angry', _('Angry'), ('x-(', 'x(', 'X-(', 'x-(')), + ('devil', _('Devil'), ('>:>', '>:)')), + ('nerd', _('Nerd'), (':-B', ':B')), + ('kiss', _('Kiss'), (':-*', ':*')), + ('laugh', _('Laughing'), [':))']), + ('sleep', _('Sleepy'), ['I-)']), + ('sick', _('Sick'), [':-&']), + ('eyebrow', _('Raised eyebrows'), ['/:)']), + ] + +SMILIES_SIZE = int(style.STANDARD_ICON_SIZE * 0.75) + +_catalog = None + + +def init(): + """Initialise smilies data.""" + global _catalog + + if _catalog is not None: + return + _catalog = {} + + png_dir = join(get_activity_root(), 'data', 'icons', 'smilies') + svg_dir = join(get_bundle_path(), 'icons', 'smilies') + + if not exists(png_dir): + os.makedirs(png_dir) + + for index, (name, hint, codes) in enumerate(THEME): + png_path = join(png_dir, name + '.png') + + for i in codes: + _catalog[i] = png_path + THEME[index] = (png_path, hint, codes) + + if not exists(png_path): + pixbuf = _from_svg_at_size( + join(svg_dir, name + '.svg'), + SMILIES_SIZE, SMILIES_SIZE, None, True) + pixbuf.save(png_path, 'png') + + +def parse(text): + """Initialise smilies data. + + :param text: + string to parse for smilies + :returns: + array of string parts and ciaro surfaces + + """ + result = [text] + + for smiley in sorted(_catalog.keys(), lambda x, y: cmp(len(y), len(x))): + smiley_surface = cairo.ImageSurface.create_from_png(_catalog[smiley]) + new_result = [] + + for word in result: + if isinstance(word, cairo.ImageSurface): + new_result.append(word) + else: + parts = word.split(smiley) + for i in parts[:-1]: + new_result.append(i) + new_result.append(smiley_surface) + new_result.append(parts[-1]) + + result = new_result + + return result + + +def _from_svg_at_size(filename=None, width=None, height=None, handle=None, + keep_ratio=True): + """Scale and load SVG into pixbuf.""" + import rsvg + + if not handle: + handle = rsvg.Handle(filename) + + dimensions = handle.get_dimension_data() + icon_width = dimensions[0] + icon_height = dimensions[1] + if icon_width != width or icon_height != height: + ratio_width = float(width) / icon_width + ratio_height = float(height) / icon_height + + if keep_ratio: + ratio = min(ratio_width, ratio_height) + if ratio_width != ratio: + ratio_width = ratio + width = int(icon_width * ratio) + elif ratio_height != ratio: + ratio_height = ratio + height = int(icon_height * ratio) + else: + ratio_width = 1 + ratio_height = 1 + + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + context = cairo.Context(surface) + context.scale(ratio_width, ratio_height) + handle.render_cairo(context) + + loader = gtk.gdk.pixbuf_loader_new_with_mime_type('image/png') + surface.write_to_png(loader) + loader.close() + + return loader.get_pixbuf() diff --git a/pippy_app.py b/pippy_app.py deleted file mode 100644 index f69ca64..0000000 --- a/pippy_app.py +++ /dev/null @@ -1,1150 +0,0 @@ -# Copyright 2007-2008 One Laptop Per Child -# -# 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 2 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, write to the Free Software -# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA - -from gettext import gettext as _ -import hippo -import cairo -import gtk -import pango -import logging -import re -import cjson -import time -import os -import sugar -import glob -import rsvg -import math -from os.path import join, basename, exists - - - -from datetime import datetime -from activity import ViewSourceActivity -from sugar.activity.activity import Activity, ActivityToolbox, SCOPE_PRIVATE -from sugar.graphics import style -from sugar.activity.activity import get_activity_root -from sugar.graphics.alert import NotifyAlert -from sugar.graphics.style import (Color, COLOR_BLACK, COLOR_WHITE, - COLOR_BUTTON_GREY, FONT_BOLD, FONT_NORMAL) -from sugar.graphics.roundbox import CanvasRoundBox -from sugar.graphics import style -from sugar.graphics.xocolor import XoColor -from sugar.graphics.palette import Palette, CanvasInvoker -from sugar.graphics.menuitem import MenuItem -from sugar.util import timestamp_to_elapsed_string -from sugar.graphics.toolbarbox import ToolbarBox -from sugar.activity.widgets import * -from sugar.presence import presenceservice -from telepathy.client import Connection, Channel -from telepathy.interfaces import ( - CHANNEL_INTERFACE, CHANNEL_INTERFACE_GROUP, CHANNEL_TYPE_TEXT, - CONN_INTERFACE_ALIASING) -from telepathy.constants import ( - CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES, - CHANNEL_TEXT_MESSAGE_TYPE_NORMAL) - -logger = logging.getLogger('chat-activity') - -URL_REGEXP = re.compile('((http|ftp)s?://)?' - '(([-a-zA-Z0-9]+[.])+[-a-zA-Z0-9]{2,}|([0-9]{1,3}[.]){3}[0-9]{1,3})' - '(:[1-9][0-9]{0,4})?(/[-a-zA-Z0-9/%~@&_+=;:,.?#]*[a-zA-Z0-9/])?') - -TEMP_SVG_PATH="icons/smilies" -ICON_SVG_PATH=os.path.join(get_activity_root(),'data', 'icons','smilies') - -## For adding a new smiley add an entry in this dictionary and place the corresponding smiley file in data/icons -SMILIES = [ - ('smile', _('Smile'), [':)', ':-)']), - ('wink', _('Winking'), [';)', ';-)']), - ('sad', _('Sad'), [':-(', ':(']), - ('grin', _('Grin'), [':D', ':-D']), - ('shock', _('Shock'), [':O', ':-O', '=-O', '=O']), - ('cool', _('Cool'), ['B)', 'B-)', '8-)', '8)']), - ('tongue', _('Tongue'), [':P', ':-P']), - ('blush', _('Blushing'), [':">']), - ('weep', _('Weeping'), [":'-(", ":'("]), - ('confused', _('Confused'), [':-/', ':/']), - ('angel', _('Angel'), ['O)', 'O-)', 'O:-)', 'O:)']), - ('shutup', _("Don't tell anyone"), (':-$', ':-$')), - ('neutral', _('Neutral'), (':-|', ':|')), - ('angry', _('Angry'), ('x-(', 'x(', 'X-(', 'x-(')), - ('devil', _('Devil'), ('>:>', '>:)')), - ('nerd', _('Nerd'), (':-B', ':B')), - ('kiss', _('Kiss'), (':-*', ':*')), - ('laugh', _('Laughing'), [':))']), - ('sleep', _('Sleepy'), ['I-)']), - ('sick', _('Sick'), [':-&']), - ('eyebrow', _('Raised eyebrows'), ['/:)']), - ] - -SMILEY_NAMES = {} - -for name, hint_, smilies in SMILIES: - for i in smilies: - SMILEY_NAMES[i] = name - - -SMILIES_COLUMNS = 5 -SMILIES_SIZE = int(style.STANDARD_ICON_SIZE * 0.75) - - -def find_key(dic, val): - return [k for k, v in dic.iteritems() if v == val][0] - -def process_text_for_continuous_smileys(text): - for key in SMILEY_NAMES.keys(): - text=text.replace(key," "+key+" ") - return text - -###Converts svg into png -def from_svg_at_size(filename=None, width=None, height=None, handle=None, - keep_ratio=True): - """Scale and load SVG into pixbuf""" - - if not handle: - handle = rsvg.Handle(filename) - - dimensions = handle.get_dimension_data() - icon_width = dimensions[0] - icon_height = dimensions[1] - if icon_width != width or icon_height != height: - ratio_width = float(width) / icon_width - ratio_height = float(height) / icon_height - - if keep_ratio: - ratio = min(ratio_width, ratio_height) - if ratio_width != ratio: - ratio_width = ratio - width = int(icon_width * ratio) - elif ratio_height != ratio: - ratio_height = ratio - height = int(icon_height * ratio) - else: - ratio_width = 1 - ratio_height = 1 - - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) - context = cairo.Context(surface) - context.scale(ratio_width, ratio_height) - handle.render_cairo(context) - - loader = gtk.gdk.pixbuf_loader_new_with_mime_type('image/png') - surface.write_to_png(loader) - loader.close() - - return loader.get_pixbuf() - -### Invoked on first run to create pngs from svgs and store in ICON_SVG_PATH -def create_pngs(): - if not exists(ICON_SVG_PATH): - os.makedirs(ICON_SVG_PATH) - - for name, hint_, smilies_ in SMILIES: - dst_path = join(ICON_SVG_PATH, name + '.png') - if exists(dst_path): - continue - src_path = join(TEMP_SVG_PATH, name + '.svg') - pixbuf = from_svg_at_size(src_path, - SMILIES_SIZE, SMILIES_SIZE, None, True) - pixbuf.save(dst_path, 'png') - -##returns an Image for a given smily -def get_smiley(text): - file_name=os.path.join(ICON_SVG_PATH , SMILEY_NAMES[text] + '.png') - surface = cairo.ImageSurface.create_from_png(file_name) - image = hippo.CanvasImage(image=surface, - border=0, - border_color=style.COLOR_BUTTON_GREY.get_int(), - xalign=hippo.ALIGNMENT_CENTER, - yalign=hippo.ALIGNMENT_CENTER) - return image - -class Chat(ViewSourceActivity): - def __init__(self, handle): - super(Chat, self).__init__(handle) - - root = self.make_root() - self.set_canvas(root) - root.show_all() - self.entry.grab_focus() - - toolbar_box = ToolbarBox() - self.set_toolbar_box(toolbar_box) - toolbar_box.toolbar.insert(ActivityButton(self), -1) - toolbar_box.toolbar.insert(TitleEntry(self), -1) - - ###check for existence of icons directory - create_pngs() - - share_button = ShareButton(self) - toolbar_box.toolbar.insert(share_button, -1) - toolbar_box.toolbar.insert(KeepButton(self), -1) - - separator = gtk.SeparatorToolItem() - toolbar_box.toolbar.insert(separator, -1) - - self._smiley = RadioMenuButton(icon_name='smilies') - self._smiley.palette = Palette(_('Insert smiley')) - toolbar_box.toolbar.insert(self._smiley, -1) - - table = self._create_pallete_smiley_table() - table.show_all() - self._smiley.palette.set_content(table) - - separator = gtk.SeparatorToolItem() - separator.props.draw = False - separator.set_expand(True) - toolbar_box.toolbar.insert(separator, -1) - - toolbar_box.toolbar.insert(StopButton(self), -1) - toolbar_box.show_all() - - pservice = presenceservice.get_instance() - self.owner = pservice.get_owner() - self._chat_log = '' - # Auto vs manual scrolling: - self._scroll_auto = True - self._scroll_value = 0.0 - # Track last message, to combine several messages: - self._last_msg = None - self._last_msg_sender = None - # Chat is room or one to one: - self._chat_is_room = False - self.text_channel = None - - if self.shared_activity: - # we are joining the activity - self.connect('joined', self._joined_cb) - if self.get_shared(): - # we have already joined - self._joined_cb() - elif handle.uri: - # XMPP non-Sugar incoming chat, not sharable - share_button.props.visible = False - self._one_to_one_connection(handle.uri) - else: - # we are creating the activity - if not self.metadata or self.metadata.get('share-scope', - SCOPE_PRIVATE) == SCOPE_PRIVATE: - # if we are in private session - self._alert(_('Off-line'), _('Share, or invite someone.')) - self.connect('shared', self._shared_cb) - - def _create_pallete_smiley_table(self): - row_count = int(math.ceil(len(SMILIES) / float(SMILIES_COLUMNS))) - table = gtk.Table(rows=row_count, columns=SMILIES_COLUMNS) - index = 0 - - for y in range(row_count): - for x in range(SMILIES_COLUMNS): - name, hint, smilies = SMILIES[index] - - image = gtk.image_new_from_file( - join(ICON_SVG_PATH, name + '.png')) - button = gtk.ToolButton(icon_widget=image) - button.set_tooltip(gtk.Tooltips(), smilies[0] + ' ' + hint) - button.connect('clicked', self._add_smiley_to_entry, smilies[0]) - table.attach(button, x, x + 1, y, y + 1) - button.show() - - index = index + 1 - if index >= len(SMILIES): - break - - return table - - def _add_smiley_to_entry(self, button, text): - pos = self.entry.props.cursor_position - self.entry.props.buffer.insert_text(pos, text, -1) - self.entry.set_position(pos + len(text)) - self._smiley.palette.popdown(True) - - def _shared_cb(self, activity): - logger.debug('Chat was shared') - self._setup() - - def _one_to_one_connection(self, tp_channel): - """Handle a private invite from a non-Sugar XMPP client.""" - if self.shared_activity or self.text_channel: - return - bus_name, connection, channel = cjson.decode(tp_channel) - logger.debug('GOT XMPP: %s %s %s', bus_name, connection, - channel) - conn = Connection( - bus_name, connection, ready_handler=lambda conn: \ - self._one_to_one_connection_ready_cb(bus_name, channel, conn)) - - def _one_to_one_connection_ready_cb(self, bus_name, channel, conn): - """Callback for Connection for one to one connection""" - text_channel = Channel(bus_name, channel) - self.text_channel = TextChannelWrapper(text_channel, conn) - self.text_channel.set_received_callback(self._received_cb) - self.text_channel.handle_pending_messages() - self.text_channel.set_closed_callback( - self._one_to_one_connection_closed_cb) - self._chat_is_room = False - self._alert(_('On-line'), _('Private Chat')) - - # XXX How do we detect the sender going offline? - self.entry.set_sensitive(True) - self.entry.grab_focus() - - def _one_to_one_connection_closed_cb(self): - """Callback for when the text channel closes.""" - self._alert(_('Off-line'), _('left the chat')) - - def _setup(self): - self.text_channel = TextChannelWrapper( - self.shared_activity.telepathy_text_chan, - self.shared_activity.telepathy_conn) - self.text_channel.set_received_callback(self._received_cb) - self._alert(_('On-line'), _('Connected')) - self.shared_activity.connect('buddy-joined', self._buddy_joined_cb) - self.shared_activity.connect('buddy-left', self._buddy_left_cb) - self._chat_is_room = True - self.entry.set_sensitive(True) - self.entry.grab_focus() - - def _joined_cb(self, activity): - """Joined a shared activity.""" - if not self.shared_activity: - return - logger.debug('Joined a shared chat') - for buddy in self.shared_activity.get_joined_buddies(): - self._buddy_already_exists(buddy) - self._setup() - - def _received_cb(self, buddy, text): - """Show message that was received.""" - if buddy: - if type(buddy) is dict: - nick = buddy['nick'] - else: - nick = buddy.props.nick - else: - nick = '???' - logger.debug('Received message from %s: %s', nick, text) - self.add_text(buddy, text) - - def _alert(self, title, text=None): - alert = NotifyAlert(timeout=5) - alert.props.title = title - alert.props.msg = text - self.add_alert(alert) - alert.connect('response', self._alert_cancel_cb) - alert.show() - - def _alert_cancel_cb(self, alert, response_id): - self.remove_alert(alert) - - def _buddy_joined_cb (self, activity, buddy): - """Show a buddy who joined""" - if buddy == self.owner: - return - if buddy: - nick = buddy.props.nick - else: - nick = '???' - self.add_text(buddy, buddy.props.nick+' '+_('joined the chat'), - status_message=True) - - def _buddy_left_cb (self, activity, buddy): - """Show a buddy who joined""" - if buddy == self.owner: - return - if buddy: - nick = buddy.props.nick - else: - nick = '???' - self.add_text(buddy, buddy.props.nick+' '+_('left the chat'), - status_message=True) - - def _buddy_already_exists(self, buddy): - """Show a buddy already in the chat.""" - if buddy == self.owner: - return - if buddy: - nick = buddy.props.nick - else: - nick = '???' - self.add_text(buddy, buddy.props.nick+' '+_('is here'), - status_message=True) - - def can_close(self): - """Perform cleanup before closing. - - Close text channel of a one to one XMPP chat. - - """ - if self._chat_is_room is False: - if self.text_channel is not None: - self.text_channel.close() - return True - - def make_root(self): - conversation = hippo.CanvasBox( - spacing=0, - background_color=COLOR_WHITE.get_int()) - self.conversation = conversation - - entry = gtk.Entry() - entry.modify_bg(gtk.STATE_INSENSITIVE, - COLOR_WHITE.get_gdk_color()) - entry.modify_base(gtk.STATE_INSENSITIVE, - COLOR_WHITE.get_gdk_color()) - entry.set_sensitive(False) - entry.connect('activate', self.entry_activate_cb) - self.entry = entry - - hbox = gtk.HBox() - hbox.add(entry) - - sw = hippo.CanvasScrollbars() - sw.set_policy(hippo.ORIENTATION_HORIZONTAL, hippo.SCROLLBAR_NEVER) - sw.set_root(conversation) - self.scrolled_window = sw - - vadj = self.scrolled_window.props.widget.get_vadjustment() - vadj.connect('changed', self.rescroll) - vadj.connect('value-changed', self.scroll_value_changed_cb) - - canvas = hippo.Canvas() - canvas.set_root(sw) - - box = gtk.VBox(homogeneous=False) - box.pack_start(canvas) - box.pack_start(hbox, expand=False) - - return box - - def rescroll(self, adj, scroll=None): - """Scroll the chat window to the bottom""" - if self._scroll_auto: - adj.set_value(adj.upper-adj.page_size) - self._scroll_value = adj.get_value() - - def scroll_value_changed_cb(self, adj, scroll=None): - """Turn auto scrolling on or off. - - If the user scrolled up, turn it off. - If the user scrolled to the bottom, turn it back on. - """ - if adj.get_value() < self._scroll_value: - self._scroll_auto = False - elif adj.get_value() == adj.upper-adj.page_size: - self._scroll_auto = True - - def _link_activated_cb(self, link): - url = url_check_protocol(link.props.text) - self._show_via_journal(url) - - - - def add_text(self, buddy, text, status_message=False): - """Display text on screen, with name and colors. - - buddy -- buddy object or dict {nick: string, color: string} - (The dict is for loading the chat log from the journal, - when we don't have the buddy object any more.) - text -- string, what the buddy said - status_message -- boolean - False: show what buddy said - True: show what buddy did - - hippo layout: - .------------- rb ---------------. - | +name_vbox+ +----msg_vbox----+ | - | | | | | | - | | nick: | | +--msg_hbox--+ | | - | | | | | text | | | - | +---------+ | +------------+ | | - | | | | - | | +--msg_hbox--+ | | - | | | text | url | | | - | | +------------+ | | - | +----------------+ | - `--------------------------------' - """ - if buddy: - if type(buddy) is dict: - # dict required for loading chat log from journal - nick = buddy['nick'] - color = buddy['color'] - else: - nick = buddy.props.nick - color = buddy.props.color - try: - color_stroke_html, color_fill_html = color.split(',') - except ValueError: - color_stroke_html, color_fill_html = ('#000000', '#888888') - # Select text color based on fill color: - color_fill_rgba = Color(color_fill_html).get_rgba() - color_fill_gray = (color_fill_rgba[0] + color_fill_rgba[1] + - color_fill_rgba[2])/3 - color_stroke = Color(color_stroke_html).get_int() - color_fill = Color(color_fill_html).get_int() - if color_fill_gray < 0.5: - text_color = COLOR_WHITE.get_int() - else: - text_color = COLOR_BLACK.get_int() - else: - nick = '???' # XXX: should be '' but leave for debugging - color_stroke = COLOR_BLACK.get_int() - color_fill = COLOR_WHITE.get_int() - text_color = COLOR_BLACK.get_int() - color = '#000000,#FFFFFF' - self._add_log(nick, color, text, status_message) - - # Check for Right-To-Left languages: - if pango.find_base_dir(nick, -1) == pango.DIRECTION_RTL: - lang_rtl = True - else: - lang_rtl = False - - # Check if new message box or add text to previous: - new_msg = True - if self._last_msg_sender: - if not status_message: - if buddy == self._last_msg_sender: - # Add text to previous message - new_msg = False - - if not new_msg: - rb = self._last_msg - msg_vbox = rb.get_children()[1] - msg_hbox = hippo.CanvasBox( - orientation=hippo.ORIENTATION_HORIZONTAL) - msg_vbox.append(msg_hbox) - else: - rb = CanvasRoundBox(background_color=color_fill, - border_color=color_stroke, - padding=4) - rb.props.border_color = color_stroke # Bug #3742 - self._last_msg = rb - self._last_msg_sender = buddy - if not status_message: - name = hippo.CanvasText(text=nick+': ', - color=text_color, - font_desc=FONT_BOLD.get_pango_desc()) - name_vbox = hippo.CanvasBox( - orientation=hippo.ORIENTATION_VERTICAL) - name_vbox.append(name) - rb.append(name_vbox) - msg_vbox = hippo.CanvasBox( - orientation=hippo.ORIENTATION_VERTICAL) - rb.append(msg_vbox) - msg_hbox = hippo.CanvasBox( - orientation=hippo.ORIENTATION_HORIZONTAL) - msg_vbox.append(msg_hbox) - - if status_message: - self._last_msg_sender = None - - match = URL_REGEXP.search(text) - while match: - # there is a URL in the text - starttext = text[:match.start()] - if starttext: - message = hippo.CanvasText( - text=starttext, - size_mode=hippo.CANVAS_SIZE_WRAP_WORD, - color=text_color, - font_desc=FONT_NORMAL.get_pango_desc(), - xalign=hippo.ALIGNMENT_START) - msg_hbox.append(message) - url = text[match.start():match.end()] - message = hippo.CanvasLink( - text=url, - color=text_color, - font_desc=FONT_BOLD.get_pango_desc(), - ) - attrs = pango.AttrList() - attrs.insert(pango.AttrUnderline(pango.UNDERLINE_SINGLE, 0, 32767)) - message.set_property("attributes", attrs) - message.connect('activated', self._link_activated_cb) - - palette = URLMenu(url) - palette.props.invoker = CanvasInvoker(message) - - msg_hbox.append(message) - text = text[match.end():] - match = URL_REGEXP.search(text) - if text: - text=process_text_for_continuous_smileys(text) - line=text - - words=line.split(' ') - for word in words: - if word in SMILEY_NAMES: - - image=get_smiley(word) - msg_hbox.append(image) - - else: - message = hippo.CanvasText(text=word+" ", #change here for changing the typed text - size_mode=hippo.CANVAS_SIZE_WRAP_WORD, - color=text_color, - font_desc=FONT_NORMAL.get_pango_desc(), - xalign=hippo.ALIGNMENT_START) - msg_hbox.append(message) - - # Order of boxes for RTL languages: - if lang_rtl: - msg_hbox.reverse() - if new_msg: - rb.reverse() - - if new_msg: - box = hippo.CanvasBox(padding=2) - box.append(rb) - self.conversation.append(box) - - def add_separator(self, timestamp): - """Add whitespace and timestamp between chat sessions.""" - box = hippo.CanvasBox(padding=2) - time_with_current_year = (time.localtime(time.time())[0],) +\ - time.strptime(timestamp, "%b %d %H:%M:%S")[1:] - timestamp_seconds = time.mktime(time_with_current_year) - if timestamp_seconds > time.time(): - time_with_previous_year = (time.localtime(time.time())[0]-1,) +\ - time.strptime(timestamp, "%b %d %H:%M:%S")[1:] - timestamp_seconds = time.mktime(time_with_previous_year) - message = hippo.CanvasText( - text=timestamp_to_elapsed_string(timestamp_seconds), - color=COLOR_BUTTON_GREY.get_int(), - font_desc=FONT_NORMAL.get_pango_desc(), - xalign=hippo.ALIGNMENT_CENTER) - box.append(message) - self.conversation.append(box) - self._last_msg_sender = None - self._add_log_timestamp(timestamp) - - def entry_activate_cb(self, entry): - text = entry.props.text - logger.debug('Entry: %s' % text) - if text: - self.add_text(self.owner, text) - entry.props.text = '' - if self.text_channel: - self.text_channel.send(text) - else: - logger.debug('Tried to send message but text channel ' - 'not connected.') - - def _add_log(self, nick, color, text, status_message): - """Add the text to the chat log. - - nick -- string, buddy nickname - color -- string, buddy.props.color - text -- string, body of message - status_message -- boolean - """ - if not nick: - nick = '???' - if not color: - color = '#000000,#FFFFFF' - if not text: - text = '-' - if not status_message: - status_message = False - self._chat_log += '%s\t%s\t%s\t%d\t%s\n' % ( - datetime.strftime(datetime.now(), '%b %d %H:%M:%S'), - nick, color, status_message, text) - - def _add_log_timestamp(self, existing_timestamp=None): - """Add a timestamp entry to the chat log.""" - if existing_timestamp is not None: - self._chat_log += '%s\t\t\n' % existing_timestamp - else: - self._chat_log += '%s\t\t\n' % ( - datetime.strftime(datetime.now(), '%b %d %H:%M:%S')) - - def _get_log(self): - return self._chat_log - - def write_file(self, file_path): - """Store chat log in Journal. - - Handling the Journal is provided by Activity - we only need - to define this method. - """ - logger.debug('write_file: writing %s' % file_path) - self._add_log_timestamp() - f = open(file_path, 'w') - try: - f.write(self._get_log()) - finally: - f.close() - self.metadata['mime_type'] = 'text/plain' - - def read_file(self, file_path): - """Load a chat log from the Journal. - - Handling the Journal is provided by Activity - we only need - to define this method. - """ - logger.debug('read_file: reading %s' % file_path) - log = open(file_path).readlines() - last_line_was_timestamp = False - for line in log: - if line.endswith('\t\t\n'): - if last_line_was_timestamp is False: - timestamp = line.strip().split('\t')[0] - self.add_separator(timestamp) - last_line_was_timestamp = True - else: - timestamp, nick, color, status, text = line.strip().split('\t') - status_message = bool(int(status)) - self.add_text({'nick': nick, 'color': color}, - text, status_message) - last_line_was_timestamp = False - - def _show_via_journal(self, url): - """Ask the journal to display a URL""" - import os - import time - from sugar import profile - from sugar.activity.activity import show_object_in_journal - from sugar.datastore import datastore - logger.debug('Create journal entry for URL: %s', url) - jobject = datastore.create() - metadata = { - 'title': "%s: %s" % (_('URL from Chat'), url), - 'title_set_by_user': '1', - 'icon-color': profile.get_color().to_string(), - 'mime_type': 'text/uri-list', - } - for k,v in metadata.items(): - jobject.metadata[k] = v - file_path = os.path.join(self.get_activity_root(), 'instance', - '%i_' % time.time()) - open(file_path, 'w').write(url + '\r\n') - os.chmod(file_path, 0755) - jobject.set_file_path(file_path) - datastore.write(jobject) - show_object_in_journal(jobject.object_id) - jobject.destroy() - os.unlink(file_path) - - -class TextChannelWrapper(object): - """Wrap a telepathy Text Channel to make usage simpler.""" - def __init__(self, text_chan, conn): - """Connect to the text channel""" - self._activity_cb = None - self._activity_close_cb = None - self._text_chan = text_chan - self._conn = conn - self._logger = logging.getLogger( - 'chat-activity.TextChannelWrapper') - self._signal_matches = [] - m = self._text_chan[CHANNEL_INTERFACE].connect_to_signal( - 'Closed', self._closed_cb) - self._signal_matches.append(m) - - def send(self, text): - """Send text over the Telepathy text channel.""" - # XXX Implement CHANNEL_TEXT_MESSAGE_TYPE_ACTION - if self._text_chan is not None: - self._text_chan[CHANNEL_TYPE_TEXT].Send( - CHANNEL_TEXT_MESSAGE_TYPE_NORMAL, text) - - def close(self): - """Close the text channel.""" - self._logger.debug('Closing text channel') - try: - self._text_chan[CHANNEL_INTERFACE].Close() - except: - self._logger.debug('Channel disappeared!') - self._closed_cb() - - def _closed_cb(self): - """Clean up text channel.""" - self._logger.debug('Text channel closed.') - for match in self._signal_matches: - match.remove() - self._signal_matches = [] - self._text_chan = None - if self._activity_close_cb is not None: - self._activity_close_cb() - - def set_received_callback(self, callback): - """Connect the function callback to the signal. - - callback -- callback function taking buddy and text args - """ - if self._text_chan is None: - return - self._activity_cb = callback - m = self._text_chan[CHANNEL_TYPE_TEXT].connect_to_signal('Received', - self._received_cb) - self._signal_matches.append(m) - - def handle_pending_messages(self): - """Get pending messages and show them as received.""" - for id, timestamp, sender, type, flags, text in \ - self._text_chan[ - CHANNEL_TYPE_TEXT].ListPendingMessages(False): - self._received_cb(id, timestamp, sender, type, flags, text) - - def _received_cb(self, id, timestamp, sender, type, flags, text): - """Handle received text from the text channel. - - Converts sender to a Buddy. - Calls self._activity_cb which is a callback to the activity. - """ - if self._activity_cb: - try: - self._text_chan[CHANNEL_INTERFACE_GROUP] - except: - # One to one XMPP chat - nick = self._conn[ - CONN_INTERFACE_ALIASING].RequestAliases([sender])[0] - buddy = {'nick': nick, 'color': '#000000,#808080'} - else: - # Normal sugar MUC chat - # XXX: cache these - buddy = self._get_buddy(sender) - self._activity_cb(buddy, text) - self._text_chan[ - CHANNEL_TYPE_TEXT].AcknowledgePendingMessages([id]) - else: - self._logger.debug('Throwing received message on the floor' - ' since there is no callback connected. See ' - 'set_received_callback') - - def set_closed_callback(self, callback): - """Connect a callback for when the text channel is closed. - - callback -- callback function taking no args - - """ - self._activity_close_cb = callback - - def _get_buddy(self, cs_handle): - """Get a Buddy from a (possibly channel-specific) handle.""" - # XXX This will be made redundant once Presence Service - # provides buddy resolution - # Get the Presence Service - pservice = presenceservice.get_instance() - # Get the Telepathy Connection - tp_name, tp_path = pservice.get_preferred_connection() - conn = Connection(tp_name, tp_path) - group = self._text_chan[CHANNEL_INTERFACE_GROUP] - my_csh = group.GetSelfHandle() - if my_csh == cs_handle: - handle = conn.GetSelfHandle() - elif group.GetGroupFlags() & \ - CHANNEL_GROUP_FLAG_CHANNEL_SPECIFIC_HANDLES: - handle = group.GetHandleOwners([cs_handle])[0] - else: - handle = cs_handle - - # XXX: deal with failure to get the handle owner - assert handle != 0 - - return pservice.get_buddy_by_telepathy_handle( - tp_name, tp_path, handle) - -class URLMenu(Palette): - def __init__(self, url): - Palette.__init__(self, url) - - self.url = url_check_protocol(url) - - menu_item = MenuItem(_('Copy to Clipboard'), 'edit-copy') - menu_item.connect('activate', self._copy_to_clipboard_cb) - self.menu.append(menu_item) - menu_item.show() - - def _copy_to_clipboard_cb(self, menuitem): - logger.debug('Copy %s to clipboard', self.url) - clipboard = gtk.clipboard_get() - targets = [("text/uri-list", 0, 0), - ("UTF8_STRING", 0, 1)] - - if not clipboard.set_with_data(targets, - self._clipboard_data_get_cb, - self._clipboard_clear_cb, - (self.url)): - logger.error('GtkClipboard.set_with_data failed!') - else: - self.owns_clipboard = True - - def _clipboard_data_get_cb(self, clipboard, selection, info, data): - logger.debug('_clipboard_data_get_cb data=%s target=%s', data, - selection.target) - if selection.target in ['text/uri-list']: - if not selection.set_uris([data]): - logger.debug('failed to set_uris') - else: - logger.debug('not uri') - if not selection.set_text(data): - logger.debug('failed to set_text') - - def _clipboard_clear_cb(self, clipboard, data): - logger.debug('clipboard_clear_cb') - self.owns_clipboard = False - - -def url_check_protocol(url): - """Check that the url has a protocol, otherwise prepend https:// - - url -- string - - Returns url -- string - """ - protocols = ['http://', 'https://', 'ftp://', 'ftps://'] - no_protocol = True - for protocol in protocols: - if url.startswith(protocol): - no_protocol = False - if no_protocol: - url = 'http://' + url - return url - -############# ACTIVITY META-INFORMATION ############### -# this is used by Pippy to generate the Chat bundle. - -CHAT_ICON=\ -""" - -]> - - -""" - -CHAT_NEWS=""" -60 - -* Version bump to allow for stable releases after v48 -* #8772: Fix journal entry creation in Chat for uri-list (kevix) -* #8471: Allow resuming Chat log in Write (morgs) -* #8411: Add license to activity.info (morgs) -* Remove parameter from bundlebuilder.start (morgs) -* Add update_url for software updater (morgs) - -45 - -* Updated translations: sl, nb, el, mr, rw, ur, ne -* Fixed MANIFEST to include all translations (morgs) - -44 - -* #7633: Close the text channel when stopping a 1-1 chat (morgs) -* #7717: Log incoming messages (morgs) -* #7692: Don't show pending messages when joining a chat (morgs) -* Updated translations: nl, te, es, mn - -43 - -* Updated translations: zh_TW, ja - -42 - -* #6036: Show timestamp as elapsed time instead of date (morgs) -* Updated translations: fr, mvo, pis, af, sd, pap, tpi, ar, de - -41 - -* Updated translations: mr, de, ht, km, es, it -* #6036: Add separator after old chat history (morgs) -* #6298: Implement 1-1 private chat with non Sugar Jabber clients (morgs) - -40 - -#5767: Use black text on light fill colors (matthias) - -39 - -* ACK received messages (cassidy) -* Handle pending messages when setting the message handler (cassidy) - -38 - -* Updated translations: zh_TW, de, it - -37 - -* UI Change: Merge multiple sequential messages from same author (morgs) -* Updated translation: ar (pootle) -* #6561: Fix RTL message alignment (Arabic) (khaled) - -36 - -* #5053: Reduce white space around boxes (morgs) -* #6621: set entry sensitive not editable (morgs) -* Add license to activity.py (morgs) -* #6743: border around gtk.entry (morgs) -* Reduce telepathy code based on improved PS channel creation API (morgs) -* Open URLs via show_object_in_journal (morgs) -* Update pippy metadata based on Pippy (morgs) -* Updated translations (pootle) - -35 - -* #6066: Make web links copied to clipboard, pasteable in Write, Browse, - Terminal (morgs) -* Added AUTHORS, COPYING (morgs) -* Updated translations (pootle) - -34 - -* Updated translations: ur, bn (pootle) -* #2351: Scrolling fixed (marcopg) - -33 - -32 - -* #5542: Repackaged as a Pippy application. (cscott) - -31 - -* Updated translations: fa, is (pootle) -* #5080: Copy to clipboard with targets (morgs) - -30 - -* Updated translations: es, fr, ne, pt, ro, ru, ur (pootle) -* #5160: Chat should not autoscroll while you scroll up (morgs) - -29 - -* #5080: add a "copy to clipboard" palette for URL's (cassidy) -* Updated translations: fr, es, el, de, ar, zh_TW, it, nl, pt_BR (pootle) - -28 - -* use NotifyAlert from sugar.graphics.alert instead of local - copy (thanks erikos!) (morgs) - -27 - -* Use sugar.graphics.alert to show status info (morgs) -* #4320: better URL handling and display (morgs) -* #4331: Don't crash/ignore non-Sugar buddies (morgs) - -26 - -* #4320 Better URL support (morgs) - -25 - -* #3417 Resuming shows chat history (morgs) -* self.set_title() considered harmful (morgs) -* New UI look per Eben's mockups (morgs) - -24 - -* #3556: Updated spanish translation (morgs) - -23 - -* Updated spanish translation (morgs) - -22 - -* Revert message dialog added by mistake. (marco) - -21 - -* Add spanish translation (xavi) - -20 - -* Update translation strings - genpot (morgs) -* #3248 Make chat not shared by default (morgs) - -19 - -* Added missing fill_color in icon (erikos) - -18 - -* New activity icon, Fix for #2829 (erikos) - -16 - -* Fix icon and roundbox changes in sugar (morgs) -* Add greek translation (simosx) -* Add arabic translation (khaled) - -15 - -* Rename buddy icon (morgs) -* Regen Chat.pot (danw) -* French translation (marcopg) - -14 - -* #2714 sugar.graphics cleanup (morgs) -* #2578 German translation (morgs) - -13 - -* Added gettext for i18n (morgs) - -12 - -* #2347 Set initial focus on text entry (cassidy) - -11 - -* #2356 Basic link support. (marco) - -10 - -* Adapt to sugar API change (marco) - -9 - -* Fix buddy handles for Salut (Link Local) channels (morgs) -* Show status messages in different colour to text messages (morgs) - -8 - -* Use room provided by PS instead of hardcoded global room (morgs) - -""" - -def pippy_activity_version(): - """Returns the version number of the generated activity bundle.""" - return 60 - -def pippy_activity_news(): - """Return the NEWS file for this activity.""" - return CHAT_NEWS - -def pippy_activity_icon(): - """Return an SVG document specifying the icon for this activity.""" - return CHAT_ICON - -def pippy_activity_class(): - """Return the class which should be started to run this activity.""" - return 'pippy_app.Chat' - -def pippy_activity_extra_info(): - return "host_version = 1" -if False: # only the official Chat should have this bundle_id. - def pippy_activity_bundle_id(): - """Return the bundle_id for the generated activity.""" - return 'org.laptop.Chat' - -if __name__ == '__main__': - print "Use 'Keep As Activity' to create a new version of Chat." -- cgit v0.9.1