Index: pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/account.py
===================================================================
--- pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/account.py	(revision 4665)
+++ pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/account.py	(revision 4671)
@@ -35,4 +35,5 @@
 import application
 import call
+import chat as ch
 
 # Account class
@@ -48,6 +49,27 @@
 		self.cfgChanged = False
 		self.buddyList = []
-                self.callList = []
-
+		self.chatList = []
+
+	def findBuddy(self, uri):
+		# TODO: proper URI comparison
+		for bud in self.buddyList:
+			if bud.cfg.uri in uri or uri in bud.cfg.uri:
+				return bud
+		return None
+		
+	def findChat(self, buddy, call_inst = None):
+		for chat in self.chatList:
+			if chat.isBuddyParticipant(buddy):
+				if call_inst and chat.isCallRegistered(call_inst):
+					return chat
+				elif not call_inst and chat.isPrivate():
+					return chat
+		return None
+	
+	def newChat(self, buddy):
+		chat = ch.Chat(self, buddy)
+		self.chatList.append(chat)
+		return chat
+	
 	def statusText(self):
 		status = '?'
@@ -75,30 +97,58 @@
 			status = '- not created -'
 		return status
-        
-        def makeCall(self):
-                mycall = call.Call(self.app, self, pj.PJSUA_INVALID_ID)
-                callPrm = pj.CallOpParam()
-                callPrm.opt.audioCount = 1
-                callPrm.opt.videoCount = 0
-                mycall.uri = "sip:test1@pjsip.org"
-                self.callList.append(mycall)
-                mycall.makeCall(mycall.uri, callPrm)
-	
+        	
 	def onRegState(self, prm):
 		self.app.updateAccount(self)
 
         def onIncomingCall(self, prm):
-                mycall = call.Call(self.app, self, prm.callId)
-                self.callList.append(mycall)
-                self.app.updateCall(self)
-                callPrm = pj.CallOpParam()
-                msg = "Incoming call for account '%s'" % self.cfg.idUri
-		if msgbox.askquestion(msg, 'Accept call?', default=msgbox.YES) == u'yes':
-                        callPrm.statusCode = 200
-                        mycall.answer(callPrm)
-                else:
-                        mycall.hangup(callPrm)
-
-
+		c = call.Call(self, call_id=prm.callId)
+                call_prm = pj.CallOpParam()
+		call_prm.statusCode = 180
+		c.answer(call_prm)
+		ci = c.getInfo()
+		msg = "Incoming call for account '%s'" % self.cfg.idUri
+		if msgbox.askquestion(msg, "Accept call from '%s'?" % (ci.remoteURI), default=msgbox.YES) == u'yes':
+			call_prm.statusCode = 200
+			c.answer(call_prm)
+			
+			# create chat instance
+			bud = self.findBuddy(ci.remoteURI)
+			if not bud: return
+			chat = self.findChat(bud)
+			if not chat: chat = self.newChat(bud, c)
+			
+			chat.registerCall(bud, c)
+			chat.showWindow()
+		else:
+			c.hangup(call_prm)
+			
+	def onInstantMessage(self, prm):
+		bud = self.findBuddy(prm.fromUri)
+		if not bud: return
+		chat = self.findChat(bud)
+		if not chat: chat = self.newChat(bud)
+			
+		chat.addMessage(bud.cfg.uri, prm.msgBody)
+		chat.showWindow()
+		
+	def onInstantMessageStatus(self, prm):
+		if prm.code/100 == 2: return
+		
+		bud = self.findBuddy(prm.toUri)
+		if not bud: return
+		chat = self.findChat(bud)
+		if not chat: return
+		
+		chat.addMessage(None, "Failed sending message to '%s': %s" % (bud.cfg.uri, prm.reason))
+		
+	def onTypingIndication(self, prm):
+		bud = self.findBuddy(prm.fromUri)
+		if not bud: return
+		chat = self.findChat(bud)
+		if not chat: return
+		
+		chat.setTypingIndication(bud.cfg.uri, prm.isTyping)
+
+		
 # Account frame, to list accounts
 class AccountListFrame(ttk.Frame):
Index: pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/application.py
===================================================================
--- pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/application.py	(revision 4665)
+++ pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/application.py	(revision 4671)
@@ -52,5 +52,5 @@
 		
 		# Logger
-		self.logger = log.Logger()
+		self.logger = None #log.Logger()
 		
 		# Accounts
@@ -136,14 +136,4 @@
 		# Start polling
 		self._onTimer()
-
-        def updateCall(self, acc):
-                iid = str(acc.randId)
-                for call in acc.callList:
-                        calliid = str(call.randId)
-                        uri, status = call.statusText()
-                        if self.tv.exists(calliid):
-                                self.tv.item(call.iid, text=uri, values=(status,))
-                        else:
-                                call.iid = self.tv.insert(iid, 0, calliid, open=True, text=uri, values=(status,))
 
 	def updateAccount(self, acc):
@@ -226,5 +216,5 @@
 		self.accMenu = tk.Menu(top, tearoff=False)
 		# Labels, must match with _onAccContextMenu()
-		labels = ['Call', '-', 'Unregister', 'Reregister', 'Add buddy...', '-',
+		labels = ['Unregister', 'Reregister', 'Add buddy...', '-',
 			  'Online', 'Invisible', 'Away', 'Busy', '-',
 			  'Settings...', '-',
@@ -238,7 +228,7 @@
 		
 		# Create Buddy context menu
-		# Labels, must match with _onAccContextMenu()
+		# Labels, must match with _onBuddyContextMenu()
 		self.buddyMenu = tk.Menu(top, tearoff=False)
-		labels = ['Video call', 'Audio call', 'Send instant message', '-',
+		labels = ['Audio call', 'Send instant message', '-',
 			  'Subscribe', 'Unsubscribe', '-',
 			  'Settings...', '-',
@@ -257,4 +247,5 @@
 		else:
 			self.tv.bind('<3>', self._onTvRightClick)
+		self.tv.bind('<Double-Button-1>', self._onTvDoubleClick)
 
 	def _getSelectedAccount(self):
@@ -303,4 +294,23 @@
 				self.buddyMenu.post(event.x_root, event.y_root)
 	
+	def _onTvDoubleClick(self, event):
+		iid = self.tv.identify_row(event.y)
+		if iid:
+			self.tv.selection_set( (iid,) )
+			acc = self._getSelectedAccount()
+			if acc:
+				self.cfgChanged = False
+				dlg = accountsetting.Dialog(self.master, acc.cfg)
+				if dlg.doModal():
+					self.updateAccount(acc)
+					acc.modify(acc.cfg)
+			else:
+				bud = self._getSelectedBuddy()
+				acc = bud.account
+				chat = acc.findChat(bud)
+				if not chat:
+					chat = acc.newChat(bud)
+				chat.showWindow()
+	
 	def _onAccContextMenu(self, label):
 		acc = self._getSelectedAccount()
@@ -308,7 +318,5 @@
 			return
 		
-                if label=='Call':
-                        acc.makeCall()
-		elif label=='Unregister':
+		if label=='Unregister':
 			acc.setRegistration(False)
 		elif label=='Reregister':
@@ -362,10 +370,13 @@
 		acc = bud.account
 			
-		if label=='Video call':
-			pass
-		elif label=='Audio call':
-			pass
+		if label=='Audio call':
+			chat = acc.findChat(bud)
+			if not chat: chat = acc.newChat(bud)
+			chat.showWindow()
+			chat.startAudio()
 		elif label=='Send instant message':
-			pass
+			chat = acc.findChat(bud)
+			if not chat: chat = acc.newChat(bud)
+			chat.showWindow()
 		elif label=='Subscribe':
 			bud.subscribePresence(True)
@@ -476,3 +487,4 @@
 		
 if __name__ == '__main__':
+	print pj
 	main()
Index: pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/call.py
===================================================================
--- pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/call.py	(revision 4665)
+++ pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/call.py	(revision 4671)
@@ -38,32 +38,37 @@
 	High level Python Call object, derived from pjsua2's Call object.
 	"""
-	def __init__(self, app, acc, callId):
-		pj.Call.__init__(self, acc, callId)
-		self.app = app
+	def __init__(self, acc, peer_uri='', chat=None, call_id = pj.PJSUA_INVALID_ID):
+		pj.Call.__init__(self, acc, call_id)
                 self.acc = acc
-                self.uri = ''
-                self.randId = random.randint(1, 9999)
+		self.peerUri = peer_uri
+		self.chat = chat
+		self.connected = False
 
-	def statusText(self):
-                uri = ''
-		status = '?'
-		if self.isActive():
-			ci = self.getInfo()
-                        status = ci.stateText
-                        uri = ci.remoteURI
-		else:
-			status = '- not established -'
-		return uri, status
-	
 	def onCallState(self, prm):
-                ci = self.getInfo()
-                if ci.state == pj.PJSIP_INV_STATE_DISCONNECTED:
-                        iid = str(self.randId)
-			self.acc.callList.remove(self)
-			self.app.tv.delete( (iid,) )
-                        del self
-                else:
-                        self.app.updateCall(self.acc)
-
+		ci = self.getInfo()
+		self.connected = ci.state == pj.PJSIP_INV_STATE_CONFIRMED			
+		if self.chat:
+			self.chat.updateCallState(self, ci)
+			
+	def onInstantMessage(self, prm):
+		# chat instance should have been initalized
+		if not self.chat: return
+			
+		self.chat.addMessage(self.peerUri, prm.msgBody)
+		self.chat.showWindow()
+			
+	def onInstantMessageStatus(self, prm):
+		if prm.code/100 == 2: return
+		# chat instance should have been initalized
+		if not self.chat: return
+		
+		self.chat.addMessage(None, "Failed sending message to '%s' (%d): %s" % (self.peerUri, prm.code, prm.reason))
+		
+	def onTypingIndication(self, prm):
+		# chat instance should have been initalized
+		if not self.chat: return
+		
+		self.chat.setTypingIndication(self.peerUri, prm.isTyping)
+			
 if __name__ == '__main__':
 	application.main()
Index: pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chat.py
===================================================================
--- pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chat.py	(revision 4671)
+++ pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chat.py	(revision 4671)
@@ -0,0 +1,216 @@
+# $Id$
+#
+# pjsua Python GUI Demo
+#
+# Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
+#
+# 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA 
+#
+import sys
+if sys.version_info[0] >= 3: # Python 3
+	import tkinter as tk
+	from tkinter import ttk
+else:
+	import Tkinter as tk
+	import ttk
+
+import call
+import chatgui as gui
+import pjsua2 as pj
+	
+class Chat(gui.ChatObserver):
+	def __init__(self, acc, bud, call_inst=None):
+		self._acc = acc
+		self._participantList = []
+		self._callList = []
+		self._gui = gui.ChatFrame(self)
+		self.addParticipant(bud, call_inst)
+	
+	def _updateGui(self):
+		if self.isPrivate():
+			bud = self._participantList[0]
+			self._gui.title(bud.cfg.uri)
+		else:
+			self._gui.title('Conference (%d participants)' % (len(self._participantList)))
+
+	def _getCallFromBuddy(self, bud):
+		try:
+			idx = self._participantList.index(bud)
+			the_call = self._callList[idx]
+		except:
+			return None
+		return the_call
+		
+	def _sendTypingIndication(self, is_typing):
+		type_ind_param = pj.SendTypingIndicationParam()
+		type_ind_param.isTyping = is_typing
+		for bud in self._participantList:
+			c = self._getCallFromBuddy(bud)
+			try:
+				if c:
+					c.sendTypingIndication(type_ind_param)
+				else:
+					bud.sendTypingIndication(type_ind_param)
+			except:
+				pass
+
+	def isPrivate(self):
+		return len(self._participantList) <= 1
+		
+	def isBuddyParticipant(self, bud, call_inst):
+		return bud in self._participantList
+		
+	def isCallRegistered(self, call_inst):
+		return call_inst in self._callList
+		
+	def registerCall(self, bud, call_inst):
+		try:
+			idx = self._participantList.index(bud)
+			print "registering call...", idx, self._participantList
+			if len(self._callList) < idx+1:
+				self._callList.append(call_inst)
+			else:
+				self._callList[idx] = call_inst
+		except:
+			return None
+		
+	def showWindow(self):
+		self._gui.bringToFront()
+
+	def addParticipant(self, bud, call_inst=None):
+		self._participantList.append(bud)
+		if call_inst:
+			self._callList.append(call_inst)
+		self._gui.addParticipant(bud.cfg.uri)
+
+		self._updateGui()
+		if not self.isPrivate():
+			self.startCall()
+			self._gui.enableAudio()
+	
+	def kickParticipant(bud):
+		if bud in self._participantList:
+			idx = self._participantList.index(bud)
+			self._participantList.remove(bud)
+			self._gui.delParticipant(bud.cfg.uri)
+			
+			# also clear call, if any
+			if self._callList: del self._callList[idx]
+			
+		if self._participantList:
+			self._updateGui()
+		else:
+			# will this eventually destroy itself?
+			self._acc.chatList.remove(self)
+			
+	def addMessage(self, from_uri, msg):
+		if from_uri:
+			msg = from_uri + ': ' + msg
+			self._gui.textAddMessage(msg)
+		else:
+			self._gui.textAddMessage(msg, False)
+			
+	def setTypingIndication(self, from_uri, is_typing):
+		self._gui.textSetTypingIndication(from_uri, is_typing)
+		
+	def startCall(self):
+		call_param = pj.CallOpParam()
+		call_param.opt.audioCount = 1
+		call_param.opt.videoCount = 0
+		for idx, bud in enumerate(self._participantList):
+			# just skip if call is instantiated
+			if len(self._callList)>=idx+1 and self._callList[idx]:
+				continue
+				
+			c = call.Call(self._acc, bud.cfg.uri, self)
+			if len(self._callList) < idx+1:
+				self._callList.append(c)
+			else:
+				self._callList[idx] = c
+
+			self._gui.audioUpdateState(bud.cfg.uri, gui.AudioState.INITIALIZING)
+			
+			try:
+				c.makeCall(bud.cfg.uri, call_param)
+			except:
+				self._gui.audioUpdateState(bud.cfg.uri, gui.AudioState.FAILED)
+			
+	def stopCall(self):
+		for bud in self._participantList:
+			self._gui.audioUpdateState(bud.cfg.uri, gui.AudioState.DISCONNECTED)
+		
+		# clear call list, calls should be auto-destroyed by GC (and hungup by destructor)
+		del self._callList[:]
+		
+	def updateCallState(self, thecall, info):
+		if info.state == pj.PJSIP_INV_STATE_CONFIRMED:
+			self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.CONNECTED)
+		elif info.state == pj.PJSIP_INV_STATE_DISCONNECTED:
+			self._gui.audioUpdateState(thecall.peerUri, gui.AudioState.DISCONNECTED)
+			# reset entry in the callList
+			try:
+				idx = self._callList.index(thecall)
+				if idx >= 0: self._callList[idx] = None
+			except:
+				pass
+
+			
+	# ** callbacks from GUI (ChatObserver implementation) **
+	
+	# Text
+	def onSendMessage(self, msg):
+		send_im_param = pj.SendInstantMessageParam()
+		send_im_param.content = str(msg)
+		for bud in self._participantList:
+			# send via call, if any, or buddy
+			c = self._getCallFromBuddy(bud)
+			try:
+				if c:
+					c.sendInstantMessage(send_im_param)
+				else:
+					bud.sendInstantMessage(send_im_param)
+			except:
+				# error will be handled via Account::onInstantMessageStatus()
+				pass
+
+	def onStartTyping(self):
+		self._sendTypingIndication(True)
+		
+	def onStopTyping(self):
+		self._sendTypingIndication(False)
+		
+	# Audio
+	def onRetry(self, peer_uri):
+		pass
+	def onKick(self, peer_uri):
+		pass
+	def onHold(self, peer_uri):
+		pass
+	def onRxMute(self, peer_uri, is_muted):
+		pass
+	def onRxVol(self, peer_uri, vol_pct):
+		pass
+	def onTxMute(self, peer_uri, is_muted):
+		pass
+
+	# Chat room
+	def onAddParticipant(self):
+		#self.addParticipant()
+		pass
+	def onStartAudio(self):
+		self.startCall()
+
+	def onStopAudio(self):
+		self.stopCall()
Index: pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chatgui.py
===================================================================
--- pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chatgui.py	(revision 4671)
+++ pjproject/branches/projects/pjsua2/pjsip-apps/src/pygui/chatgui.py	(revision 4671)
@@ -0,0 +1,398 @@
+# $Id$
+#
+# pjsua Python GUI Demo
+#
+# Copyright (C)2013 Teluu Inc. (http://www.teluu.com)
+#
+# 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA 
+#
+import sys
+if sys.version_info[0] >= 3: # Python 3
+	import tkinter as tk
+	from tkinter import ttk
+	from tkinter import messagebox as msgbox
+else:
+	import Tkinter as tk
+	import ttk
+	import tkMessageBox as msgbox
+
+
+class TextObserver:
+	def onSendMessage(self, msg):
+		pass
+	def onStartTyping(self):
+		pass
+	def onStopTyping(self):
+		pass
+		
+class TextFrame(ttk.Frame):
+	def __init__(self, master, observer):
+		ttk.Frame.__init__(self, master)
+		self._observer = observer
+		self._isTyping = False
+		self._createWidgets()
+
+	def _onSendMessage(self, event):
+		send_text = self._typingBox.get("1.0", tk.END).strip()
+		if send_text == '':
+			return
+		
+		self.addMessage('me: ' + send_text)
+		self._typingBox.delete("0.0", tk.END)
+		self._onTyping(None)
+		
+		# notify app for sending message
+		self._observer.onSendMessage(send_text)
+		
+	def _onTyping(self, event):
+		# notify app for typing indication
+		is_typing = self._typingBox.get("1.0", tk.END).strip() != ''
+		if is_typing != self._isTyping:
+			self._isTyping = is_typing
+			if is_typing:
+				self._observer.onStartTyping()
+			else:
+				self._observer.onStopTyping()
+		
+	def _createWidgets(self):
+		self.rowconfigure(0, weight=1)
+		self.rowconfigure(1, weight=0)
+		self.rowconfigure(2, weight=0)
+		self.columnconfigure(0, weight=1)
+		self.columnconfigure(1, weight=0)
+		
+		self._text = tk.Text(self, font=("Arial", "10"))
+		self._text.grid(row=0, column=0, sticky='nswe')
+		self._text.config(state=tk.DISABLED)
+		self._text.tag_config("info", foreground="darkgray", font=("Arial", "9", "italic"))
+		
+		scrl = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self._text.yview)
+		self._text.config(yscrollcommand=scrl.set)
+		scrl.grid(row=0, column=1, sticky='nsw')
+		
+		self._typingBox = tk.Text(self, height=1, font=("Arial", "10"))
+		self._typingBox.grid(row=1, columnspan=2, sticky='we', pady=0)
+		
+		self._statusBar = tk.Label(self, anchor='w', font=("Arial", "8", "italic"))
+		self._statusBar.grid(row=2, columnspan=2, sticky='we')
+		
+		self._typingBox.bind('<Return>', self._onSendMessage)
+		self._typingBox.bind("<Key>", self._onTyping)
+		self._typingBox.focus_set()
+		
+	def addMessage(self, msg, is_chat = True):
+		self._text.config(state=tk.NORMAL)
+		if is_chat:
+			self._text.insert(tk.END, msg+'\r\n')
+		else:
+			self._text.insert(tk.END, msg+'\r\n', 'info')
+		self._text.config(state=tk.DISABLED)
+		self._text.yview(tk.END)
+
+	def setTypingIndication(self, who, is_typing):
+		if is_typing:
+			self._statusBar['text'] = "'%s' is typing.." % (who)
+		else:
+			self._statusBar['text'] = ''
+
+class AudioState:
+	NULL, INITIALIZING, CONNECTED, DISCONNECTED, FAILED = range(5)
+			
+class AudioObserver:
+	def onRetry(self, peer_uri):
+		pass
+	def onKick(self, peer_uri):
+		pass
+	def onHold(self, peer_uri):
+		pass
+	def onRxMute(self, peer_uri, is_muted):
+		pass
+	def onRxVol(self, peer_uri, vol_pct):
+		pass
+	def onTxMute(self, peer_uri, is_muted):
+		pass
+			
+
+class AudioFrame(ttk.Labelframe):
+	def __init__(self, master, peer_uri, observer):
+		ttk.Labelframe.__init__(self, master, text=peer_uri)
+		self.peerUri = peer_uri
+		self._observer = observer
+		self._initFrame = None
+		self._callFrame = None
+		self._rxMute = False
+		self._txMute = False
+
+		# internal state: 0:init - 1:established - 2:failure/rejected 3:normal cleared
+		self._state = AudioState.NULL
+		
+		self._createInitWidgets()
+		self._createWidgets()
+		
+	def updateState(self, state):
+		if self._state == state:
+			return
+
+		if state == AudioState.INITIALIZING:
+			self._callFrame.pack_forget()
+			self._initFrame.pack(fill=tk.BOTH)
+			self._btnRetry.pack_forget()
+			self._btnCancel.pack(side=tk.TOP)
+			self._lblInitState['text'] = 'Intializing..'
+
+		elif state == AudioState.CONNECTED:
+			self._initFrame.pack_forget()
+			self._callFrame.pack(fill=tk.BOTH)			
+		else:
+			self._callFrame.pack_forget()
+			self._initFrame.pack(fill=tk.BOTH)
+			if state == AudioState.FAILED:
+				self._lblInitState['text'] = 'Failed'
+				self._btnRetry.pack()
+			else:
+				self._lblInitState['text'] = 'Normal cleared'
+				self._btnCancel.pack_forget()
+		
+		# save last state
+		self._state = state
+		
+	def _onRetry(self):
+		self._btnRetry.pack_forget()
+		self._state = 0
+		# notify app
+		self._observer.onRetry(self.peerUri)
+
+	def _onKick(self):
+		# notify app
+		self._observer.onKick(self.peerUri)
+		
+	def _onHold(self):
+		# notify app
+		self._observer.onHold(self.peerUri)
+
+	def _onHangup(self):
+		# notify app
+		self._observer.onKick(self.peerUri)
+
+	def _onRxMute(self):
+		# notify app
+		self._rxMute = not self._rxMute
+		self._observer.onRxMute(self.peerUri, self._rxMute)
+		
+	def _onRxVol(self, event):
+		# notify app
+		vol = self.rxVol.get()
+		self._observer.onRxVol(self.peerUri, vol)
+
+	def _onTxMute(self):
+		# notify app
+		self._txMute = not self._txMute
+		self._observer.onTxMute(self.peerUri, self._txMute)
+
+	def _createInitWidgets(self):
+		self._initFrame = ttk.Frame(self)
+		#self._initFrame.pack(fill=tk.BOTH)
+
+	
+		self._lblInitState = tk.Label(self._initFrame, font=("Arial", "12"), text='')
+		self._lblInitState.pack(side=tk.TOP, fill=tk.X, expand=1)
+		
+		# Operation: retry, cancel/kick
+		self._btnRetry = ttk.Button(self._initFrame, text = 'Retry', command=self._onRetry)
+		self._btnRetry.pack(side=tk.TOP)
+		self._btnCancel = ttk.Button(self._initFrame, text = 'Cancel', command=self._onKick)
+		self._btnCancel.pack(side=tk.TOP)
+				
+	def _createWidgets(self):
+		self._callFrame = ttk.Frame(self)
+		#self._callFrame.pack(fill=tk.BOTH)
+		
+		# toolbar
+		toolbar = ttk.Frame(self._callFrame)
+		toolbar.pack(side=tk.TOP, fill=tk.X)
+		self._btnHold = ttk.Button(toolbar, text='Hold', command=self._onHold)
+		self._btnHold.pack(side=tk.LEFT, fill=tk.Y)
+		#self._btnXfer = ttk.Button(toolbar, text='Transfer..')
+		#self._btnXfer.pack(side=tk.LEFT, fill=tk.Y)
+		self._btnHangUp = ttk.Button(toolbar, text='Hangup', command=self._onHangup)
+		self._btnHangUp.pack(side=tk.LEFT, fill=tk.Y)
+
+		# volume tool
+		vol_frm = ttk.Frame(self._callFrame)
+		vol_frm.pack(side=tk.TOP, fill=tk.X)
+		
+		self.rxVolFrm = ttk.Labelframe(vol_frm, text='RX volume')
+		self.rxVolFrm.pack(side=tk.LEFT, fill=tk.Y)
+		
+		self.btnRxMute = ttk.Button(self.rxVolFrm, width=5, text='Mute', command=self._onRxMute)
+		self.btnRxMute.pack(side=tk.LEFT)
+		self.rxVol = tk.Scale(self.rxVolFrm, orient=tk.HORIZONTAL, from_=0.0, to=10.0, showvalue=0) #, tickinterval=10.0, showvalue=1)
+		self.rxVol.set(5.0)
+		self.rxVol.bind("<ButtonRelease-1>", self._onRxVol)
+		self.rxVol.pack(side=tk.LEFT)
+		
+		self.txVolFrm = ttk.Labelframe(vol_frm, text='TX volume')
+		self.txVolFrm.pack(side=tk.RIGHT, fill=tk.Y)
+		
+		self.btnTxMute = ttk.Button(self.txVolFrm, width=5, text='Mute', command=self._onTxMute)
+		self.btnTxMute.pack(side=tk.LEFT)
+		
+		# stat
+		self.stat = tk.Text(self._callFrame, width=20, height=5, font=("Arial", "10"))
+		self.stat.insert(tk.END, 'stat here')
+		self.stat.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
+
+
+class ChatObserver(TextObserver, AudioObserver):
+	def onAddParticipant(self):
+		pass
+	def onStartAudio(self):
+		pass
+	def onStopAudio(self):
+		pass
+		
+class ChatFrame(tk.Toplevel):
+	"""
+	Room
+	"""
+	def __init__(self, observer):
+		tk.Toplevel.__init__(self)
+		self.protocol("WM_DELETE_WINDOW", lambda: self.withdraw())
+		self._observer = observer
+
+		self._text = None
+		self._text_shown = True
+		
+		self._audioEnabled = False
+		self._audioFrames = []
+		self._createWidgets()
+	
+	def _createWidgets(self):
+		# toolbar
+		self.toolbar = ttk.Frame(self)
+		self.toolbar.pack(side=tk.TOP, fill=tk.BOTH)
+		
+		btnText = ttk.Button(self.toolbar, text='Show/hide text', command=self._onShowHideText)
+		btnText.pack(side=tk.LEFT, fill=tk.Y)
+		btnAudio = ttk.Button(self.toolbar, text='Start/stop audio', command=self._onStartStopAudio)
+		btnAudio.pack(side=tk.LEFT, fill=tk.Y)
+		
+		ttk.Separator(self.toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx = 4)
+
+		btnAdd = ttk.Button(self.toolbar, text='Add participant..', command=self._onAddParticipant)
+		btnAdd.pack(side=tk.LEFT, fill=tk.Y)
+
+		# media frame
+		self.media = ttk.Frame(self)
+		self.media.pack(side=tk.BOTTOM, fill=tk.BOTH, expand=1)
+		
+		# create Text Chat frame
+		self.media_left = ttk.Frame(self.media)
+		self._text = TextFrame(self.media_left, self._observer)
+		self._text.pack(fill=tk.BOTH, expand=1)
+		self.media_left.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)
+		
+		# create other media frame
+		self.media_right = ttk.Frame(self.media)
+		
+	def _arrangeMediaFrames(self):
+		if len(self._audioFrames) == 0:
+			self.media_right.pack_forget()
+			return
+		
+		self.media_right.pack(side=tk.RIGHT, fill=tk.BOTH, expand=1)
+		MAX_ROWS = 3
+		row_num = 0
+		col_num = 1
+		for frm in self._audioFrames:
+			frm.grid(row=row_num, column=col_num, sticky='nsew', padx=5, pady=5)
+			row_num += 1
+			if row_num >= MAX_ROWS:
+				row_num  = 0
+				col_num += 1
+	
+	def _onShowHideText(self):
+		self.textShowHide(not self._text_shown)
+		
+	def _onAddParticipant(self):
+		self._observer.onAddParticipant()
+	
+	def _onStartStopAudio(self):
+		self._audioEnabled = not self._audioEnabled
+		if self._audioEnabled:
+			self._observer.onStartAudio()
+		else:
+			self._observer.onStopAudio()
+		self.enableAudio(self._audioEnabled)
+			
+	# APIs
+	
+	def bringToFront(self):
+		self.deiconify()
+		self.lift()
+		self._text._typingBox.focus_set()
+		
+	def textAddMessage(self, msg, is_chat = True):
+		self._text.addMessage(msg, is_chat)
+		
+	def textSetTypingIndication(self, who, is_typing = True):
+		self._text.setTypingIndication(who, is_typing)
+		
+	def addParticipant(self, participant_uri):
+		aud_frm = AudioFrame(self.media_right, participant_uri, self._observer)
+		self._audioFrames.append(aud_frm)
+	
+	def delParticipant(self, participant_uri):
+		for aud_frm in self._audioFrames:
+			if participant_uri == aud_frm.peerUri:
+				self._audioFrames.remove(aud_frm)
+				# need to delete aud_frm manually?
+				aud_frm.destroy()
+				return
+
+	def textShowHide(self, show = True):
+		if show:
+			self.media_left.pack(side=tk.LEFT, fill=tk.BOTH, expand=1)
+			self._text._typingBox.focus_set()
+		else:
+			self.media_left.pack_forget()
+		self._text_shown = show
+	
+	def enableAudio(self, is_enabled = True):
+		if is_enabled:
+			self._arrangeMediaFrames()
+		else:
+			self.media_right.pack_forget()
+		self._audioEnabled = is_enabled
+			
+	def audioUpdateState(self, participant_uri, state):
+		for aud_frm in self._audioFrames:
+			if participant_uri == aud_frm.peerUri:
+				aud_frm.updateState(state)
+				break
+						
+if __name__ == '__main__':
+	root = tk.Tk()
+	root.title("Chat")
+	root.columnconfigure(0, weight=1)
+	root.rowconfigure(0, weight=1)
+	
+	obs = ChatObserver()
+	dlg = ChatFrame(obs)
+	#dlg = TextFrame(root)
+	#dlg = AudioFrame(root)
+
+	#dlg.pack(fill=tk.BOTH, expand=1)
+	root.mainloop()
