comparison frontends/src/quick_frontend/quick_chat.py @ 1963:a2bc5089c2eb

backend, frontends: message refactoring (huge commit): /!\ several features are temporarily disabled, like notifications in frontends next step in refactoring, with the following changes: - jp: updated jp message to follow changes in backend/bridge - jp: added --lang, --subject, --subject_lang, and --type options to jp message + fixed unicode handling for jid - quick_frontend (QuickApp, QuickChat): - follow backend changes - refactored chat, message are now handled in OrderedDict and uid are kept so they can be updated - Message and Occupant classes handle metadata, so frontend just have to display them - Primitivus (Chat): - follow backend/QuickFrontend changes - info & standard messages are handled in the same MessageWidget class - improved/simplified handling of messages, removed update() method - user joined/left messages are merged when next to each other - a separator is shown when message is received while widget is out of focus, so user can quickly see the new messages - affiliation/role are shown (in a basic way for now) in occupants panel - removed "/me" messages handling, as it will be done by a backend plugin - message language is displayed when available (only one language per message for now) - fixed :history and :search commands - core (constants): new constants for messages type, XML namespace, entity type - core: *Message methods renamed to follow new code sytle (e.g. sendMessageToBridge => messageSendToBridge) - core (messages handling): fixed handling of language - core (messages handling): mes_data['from'] and ['to'] are now jid.JID - core (core.xmpp): reorganised message methods, added getNick() method to client.roster - plugin text commands: fixed plugin and adapted to new messages behaviour. client is now used in arguments instead of profile - plugins: added information for cancellation reason in CancelError calls - plugin XEP-0045: various improvments, but this plugin still need work: - trigger is used to avoid message already handled by the plugin to be handled a second time - changed the way to handle history, the last message from DB is checked and we request only messages since this one, in seconds (thanks Poezio folks :)) - subject reception is waited before sending the roomJoined signal, this way we are sure that everything including history is ready - cmd_* method now follow the new convention with client instead of profile - roomUserJoined and roomUserLeft messages are removed, the events are now handled with info message with a "ROOM_USER_JOINED" info subtype - probably other forgotten stuffs :p
author Goffi <goffi@goffi.org>
date Mon, 20 Jun 2016 18:41:53 +0200
parents 633b5c21aefd
children 02d21a589be2
comparison
equal deleted inserted replaced
1962:a45235d8dc93 1963:a2bc5089c2eb
18 # along with this program. If not, see <http://www.gnu.org/licenses/>. 18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 19
20 from sat.core.i18n import _ 20 from sat.core.i18n import _
21 from sat.core.log import getLogger 21 from sat.core.log import getLogger
22 log = getLogger(__name__) 22 log = getLogger(__name__)
23 from sat_frontends.tools import jid 23 from sat.core import exceptions
24 from sat_frontends.quick_frontend import quick_widgets 24 from sat_frontends.quick_frontend import quick_widgets
25 from sat_frontends.quick_frontend.constants import Const as C 25 from sat_frontends.quick_frontend.constants import Const as C
26 from collections import OrderedDict 26 from collections import OrderedDict
27 from datetime import datetime 27 from sat_frontends.tools import jid
28
29 ROOM_USER_JOINED = 'ROOM_USER_JOINED'
30 ROOM_USER_LEFT = 'ROOM_USER_LEFT'
31 ROOM_USER_MOVED = (ROOM_USER_JOINED, ROOM_USER_LEFT)
32
33 # from datetime import datetime
28 34
29 try: 35 try:
30 # FIXME: to be removed when an acceptable solution is here 36 # FIXME: to be removed when an acceptable solution is here
31 unicode('') # XXX: unicode doesn't exist in pyjamas 37 unicode('') # XXX: unicode doesn't exist in pyjamas
32 except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options 38 except (TypeError, AttributeError): # Error raised is not the same depending on pyjsbuild options
33 unicode = str 39 unicode = str
34 40
41 # FIXME: day_format need to be settable (i18n)
42
43 class Message(object):
44 """Message metadata"""
45
46 def __init__(self, parent, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile):
47 self.parent = parent
48 self.profile = profile
49 self.uid = uid
50 self.timestamp = timestamp
51 self.from_jid = from_jid
52 self.to_jid = to_jid
53 self.message = msg
54 self.subject = subject
55 self.type = type_
56 self.extra = extra
57 self.nick = self.getNick(from_jid)
58 # own_mess is True if message was sent by profile's jid
59 self.own_mess = (from_jid.resource == self.parent.nick) if self.parent.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare)
60 self.widgets = set() # widgets linked to this message
61
62 @property
63 def host(self):
64 return self.parent.host
65
66 @property
67 def info_type(self):
68 return self.extra.get('info_type')
69
70 def getNick(self, entity):
71 """Return nick of an entity when possible"""
72 contact_list = self.host.contact_lists[self.profile]
73 if self.type == C.MESS_TYPE_INFO and self.info_type in ROOM_USER_MOVED:
74 try:
75 return self.extra['user_nick']
76 except KeyError:
77 log.error(u"extra data is missing user nick for uid {}".format(self.uid))
78 return ""
79 if self.parent.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(C.CONTACT_SPECIAL_GROUP):
80 return entity.resource or ""
81 if entity.bare in contact_list:
82 return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity
83 return entity.node or entity
84
85
86 class Occupant(object):
87 """Occupant metadata"""
88
89 def __init__(self, parent, data, profile):
90 self.parent = parent
91 self.profile = profile
92 self.nick = data['nick']
93 self.entity = data.get('entity')
94 if not self.entity:
95 self.entity = jid.JID(u"{}/{}".format(parent.target.bare, self.nick)),
96 self.affiliation = data['affiliation']
97 self.role = data['role']
98 self.widgets = set() # widgets linked to this occupant
99
100 @property
101 def host(self):
102 return self.parent.host
103
35 104
36 class QuickChat(quick_widgets.QuickWidget): 105 class QuickChat(quick_widgets.QuickWidget):
37 106
38 visible_states = ['chat_state'] 107 visible_states = ['chat_state']
39 108
40 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, profiles=None): 109 def __init__(self, host, target, type_=C.CHAT_ONE2ONE, occupants=None, subject=None, profiles=None):
41 """ 110 """
42 @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC 111 @param type_: can be C.CHAT_ONE2ONE for single conversation or C.CHAT_GROUP for chat à la IRC
43 """ 112 """
113 self.lang = '' # default language to use for messages
44 quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles) 114 quick_widgets.QuickWidget.__init__(self, host, target, profiles=profiles)
115 self._locked = False # True when we are waiting for history/search
116 # messageNew signals are cached when locked
117 self._cache = []
45 assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP) 118 assert type_ in (C.CHAT_ONE2ONE, C.CHAT_GROUP)
46 if type_ == C.CHAT_GROUP and target.resource:
47 raise ValueError("A group chat entity can't have a resource")
48 self.current_target = target 119 self.current_target = target
49 self.type = type_ 120 self.type = type_
50 self.id = "" # FIXME: to be removed 121 if type_ == C.CHAT_GROUP:
51 self.nick = None 122 if target.resource:
123 raise exceptions.InternalError(u"a group chat entity can't have a resource")
124 self.nick = None
125 self.occupants = {}
126 self.setOccupants(occupants)
127 else:
128 if occupants is not None:
129 raise exceptions.InternalError(u"only group chat can have occupants")
130 self.messages = OrderedDict() # key: uid, value: Message instance
52 self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame 131 self.games = {} # key=game name (unicode), value=instance of quick_games.RoomGame
53 132 self.subject = subject
133
134 def postInit(self):
135 """Method to be called by frontend after widget is initialised
136
137 handle the display of history and subject
138 """
54 self.historyPrint(profile=self.profile) 139 self.historyPrint(profile=self.profile)
140 if self.subject is not None:
141 self.setSubject(self.subject)
142
143 ## Widget management ##
55 144
56 def __str__(self): 145 def __str__(self):
57 return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile) 146 return u"Chat Widget [target: {}, type: {}, profile: {}]".format(self.target, self.type, self.profile)
58 147
59 @staticmethod 148 @staticmethod
71 160
72 def addTarget(self, target): 161 def addTarget(self, target):
73 super(QuickChat, self).addTarget(target) 162 super(QuickChat, self).addTarget(target)
74 if target.resource: 163 if target.resource:
75 self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead 164 self.current_target = target # FIXME: tmp, must use resource priority throught contactList instead
165
166 def onPrivateCreated(self, widget):
167 """Method called when a new widget for private conversation (MUC) is created"""
168 raise NotImplementedError
169
170 def getOrCreatePrivateWidget(self, entity):
171 """Create a widget for private conversation, or get it if it already exists
172
173 @param entity: full jid of the target
174 """
175 return self.host.widgets.getOrCreateWidget(QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.getPrivateHash(self.profile, entity), on_new_widget=self.onPrivateCreated, profile=self.profile) # we force hash to have a new widget, not this one again
76 176
77 @property 177 @property
78 def target(self): 178 def target(self):
79 if self.type == C.CHAT_GROUP: 179 if self.type == C.CHAT_GROUP:
80 return self.current_target.bare 180 return self.current_target.bare
81 return self.current_target 181 return self.current_target
82 182
83 @property 183 ## occupants ##
84 def occupants(self): 184
85 """Return the occupants of a group chat (nicknames). 185 def setOccupants(self, occupants):
86 186 """set the whole list of occupants"""
87 @return: set(unicode) 187 assert len(self.occupants) == 0
88 """ 188 for nick, data in occupants.iteritems():
89 if self.type != C.CHAT_GROUP: 189 self.occupants[nick] = Occupant(
90 return set() 190 self,
91 contact_list = self.host.contact_lists[self.profile] 191 data,
92 return contact_list.getCache(self.target, C.CONTACT_RESOURCES).keys() 192 self.profile
193 )
194
195 def addUser(self, occupant_data):
196 """Add user if it is not in the group list"""
197 occupant = Occupant(
198 self,
199 occupant_data,
200 self.profile
201 )
202 self.occupants[occupant.nick] = occupant
203 return occupant
204
205 def removeUser(self, occupant_data):
206 """Remove a user from the group list"""
207 nick = occupant_data['nick']
208 try:
209 occupant = self.occupants.pop(nick)
210 except KeyError:
211 log.warning(u"Trying to remove an unknown occupant: {}".format(nick))
212 else:
213 return occupant
214
215 def setUserNick(self, nick):
216 """Set the nick of the user, usefull for e.g. change the color of the user"""
217 self.nick = nick
218
219 def changeUserNick(self, old_nick, new_nick):
220 """Change nick of a user in group list"""
221 self.printInfo("%s is now known as %s" % (old_nick, new_nick))
222
223 ## Messages ##
93 224
94 def manageMessage(self, entity, mess_type): 225 def manageMessage(self, entity, mess_type):
95 """Tell if this chat widget manage an entity and message type couple 226 """Tell if this chat widget manage an entity and message type couple
96 227
97 @param entity (jid.JID): (full) jid of the sending entity 228 @param entity (jid.JID): (full) jid of the sending entity
98 @param mess_type (str): message type as given by messageNew 229 @param mess_type (str): message type as given by messageNew
99 @return (bool): True if this Chat Widget manage this couple 230 @return (bool): True if this Chat Widget manage this couple
100 """ 231 """
101 if self.type == C.CHAT_GROUP: 232 if self.type == C.CHAT_GROUP:
102 if mess_type == C.MESS_TYPE_GROUPCHAT and self.target == entity.bare: 233 if mess_type in (C.MESS_TYPE_GROUPCHAT, C.MESS_TYPE_INFO) and self.target == entity.bare:
103 return True 234 return True
104 else: 235 else:
105 if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets: 236 if mess_type != C.MESS_TYPE_GROUPCHAT and entity in self.targets:
106 return True 237 return True
107 return False 238 return False
108 239
109 def addUser(self, nick): 240 def updateHistory(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'):
110 """Add user if it is not in the group list""" 241 """Called when history need to be recreated
111 self.printInfo("=> %s has joined the room" % nick) 242
112 243 Remove all message from history then call historyPrint
113 def removeUser(self, nick): 244 Must probably be overriden by frontend to clear widget
114 """Remove a user from the group list"""
115 self.printInfo("<= %s has left the room" % nick)
116
117 def setUserNick(self, nick):
118 """Set the nick of the user, usefull for e.g. change the color of the user"""
119 self.nick = nick
120
121 def changeUserNick(self, old_nick, new_nick):
122 """Change nick of a user in group list"""
123 self.printInfo("%s is now known as %s" % (old_nick, new_nick))
124
125 def setSubject(self, subject):
126 """Set title for a group chat"""
127 log.debug(_("Setting subject to %s") % subject)
128 if self.type != C.CHAT_GROUP:
129 log.error (_("[INTERNAL] trying to set subject for a non group chat window"))
130 raise Exception("INTERNAL ERROR") #TODO: raise proper Exception here
131
132 def afterHistoryPrint(self):
133 """Refresh or scroll down the focus after the history is printed"""
134 pass
135
136 def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'):
137 """Print the current history
138
139 @param size (int): number of messages 245 @param size (int): number of messages
140 @param search (str): pattern to filter the history results 246 @param search (str): pattern to filter the history results
141 @param profile (str): %(doc_profile)s 247 @param profile (str): %(doc_profile)s
142 """ 248 """
249 self._locked = True
250 self.messages.clear()
251 self.historyPrint(size, search, profile)
252
253 def _onHistoryPrinted(self):
254 """Method called when history is printed (or failed)
255
256 unlock the widget, and can be used to refresh or scroll down
257 the focus after the history is printed
258 """
259 self._locked = False
260 for data in self._cache:
261 self.messageNew(*data)
262
263 def historyPrint(self, size=C.HISTORY_LIMIT_DEFAULT, search='', profile='@NONE@'):
264 """Print the current history
265
266 @param size (int): number of messages
267 @param search (str): pattern to filter the history results
268 @param profile (str): %(doc_profile)s
269 """
270 if size == 0:
271 log.debug(u"Empty history requested, skipping")
272 self._onHistoryPrinted()
273 return
143 log_msg = _(u"now we print the history") 274 log_msg = _(u"now we print the history")
144 if size != C.HISTORY_LIMIT_DEFAULT: 275 if size != C.HISTORY_LIMIT_DEFAULT:
145 log_msg += _(u" (%d messages)" % size) 276 log_msg += _(u" ({} messages)".format(size))
146 log.debug(log_msg) 277 log.debug(log_msg)
147 278
148 target = self.target.bare 279 target = self.target.bare
149 280
150 def _historyGetCb(history): 281 def _historyGetCb(history):
151 day_format = "%A, %d %b %Y" # to display the day change 282 # day_format = "%A, %d %b %Y" # to display the day change
152 previous_day = datetime.now().strftime(day_format) 283 # previous_day = datetime.now().strftime(day_format)
284 # message_day = datetime.fromtimestamp(timestamp).strftime(self.day_format)
285 # if previous_day != message_day:
286 # self.printDayChange(message_day)
287 # previous_day = message_day
153 for data in history: 288 for data in history:
154 uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data # FIXME: extra is unused ! 289 uid, timestamp, from_jid, to_jid, message, subject, type_, extra = data
155 if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or 290 from_jid = jid.JID(from_jid)
156 (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)): 291 to_jid = jid.JID(to_jid)
157 continue 292 # if ((self.type == C.CHAT_GROUP and type_ != C.MESS_TYPE_GROUPCHAT) or
158 message_day = datetime.fromtimestamp(timestamp).strftime(day_format) 293 # (self.type == C.CHAT_ONE2ONE and type_ == C.MESS_TYPE_GROUPCHAT)):
159 if previous_day != message_day: 294 # continue
160 self.printDayChange(message_day) 295 self.messages[uid] = Message(self, uid, timestamp, from_jid, to_jid, message, subject, type_, extra, profile)
161 previous_day = message_day 296 self._onHistoryPrinted()
162 extra["timestamp"] = timestamp
163 self.messageNew(uid, timestamp, jid.JID(from_jid), target, message, subject, type_, extra, profile)
164 self.afterHistoryPrint()
165 297
166 def _historyGetEb(err): 298 def _historyGetEb(err):
167 log.error(_("Can't get history")) 299 log.error(_(u"Can't get history"))
300 self._onHistoryPrinted()
168 301
169 self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb) 302 self.host.bridge.historyGet(unicode(self.host.profiles[profile].whoami.bare), unicode(target), size, True, search, profile, callback=_historyGetCb, errback=_historyGetEb)
170 303
171 def _get_nick(self, entity): 304 def messageNew(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile):
172 """Return nick of this entity when possible""" 305 log.debug(u"messageNew ==> {}".format((uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)))
173 contact_list = self.host.contact_lists[self.profile] 306 if self._locked:
174 if self.type == C.CHAT_GROUP or entity in contact_list.getSpecialExtras(C.CONTACT_SPECIAL_GROUP): 307 self._cache.append(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
175 return entity.resource or ""
176 if entity.bare in contact_list:
177 return contact_list.getCache(entity, 'nick') or contact_list.getCache(entity, 'name') or entity.node or entity
178 return entity.node or entity
179
180 def onPrivateCreated(self, widget):
181 """Method called when a new widget for private conversation (MUC) is created"""
182 raise NotImplementedError
183
184 def getOrCreatePrivateWidget(self, entity):
185 """Create a widget for private conversation, or get it if it already exists
186
187 @param entity: full jid of the target
188 """
189 return self.host.widgets.getOrCreateWidget(QuickChat, entity, type_=C.CHAT_ONE2ONE, force_hash=self.getPrivateHash(self.profile, entity), on_new_widget=self.onPrivateCreated, profile=self.profile) # we force hash to have a new widget, not this one again
190
191 def messageNew(self, uid, timestamp, from_jid, target, msg, subject, type_, extra, profile):
192 try:
193 msg = msg.itervalues().next() # FIXME: tmp fix until message refactoring is finished (msg is now a dict)
194 except StopIteration:
195 log.warning(u"No message found (uid: {})".format(uid))
196 msg = ''
197 if self.type == C.CHAT_GROUP and target.resource and type_ != C.MESS_TYPE_GROUPCHAT:
198 # we have a private message, we forward it to a private conversation widget
199 chat_widget = self.getOrCreatePrivateWidget(target)
200 chat_widget.messageNew(uid, timestamp, from_jid, target, msg, subject, type_, extra, profile)
201 return 308 return
202 309 if self.type == C.CHAT_GROUP:
203 if type_ == C.MESS_TYPE_INFO: 310 if to_jid.resource and type_ != C.MESS_TYPE_GROUPCHAT:
204 self.printInfo(msg, extra=extra) 311 # we have a private message, we forward it to a private conversation widget
205 else: 312 chat_widget = self.getOrCreatePrivateWidget(to_jid)
206 nick = self._get_nick(from_jid) 313 chat_widget.messageNew(uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
207 if msg.startswith('/me '): 314 return
208 self.printInfo('* {} {}'.format(nick, msg[4:]), type_='me', extra=extra) 315 if type_ == C.MESS_TYPE_INFO:
209 else: 316 try:
210 # my_message is True if message comes from local user 317 info_type = extra['info_type']
211 my_message = (from_jid.resource == self.nick) if self.type == C.CHAT_GROUP else (from_jid.bare == self.host.profiles[profile].whoami.bare) 318 except KeyError:
212 self.printMessage(nick, my_message, msg, timestamp, extra, profile) 319 pass
213 # FIXME: to be checked/removed after message refactoring 320 else:
214 # if timestamp: 321 user_data = {k[5:]:v for k,v in extra.iteritems() if k.startswith('user_')}
215 self.afterHistoryPrint() 322 if info_type == ROOM_USER_JOINED:
216 323 self.addUser(user_data)
217 def printMessage(self, nick, my_message, message, timestamp, extra=None, profile=C.PROF_KEY_NONE): 324 elif info_type == ROOM_USER_LEFT:
218 """Print message in chat window. 325 self.removeUser(user_data)
219 326
220 @param nick (unicode): author nick 327 message = Message(self, uid, timestamp, from_jid, to_jid, msg, subject, type_, extra, profile)
221 @param my_message (boolean): True if profile is the author 328 self.messages[uid] = message
222 @param message (unicode): message content 329
223 @param extra (dict): extra data 330 if 'received_timestamp' in extra:
224 """ 331 log.warning(u"Delayed message received after history, this should not happen")
225 # FIXME: check/remove this if necessary (message refactoring) 332 self.createMessage(message)
226 # if not timestamp: 333
227 # # XXX: do not send notifications for each line of the history being displayed 334 def createMessage(self, message, append=False):
228 # # FIXME: this must be changed in the future if the timestamp is passed with 335 """Must be implemented by frontend to create and show a new message widget
229 # # all messages and not only with the messages coming from the history. 336
230 self.notify(nick, message) 337 This is only called on messageNew, not on history.
231 338 You need to override historyPrint to handle the later
232 def printInfo(self, msg, type_='normal', extra=None): 339 @param message(Message): message data
233 """Print general info. 340 """
234 341 raise NotImplementedError
235 @param msg (unicode): message to print
236 @param type_ (unicode):
237 - 'normal': general info like "toto has joined the room"
238 - 'me': "/me" information like "/me clenches his fist" ==> "toto clenches his fist"
239 @param extra (dict): message data
240 """
241 self.notify(msg=msg)
242 342
243 def notify(self, contact="somebody", msg=""): 343 def notify(self, contact="somebody", msg=""):
244 """Notify the user of a new message if the frontend doesn't have the focus. 344 """Notify the user of a new message if the frontend doesn't have the focus.
245 345
246 @param contact (unicode): contact who wrote to the users 346 @param contact (unicode): contact who wrote to the users
247 @param msg (unicode): the message that has been received 347 @param msg (unicode): the message that has been received
248 """ 348 """
349 # FIXME: not called anymore after refactoring
249 raise NotImplemented 350 raise NotImplemented
250 351
251 def printDayChange(self, day): 352 def printDayChange(self, day):
252 """Display the day on a new line. 353 """Display the day on a new line.
253 354
254 @param day(unicode): day to display (or not if this method is not overwritten) 355 @param day(unicode): day to display (or not if this method is not overwritten)
255 """ 356 """
357 # FIXME: not called anymore after refactoring
256 pass 358 pass
257 359
258 def getEntityStates(self, entity): 360 ## Room ##
259 """Retrieve states for an entity. 361
260 362 def setSubject(self, subject):
261 @param entity (jid.JID): entity 363 """Set title for a group chat"""
262 @return: OrderedDict{unicode: unicode} 364 self.subject = subject
263 """ 365 if self.type != C.CHAT_GROUP:
264 states = OrderedDict() 366 raise exceptions.InternalError("trying to set subject for a non group chat window")
265 clist = self.host.contact_lists[self.profile]
266 for key in self.visible_states:
267 value = clist.getCache(entity, key)
268 if value:
269 states[key] = value
270 return states
271 367
272 def addGamePanel(self, widget): 368 def addGamePanel(self, widget):
273 """Insert a game panel to this Chat dialog. 369 """Insert a game panel to this Chat dialog.
274 370
275 @param widget (Widget): the game panel 371 @param widget (Widget): the game panel