From eb50ea03ab7fc41610a8945002fe19dd30ffb5d7 Mon Sep 17 00:00:00 2001
From: Kevin Smith <git@kismith.co.uk>
Date: Tue, 31 Aug 2010 20:10:57 +0100
Subject: Squash presence in chat and MUC windows.

Join/Parts will be shown in one block if they're uninterrupted, and only the last presence change in a row will be shown for chats.

Resolves: #230
Resolves: #430

diff --git a/Swift/Controllers/Chat/ChatController.cpp b/Swift/Controllers/Chat/ChatController.cpp
index d9524da..52288c2 100644
--- a/Swift/Controllers/Chat/ChatController.cpp
+++ b/Swift/Controllers/Chat/ChatController.cpp
@@ -25,6 +25,7 @@ namespace Swift {
 ChatController::ChatController(const JID& self, StanzaChannel* stanzaChannel, IQRouter* iqRouter, ChatWindowFactory* chatWindowFactory, const JID &contact, NickResolver* nickResolver, PresenceOracle* presenceOracle, AvatarManager* avatarManager, bool isInMUC, bool useDelayForLatency, UIEventStream* eventStream, EventController* eventController)
 	: ChatControllerBase(self, stanzaChannel, iqRouter, chatWindowFactory, contact, presenceOracle, avatarManager, useDelayForLatency, eventStream, eventController) {
 	isInMUC_ = isInMUC;
+	lastWasPresence_ = false;
 	chatStateNotifier_ = new ChatStateNotifier();
 	chatStateMessageSender_ = new ChatStateMessageSender(chatStateNotifier_, stanzaChannel, contact);
 	chatStateTracker_ = new ChatStateTracker();
@@ -70,6 +71,7 @@ void ChatController::preHandleIncomingMessage(boost::shared_ptr<MessageEvent> me
 	}
 	chatStateNotifier_->receivedMessageFromContact(message->getPayload<ChatState>());
 	chatStateTracker_->handleMessageReceived(message);
+	lastWasPresence_ = false;
 }
 
 void ChatController::preSendMessageRequest(boost::shared_ptr<Message> message) {
@@ -80,6 +82,7 @@ void ChatController::preSendMessageRequest(boost::shared_ptr<Message> message) {
 
 void ChatController::postSendMessage(const String& body) {
 	addMessage(body, "me", true, labelsEnabled_ ? chatWindow_->getSelectedSecurityLabel() : boost::optional<SecurityLabel>(), String(avatarManager_->getAvatarPath(selfJID_).string()), boost::posix_time::microsec_clock::universal_time());
+	lastWasPresence_ = false;
 	chatStateNotifier_->userSentMessage();
 }
 
@@ -113,7 +116,12 @@ void ChatController::handlePresenceChange(boost::shared_ptr<Presence> newPresenc
 	chatStateTracker_->handlePresenceChange(newPresence, previousPresence);
 	String newStatusChangeString = getStatusChangeString(newPresence);
 	if (!previousPresence || newStatusChangeString != getStatusChangeString(previousPresence)) {
-		chatWindow_->addPresenceMessage(newStatusChangeString);
+		if (lastWasPresence_) {
+			chatWindow_->replaceLastMessage(newStatusChangeString);
+		} else {
+			chatWindow_->addPresenceMessage(newStatusChangeString);
+		}
+		lastWasPresence_ = true;
 	}
 }
 
diff --git a/Swift/Controllers/Chat/ChatController.h b/Swift/Controllers/Chat/ChatController.h
index 291a0d0..d833094 100644
--- a/Swift/Controllers/Chat/ChatController.h
+++ b/Swift/Controllers/Chat/ChatController.h
@@ -38,6 +38,7 @@ namespace Swift {
 			ChatStateMessageSender* chatStateMessageSender_;
 			ChatStateTracker* chatStateTracker_;
 			bool isInMUC_;
+			bool lastWasPresence_;
 	};
 }
 #endif
diff --git a/Swift/Controllers/Chat/MUCController.cpp b/Swift/Controllers/Chat/MUCController.cpp
index 5858127..3b37179 100644
--- a/Swift/Controllers/Chat/MUCController.cpp
+++ b/Swift/Controllers/Chat/MUCController.cpp
@@ -10,6 +10,7 @@
 
 #include "Swiften/Network/Timer.h"
 #include "Swiften/Network/TimerFactory.h"
+#include "Swiften/Base/foreach.h"
 #include "SwifTools/TabComplete.h"
 #include "Swiften/Base/foreach.h"
 #include "Swift/Controllers/EventController.h"
@@ -50,6 +51,7 @@ MUCController::MUCController (
 			ChatControllerBase(self, stanzaChannel, iqRouter, chatWindowFactory, muc, presenceOracle, avatarManager, useDelayForLatency, uiEventStream, eventController), muc_(new MUC(stanzaChannel, presenceSender, muc)), nick_(nick) {
 	parting_ = true;
 	joined_ = false;
+	lastWasPresence_ = false;
 	events_ = uiEventStream;
 	
 	roster_ = new Roster(false, true);
@@ -142,6 +144,7 @@ void MUCController::handleJoinComplete(const String& nick) {
 	String joinMessage = "You have joined room " + toJID_.toString() + " as " + nick;
 	nick_ = nick;
 	chatWindow_->addSystemMessage(joinMessage);
+	clearPresenceQueue();
 	setEnabled(true);
 }
 
@@ -170,6 +173,8 @@ void MUCController::handleOccupantJoined(const MUCOccupant& occupant) {
 		realJID = occupant.getRealJID().get();
 	}
 	currentOccupants_.insert(occupant.getNick());
+	NickJoinPart event(occupant.getNick(), Join);
+	appendToJoinParts(joinParts_, event);
 	roster_->addContact(jid, realJID, occupant.getNick(), roleToGroupName(occupant.getRole()));
 	if (joined_) {
 		String joinString = occupant.getNick() + " has joined the room";
@@ -179,13 +184,28 @@ void MUCController::handleOccupantJoined(const MUCOccupant& occupant) {
 
 		}
 		joinString += ".";
-		chatWindow_->addPresenceMessage(joinString);
+		if (shouldUpdateJoinParts()) {
+			updateJoinParts();
+		} else {
+			addPresenceMessage(joinString);
+
+		}
 	}
 	if (avatarManager_ != NULL) {
 		handleAvatarChanged(jid, "dummy");
 	}
 }
 
+void MUCController::addPresenceMessage(const String& message) {
+	lastWasPresence_ = true;
+	chatWindow_->addPresenceMessage(message);
+}
+
+void MUCController::clearPresenceQueue() {
+	lastWasPresence_ = false;
+	joinParts_.clear();
+}
+
 String MUCController::roleToFriendlyName(MUCOccupant::Role role) {
 	switch (role) {
 	case MUCOccupant::Moderator: return "moderator";
@@ -205,6 +225,7 @@ bool MUCController::messageTargetsMe(boost::shared_ptr<Message> message) {
 }
 
 void MUCController::preHandleIncomingMessage(boost::shared_ptr<MessageEvent> messageEvent) {
+	clearPresenceQueue();
 	boost::shared_ptr<Message> message = messageEvent->getStanza();
 	if (joined_ && messageTargetsMe(message) && !message->getPayload<Delay>()) {
 		eventController_->handleIncomingEvent(messageEvent);
@@ -260,7 +281,13 @@ void MUCController::setEnabled(bool enabled) {
 	}
 }
 
+bool MUCController::shouldUpdateJoinParts() {
+	return lastWasPresence_;
+}
+
 void MUCController::handleOccupantLeft(const MUCOccupant& occupant, MUC::LeavingType, const String& reason) {
+	NickJoinPart event(occupant.getNick(), Part);
+	appendToJoinParts(joinParts_, event);
 	currentOccupants_.erase(occupant.getNick());
 	completer_->removeWord(occupant.getNick());
 	String partMessage = (occupant.getNick() != nick_) ? occupant.getNick() + " has left the room" : "You have left the room";
@@ -268,10 +295,16 @@ void MUCController::handleOccupantLeft(const MUCOccupant& occupant, MUC::Leaving
 		partMessage += " (" + reason + ")";
 	}
 	partMessage += ".";
-	chatWindow_->addPresenceMessage(partMessage);
+
 	if (occupant.getNick() != nick_) {
+		if (shouldUpdateJoinParts()) {
+			updateJoinParts();
+		} else {
+			addPresenceMessage(partMessage);
+		}
 		roster_->removeContact(JID(toJID_.getNode(), toJID_.getDomain(), occupant.getNick()));
 	} else {
+		addPresenceMessage(partMessage);
 		parting_ = true;
 		setEnabled(false);
 	}
@@ -299,4 +332,53 @@ boost::optional<boost::posix_time::ptime> MUCController::getMessageTimestamp(boo
 	return message->getTimestampFrom(toJID_);
 }
 
+void MUCController::updateJoinParts() {
+	chatWindow_->replaceLastMessage(generateJoinPartString(joinParts_));
+}
+
+void MUCController::appendToJoinParts(std::vector<NickJoinPart>& joinParts, const NickJoinPart& newEvent) {
+	std::vector<NickJoinPart>::iterator it = joinParts.begin();
+	bool matched = false;
+	for (; it != joinParts.end(); it++) {
+		if ((*it).nick == newEvent.nick) {
+			matched = true;
+			JoinPart type = (*it).type;
+			switch (newEvent.type) {
+				case Join: type = (type == Part) ? PartThenJoin : Join; break;
+				case Part: type = (type == Join) ? JoinThenPart : Part; break;
+				default: /*Nothing to see here */;break;
+			}
+			(*it).type = type;
+			break;
+		}
+	}
+	if (!matched) {
+		joinParts.push_back(newEvent);
+	}
+}
+
+String MUCController::generateJoinPartString(std::vector<NickJoinPart> joinParts) {
+	String result;
+	for (size_t i = 0; i < joinParts.size(); i++) {
+		if (i > 0) {
+			if (i < joinParts.size() - 1) {
+				result += ", ";
+			} else {
+				result += " and ";
+			}
+		}
+		NickJoinPart event = joinParts[i];
+		result += event.nick;
+		switch (event.type) {
+			case Join: result += " has joined";break;
+			case Part: result += " has left";break;
+			case JoinThenPart: result += " joined then left";break;
+			case PartThenJoin: result += " left then rejoined";break;
+		}
+		result += " the room";
+	}
+	result += ".";
+	return result;
+}
+
 }
diff --git a/Swift/Controllers/Chat/MUCController.h b/Swift/Controllers/Chat/MUCController.h
index 31e3d48..4601386 100644
--- a/Swift/Controllers/Chat/MUCController.h
+++ b/Swift/Controllers/Chat/MUCController.h
@@ -31,6 +31,14 @@ namespace Swift {
 	class TimerFactory;
 	class TabComplete;
 
+	enum JoinPart {Join, Part, JoinThenPart, PartThenJoin};
+
+	struct NickJoinPart {
+			NickJoinPart(const String& nick, JoinPart type) : nick(nick), type(type) {};
+			String nick;
+			JoinPart type;
+	};
+
 	class MUCController : public ChatControllerBase {
 		public:
 			MUCController(const JID& self, const JID &muc, const String &nick, StanzaChannel* stanzaChannel, PresenceSender* presenceSender, IQRouter* iqRouter, ChatWindowFactory* chatWindowFactory, PresenceOracle* presenceOracle, AvatarManager* avatarManager, UIEventStream* events, bool useDelayForLatency, TimerFactory* timerFactory, EventController* eventController);
@@ -38,6 +46,8 @@ namespace Swift {
 			boost::signal<void ()> onUserLeft;
 			virtual void setEnabled(bool enabled);
 			void rejoin();
+			static void appendToJoinParts(std::vector<NickJoinPart>& joinParts, const NickJoinPart& newEvent);
+			static String generateJoinPartString(std::vector<NickJoinPart> joinParts);
 		
 		protected:
 			void preSendMessageRequest(boost::shared_ptr<Message> message);
@@ -47,6 +57,8 @@ namespace Swift {
 			void preHandleIncomingMessage(boost::shared_ptr<MessageEvent>);
 
 		private:
+			void clearPresenceQueue();
+			void addPresenceMessage(const String& message);
 			void handleWindowClosed();
 			void handleAvatarChanged(const JID& jid, const String&);
 			void handleOccupantJoined(const MUCOccupant& occupant);
@@ -61,6 +73,8 @@ namespace Swift {
 			String roleToFriendlyName(MUCOccupant::Role role);
 			void receivedActivity();
 			bool messageTargetsMe(boost::shared_ptr<Message> message);
+			void updateJoinParts();
+			bool shouldUpdateJoinParts();
 		private:
 			MUC* muc_;
 			UIEventStream* events_;
@@ -69,9 +83,11 @@ namespace Swift {
 			TabComplete* completer_;
 			bool parting_;
 			bool joined_;
+			bool lastWasPresence_;
 			boost::bsignals::scoped_connection avatarChangedConnection_;
 			boost::shared_ptr<Timer> loginCheckTimer_;
 			std::set<String> currentOccupants_;
+			std::vector<NickJoinPart> joinParts_;
 	};
 }
 
diff --git a/Swift/Controllers/Chat/UnitTest/MUCControllerTest.cpp b/Swift/Controllers/Chat/UnitTest/MUCControllerTest.cpp
new file mode 100644
index 0000000..fbc6901
--- /dev/null
+++ b/Swift/Controllers/Chat/UnitTest/MUCControllerTest.cpp
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2010 Kevin Smith
+ * Licensed under the GNU General Public License v3.
+ * See Documentation/Licenses/GPLv3.txt for more information.
+ */
+
+#include <cppunit/extensions/HelperMacros.h>
+#include <cppunit/extensions/TestFactoryRegistry.h>
+
+#include "Swift/Controllers/Chat/MUCController.h"
+
+using namespace Swift;
+
+class MUCControllerTest : public CppUnit::TestFixture
+{
+	CPPUNIT_TEST_SUITE(MUCControllerTest);
+	CPPUNIT_TEST(testJoinPartStringContructionSimple);
+	CPPUNIT_TEST(testJoinPartStringContructionMixed);
+	CPPUNIT_TEST(testAppendToJoinParts);
+	CPPUNIT_TEST_SUITE_END();
+
+public:
+	MUCControllerTest() {};
+
+	void setUp() {
+	};
+
+	void tearDown() {
+	}
+
+	void checkEqual(const std::vector<NickJoinPart>& expected, const std::vector<NickJoinPart>& actual) {
+		CPPUNIT_ASSERT_EQUAL(expected.size(), actual.size());
+		for (size_t i = 0; i < expected.size(); i++) {
+			CPPUNIT_ASSERT_EQUAL(expected[i].nick, actual[i].nick);
+			CPPUNIT_ASSERT_EQUAL(expected[i].type, actual[i].type);
+		}
+	}
+
+	void testAppendToJoinParts() {
+		std::vector<NickJoinPart> list;
+		std::vector<NickJoinPart> gold;
+		MUCController::appendToJoinParts(list, NickJoinPart("Kev", Join));
+		gold.push_back(NickJoinPart("Kev", Join));
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Remko", Join));
+		gold.push_back(NickJoinPart("Remko", Join));
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Bert", Join));
+		gold.push_back(NickJoinPart("Bert", Join));
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Bert", Part));
+		gold[2].type = JoinThenPart;
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Kev", Part));
+		gold[0].type = JoinThenPart;
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Remko", Part));
+		gold[1].type = JoinThenPart;
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Ernie", Part));
+		gold.push_back(NickJoinPart("Ernie", Part));
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Ernie", Join));
+		gold[3].type = PartThenJoin;
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Kev", Join));
+		gold[0].type = Join;
+		checkEqual(gold, list);
+		MUCController::appendToJoinParts(list, NickJoinPart("Ernie", Part));
+		gold[3].type = Part;
+		checkEqual(gold, list);
+
+	}
+
+	void testJoinPartStringContructionSimple() {
+		std::vector<NickJoinPart> list;
+		list.push_back(NickJoinPart("Kev", Join));
+		CPPUNIT_ASSERT_EQUAL(String("Kev has joined the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Remko", Part));
+		CPPUNIT_ASSERT_EQUAL(String("Kev has joined the room and Remko has left the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Bert", Join));
+		CPPUNIT_ASSERT_EQUAL(String("Kev has joined the room, Remko has left the room and Bert has joined the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Ernie", Join));
+		CPPUNIT_ASSERT_EQUAL(String("Kev has joined the room, Remko has left the room, Bert has joined the room and Ernie has joined the room."), MUCController::generateJoinPartString(list));
+	}
+
+	void testJoinPartStringContructionMixed() {
+		std::vector<NickJoinPart> list;
+		list.push_back(NickJoinPart("Kev", JoinThenPart));
+		CPPUNIT_ASSERT_EQUAL(String("Kev joined then left the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Remko", Part));
+		CPPUNIT_ASSERT_EQUAL(String("Kev joined then left the room and Remko has left the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Bert", PartThenJoin));
+		CPPUNIT_ASSERT_EQUAL(String("Kev joined then left the room, Remko has left the room and Bert left then rejoined the room."), MUCController::generateJoinPartString(list));
+		list.push_back(NickJoinPart("Ernie", JoinThenPart));
+		CPPUNIT_ASSERT_EQUAL(String("Kev joined then left the room, Remko has left the room, Bert left then rejoined the room and Ernie joined then left the room."), MUCController::generateJoinPartString(list));
+	}
+};
+
+CPPUNIT_TEST_SUITE_REGISTRATION(MUCControllerTest);
+
diff --git a/Swift/Controllers/SConscript b/Swift/Controllers/SConscript
index ea0dc3a..1ccee64 100644
--- a/Swift/Controllers/SConscript
+++ b/Swift/Controllers/SConscript
@@ -48,5 +48,6 @@ if env["SCONS_STAGE"] == "build" :
 			File("UnitTest/XMPPRosterControllerTest.cpp"),
 			File("UnitTest/PreviousStatusStoreTest.cpp"),
 			File("Chat/UnitTest/ChatsManagerTest.cpp"),
+			File("Chat/UnitTest/MUCControllerTest.cpp"),
 			File("UnitTest/MockChatWindow.cpp"),
 		])
diff --git a/Swift/Controllers/UIInterfaces/ChatWindow.h b/Swift/Controllers/UIInterfaces/ChatWindow.h
index 79c7d65..4d00dca 100644
--- a/Swift/Controllers/UIInterfaces/ChatWindow.h
+++ b/Swift/Controllers/UIInterfaces/ChatWindow.h
@@ -48,6 +48,7 @@ namespace Swift {
 			virtual void setInputEnabled(bool enabled) = 0;
 			virtual void setRosterModel(Roster* model) = 0;
 			virtual void setTabComplete(TabComplete* completer) = 0;
+			virtual void replaceLastMessage(const String& message) = 0;
 
 			boost::signal<void ()> onClosed;
 			boost::signal<void ()> onAllMessagesRead;
diff --git a/Swift/Controllers/UnitTest/MockChatWindow.h b/Swift/Controllers/UnitTest/MockChatWindow.h
index 4985cbb..822a128 100644
--- a/Swift/Controllers/UnitTest/MockChatWindow.h
+++ b/Swift/Controllers/UnitTest/MockChatWindow.h
@@ -33,6 +33,7 @@ namespace Swift {
 			virtual void setInputEnabled(bool /*enabled*/) {};
 			virtual void setRosterModel(Roster* /*roster*/) {};
 			virtual void setTabComplete(TabComplete*) {};
+			virtual void replaceLastMessage(const Swift::String&) {};
 
 			boost::signal<void ()> onClosed;
 			boost::signal<void ()> onAllMessagesRead;
diff --git a/Swift/QtUI/QtChatView.cpp b/Swift/QtUI/QtChatView.cpp
index ef558b7..d48365b 100644
--- a/Swift/QtUI/QtChatView.cpp
+++ b/Swift/QtUI/QtChatView.cpp
@@ -106,16 +106,21 @@ void QtChatView::addToDOM(boost::shared_ptr<ChatSnippet> snippet) {
 	}
 }
 
-void QtChatView::correctLastMessage(const QString& newMessage) {
+void QtChatView::replaceLastMessage(const QString& newMessage) {
 	/* FIXME: must be queued */
-	lastElement_.findFirst("swift_message");
-	lastElement_.setPlainText(ChatSnippet::escape(newMessage));
+	QWebElement replace = lastElement_.findFirst("span.swift_message");
+	assert(!replace.isNull());
+	QString old = lastElement_.toOuterXml();
+	replace.setInnerXml(ChatSnippet::escape(newMessage));
+	qDebug() << "Replacing old: " << old;
+	qDebug() << "With new: " << lastElement_.toOuterXml();
 }
 
-void QtChatView::correctLastMessage(const QString& newMessage, const QString& note) {
-	correctLastMessage(newMessage);
-	lastElement_.findFirst("swift_time");
-	lastElement_.setPlainText(ChatSnippet::escape(note));
+void QtChatView::replaceLastMessage(const QString& newMessage, const QString& note) {
+	replaceLastMessage(newMessage);
+	QWebElement replace = lastElement_.findFirst("span.swift_time");
+	assert(!replace.isNull());
+	replace.setInnerXml(ChatSnippet::escape(note));
 }
 
 void QtChatView::copySelectionToClipboard() {
@@ -147,8 +152,6 @@ void QtChatView::handleViewLoadFinished(bool ok) {
 	Q_ASSERT(ok);
 	viewReady_ = true;
 	addQueuedSnippets();
-//	webPage_->mainFrame()->evaluateJavaScript(queuedMessages_);
-//	queuedMessages_.clear();
 }
 
 }
diff --git a/Swift/QtUI/QtChatView.h b/Swift/QtUI/QtChatView.h
index 01c1ad7..ce1f8bc 100644
--- a/Swift/QtUI/QtChatView.h
+++ b/Swift/QtUI/QtChatView.h
@@ -28,8 +28,8 @@ namespace Swift {
 			QtChatView(QtChatTheme* theme, QWidget* parent);
 
 			void addMessage(boost::shared_ptr<ChatSnippet> snippet);
-			void correctLastMessage(const QString& newMessage);
-			void correctLastMessage(const QString& newMessage, const QString& note);
+			void replaceLastMessage(const QString& newMessage);
+			void replaceLastMessage(const QString& newMessage, const QString& note);
 			bool isScrolledToBottom() const;
 
 		signals:
diff --git a/Swift/QtUI/QtChatWindow.cpp b/Swift/QtUI/QtChatWindow.cpp
index 53b0dde..70bde4b 100644
--- a/Swift/QtUI/QtChatWindow.cpp
+++ b/Swift/QtUI/QtChatWindow.cpp
@@ -370,4 +370,8 @@ void QtChatWindow::moveEvent(QMoveEvent*) {
 	emit geometryChanged();	
 }
 
+void QtChatWindow::replaceLastMessage(const String& message) {
+	messageLog_->replaceLastMessage(P2QSTRING(message));
+}
+
 }
diff --git a/Swift/QtUI/QtChatWindow.h b/Swift/QtUI/QtChatWindow.h
index 2b006d9..a51b866 100644
--- a/Swift/QtUI/QtChatWindow.h
+++ b/Swift/QtUI/QtChatWindow.h
@@ -49,6 +49,7 @@ namespace Swift {
 			void setRosterModel(Roster* roster);
 			void setTabComplete(TabComplete* completer);
 			int getCount();
+			void replaceLastMessage(const String& message);
 
 		signals:
 			void geometryChanged();
-- 
cgit v0.10.2-6-g49f6