summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--CMakeLists.txt2
-rw-r--r--include/AvatarProvider.h47
-rw-r--r--include/MatrixClient.h4
-rw-r--r--include/TimelineItem.h25
-rw-r--r--src/AvatarProvider.cc83
-rw-r--r--src/ChatPage.cc13
-rw-r--r--src/MatrixClient.cc52
-rw-r--r--src/TimelineItem.cc285
-rw-r--r--src/main.cc2
9 files changed, 412 insertions, 101 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index f6f88e4f..51e1993b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -78,6 +78,7 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang" OR CMAKE_CXX_COMPILER_ID MATCHES "GNU")
 endif()
 
 set(SRC_FILES
+    src/AvatarProvider.cc
     src/ChatPage.cc
     src/Deserializable.cc
     src/EmojiCategory.cc
@@ -160,6 +161,7 @@ include_directories(include/events)
 include_directories(include/events/messages)
 
 qt5_wrap_cpp(MOC_HEADERS
+    include/AvatarProvider.h
     include/ChatPage.h
     include/EmojiCategory.h
     include/EmojiItemDelegate.h
diff --git a/include/AvatarProvider.h b/include/AvatarProvider.h
new file mode 100644
index 00000000..29c8152b
--- /dev/null
+++ b/include/AvatarProvider.h
@@ -0,0 +1,47 @@
+/*
+ * nheko Copyright (C) 2017  Konstantinos Sideris <siderisk@auth.gr>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#pragma once
+
+#include <QImage>
+#include <QObject>
+#include <QSharedPointer>
+#include <QUrl>
+
+#include "MatrixClient.h"
+#include "TimelineItem.h"
+
+class AvatarProvider : public QObject
+{
+	Q_OBJECT
+
+public:
+	static void init(QSharedPointer<MatrixClient> client);
+	static void resolve(const QString &userId, TimelineItem *item);
+	static void setAvatarUrl(const QString &userId, const QUrl &url);
+
+	static void clear();
+
+private:
+	static void updateAvatar(const QString &uid, const QImage &img);
+
+	static QSharedPointer<MatrixClient> client_;
+	static QMap<QString, QList<TimelineItem *>> toBeResolved_;
+
+	static QMap<QString, QImage> userAvatars_;
+	static QMap<QString, QUrl> avatarUrls_;
+};
diff --git a/include/MatrixClient.h b/include/MatrixClient.h
index 2fdb57f0..2fde1c1e 100644
--- a/include/MatrixClient.h
+++ b/include/MatrixClient.h
@@ -41,6 +41,7 @@ public:
 	void registerUser(const QString &username, const QString &password, const QString &server) noexcept;
 	void versions() noexcept;
 	void fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url);
+	void fetchUserAvatar(const QString &userId, const QUrl &avatarUrl);
 	void fetchOwnAvatar(const QUrl &avatar_url);
 	void downloadImage(const QString &event_id, const QUrl &url);
 	void messages(const QString &room_id, const QString &from_token) noexcept;
@@ -69,6 +70,7 @@ signals:
 	void registerSuccess(const QString &userid, const QString &homeserver, const QString &token);
 
 	void roomAvatarRetrieved(const QString &roomid, const QPixmap &img);
+	void userAvatarRetrieved(const QString &userId, const QImage &img);
 	void ownAvatarRetrieved(const QPixmap &img);
 	void imageDownloaded(const QString &event_id, const QPixmap &img);
 
@@ -95,6 +97,7 @@ private:
 		Messages,
 		Register,
 		RoomAvatar,
+		UserAvatar,
 		SendTextMessage,
 		Sync,
 		Versions,
@@ -111,6 +114,7 @@ private:
 	void onInitialSyncResponse(QNetworkReply *reply);
 	void onSyncResponse(QNetworkReply *reply);
 	void onRoomAvatarResponse(QNetworkReply *reply);
+	void onUserAvatarResponse(QNetworkReply *reply);
 	void onImageResponse(QNetworkReply *reply);
 	void onMessagesResponse(QNetworkReply *reply);
 
diff --git a/include/TimelineItem.h b/include/TimelineItem.h
index 5db823b0..c0cf1c7b 100644
--- a/include/TimelineItem.h
+++ b/include/TimelineItem.h
@@ -24,6 +24,7 @@
 #include "ImageItem.h"
 #include "Sync.h"
 
+#include "Avatar.h"
 #include "Image.h"
 #include "MessageEvent.h"
 #include "Notice.h"
@@ -46,19 +47,35 @@ public:
 	TimelineItem(ImageItem *img, const events::MessageEvent<msgs::Image> &e, const QString &color, QWidget *parent);
 	TimelineItem(ImageItem *img, const events::MessageEvent<msgs::Image> &e, QWidget *parent);
 
+	void setUserAvatar(const QImage &pixmap);
+
 	~TimelineItem();
 
 private:
+	void init();
+
 	void generateBody(const QString &body);
 	void generateBody(const QString &userid, const QString &color, const QString &body);
 	void generateTimestamp(const QDateTime &time);
 
+	void setupAvatarLayout(const QString &userName);
+	void setupSimpleLayout();
+
 	QString replaceEmoji(const QString &body);
 
-	void setupLayout();
+	QHBoxLayout *topLayout_;
+	QVBoxLayout *sideLayout_;  // Avatar or Timestamp
+	QVBoxLayout *mainLayout_;  // Header & Message body
+
+	QHBoxLayout *headerLayout_;  // Username (&) Timestamp
+
+	Avatar *userAvatar_;
 
-	QHBoxLayout *top_layout_;
+	QLabel *timestamp_;
+	QLabel *userName_;
+	QLabel *body_;
 
-	QLabel *time_label_;
-	QLabel *content_label_;
+	QFont bodyFont_;
+	QFont usernameFont_;
+	QFont timestampFont_;
 };
diff --git a/src/AvatarProvider.cc b/src/AvatarProvider.cc
new file mode 100644
index 00000000..7481b781
--- /dev/null
+++ b/src/AvatarProvider.cc
@@ -0,0 +1,83 @@
+/*
+ * nheko Copyright (C) 2017  Konstantinos Sideris <siderisk@auth.gr>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ */
+
+#include "AvatarProvider.h"
+
+QSharedPointer<MatrixClient> AvatarProvider::client_;
+
+QMap<QString, QImage> AvatarProvider::userAvatars_;
+QMap<QString, QUrl> AvatarProvider::avatarUrls_;
+QMap<QString, QList<TimelineItem *>> AvatarProvider::toBeResolved_;
+
+void AvatarProvider::init(QSharedPointer<MatrixClient> client)
+{
+	client_ = client;
+
+	connect(client_.data(), &MatrixClient::userAvatarRetrieved, &AvatarProvider::updateAvatar);
+}
+
+void AvatarProvider::updateAvatar(const QString &uid, const QImage &img)
+{
+	if (toBeResolved_.contains(uid)) {
+		auto items = toBeResolved_[uid];
+
+		// Update all the timeline items with the resolved avatar.
+		for (const auto item : items)
+			item->setUserAvatar(img);
+
+		toBeResolved_.remove(uid);
+	}
+
+	userAvatars_.insert(uid, img);
+}
+
+void AvatarProvider::resolve(const QString &userId, TimelineItem *item)
+{
+	if (userAvatars_.contains(userId)) {
+		auto img = userAvatars_[userId];
+
+		item->setUserAvatar(img);
+
+		return;
+	}
+
+	if (avatarUrls_.contains(userId)) {
+		// Add the current timeline item to the waiting list for this avatar.
+		if (!toBeResolved_.contains(userId)) {
+			client_->fetchUserAvatar(userId, avatarUrls_[userId]);
+
+			QList<TimelineItem *> timelineItems;
+			timelineItems.push_back(item);
+
+			toBeResolved_.insert(userId, timelineItems);
+		} else {
+			toBeResolved_[userId].push_back(item);
+		}
+	}
+}
+
+void AvatarProvider::setAvatarUrl(const QString &userId, const QUrl &url)
+{
+	avatarUrls_.insert(userId, url);
+}
+
+void AvatarProvider::clear()
+{
+	userAvatars_.clear();
+	avatarUrls_.clear();
+	toBeResolved_.clear();
+}
diff --git a/src/ChatPage.cc b/src/ChatPage.cc
index 0b09693b..4e9120d2 100644
--- a/src/ChatPage.cc
+++ b/src/ChatPage.cc
@@ -27,6 +27,7 @@
 
 #include "AliasesEventContent.h"
 #include "AvatarEventContent.h"
+#include "AvatarProvider.h"
 #include "CanonicalAliasEventContent.h"
 #include "CreateEventContent.h"
 #include "HistoryVisibilityEventContent.h"
@@ -173,6 +174,8 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client, QWidget *parent)
 		SIGNAL(ownAvatarRetrieved(const QPixmap &)),
 		this,
 		SLOT(setOwnAvatar(const QPixmap &)));
+
+	AvatarProvider::init(client);
 }
 
 void ChatPage::logout()
@@ -203,6 +206,8 @@ void ChatPage::logout()
 	settingsManager_.clear();
 	room_avatars_.clear();
 
+	AvatarProvider::clear();
+
 	emit close();
 }
 
@@ -300,6 +305,14 @@ void ChatPage::initialSyncCompleted(const SyncResponse &response)
 
 		state_manager_.insert(it.key(), room_state);
 		settingsManager_.insert(it.key(), QSharedPointer<RoomSettings>(new RoomSettings(it.key())));
+
+		for (const auto membership : room_state.memberships) {
+			auto uid = membership.sender();
+			auto url = membership.content().avatarUrl();
+
+			if (!url.toString().isEmpty())
+				AvatarProvider::setAvatarUrl(uid, url);
+		}
 	}
 
 	view_manager_->initialize(response.rooms());
diff --git a/src/MatrixClient.cc b/src/MatrixClient.cc
index a605623f..927db541 100644
--- a/src/MatrixClient.cc
+++ b/src/MatrixClient.cc
@@ -287,6 +287,29 @@ void MatrixClient::onRoomAvatarResponse(QNetworkReply *reply)
 	emit roomAvatarRetrieved(roomid, pixmap);
 }
 
+void MatrixClient::onUserAvatarResponse(QNetworkReply *reply)
+{
+	reply->deleteLater();
+
+	int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+
+	if (status == 0 || status >= 400) {
+		qWarning() << reply->errorString();
+		return;
+	}
+
+	auto data = reply->readAll();
+
+	if (data.size() == 0)
+		return;
+
+	auto roomid = reply->property("userid").toString();
+
+	QImage img;
+	img.loadFromData(data);
+
+	emit userAvatarRetrieved(roomid, img);
+}
 void MatrixClient::onGetOwnAvatarResponse(QNetworkReply *reply)
 {
 	reply->deleteLater();
@@ -392,6 +415,9 @@ void MatrixClient::onResponse(QNetworkReply *reply)
 	case Endpoint::RoomAvatar:
 		onRoomAvatarResponse(reply);
 		break;
+	case Endpoint::UserAvatar:
+		onUserAvatarResponse(reply);
+		break;
 	case Endpoint::GetOwnAvatar:
 		onGetOwnAvatarResponse(reply);
 		break;
@@ -591,6 +617,32 @@ void MatrixClient::fetchRoomAvatar(const QString &roomid, const QUrl &avatar_url
 	reply->setProperty("endpoint", static_cast<int>(Endpoint::RoomAvatar));
 }
 
+void MatrixClient::fetchUserAvatar(const QString &userId, const QUrl &avatarUrl)
+{
+	QList<QString> url_parts = avatarUrl.toString().split("mxc://");
+
+	if (url_parts.size() != 2) {
+		qDebug() << "Invalid format for user avatar " << avatarUrl.toString();
+		return;
+	}
+
+	QUrlQuery query;
+	query.addQueryItem("width", "128");
+	query.addQueryItem("height", "128");
+	query.addQueryItem("method", "crop");
+
+	QString media_url = QString("%1/_matrix/media/r0/thumbnail/%2").arg(getHomeServer().toString(), url_parts[1]);
+
+	QUrl endpoint(media_url);
+	endpoint.setQuery(query);
+
+	QNetworkRequest avatar_request(endpoint);
+
+	QNetworkReply *reply = get(avatar_request);
+	reply->setProperty("userid", userId);
+	reply->setProperty("endpoint", static_cast<int>(Endpoint::UserAvatar));
+}
+
 void MatrixClient::downloadImage(const QString &event_id, const QUrl &url)
 {
 	QNetworkRequest image_request(url);
diff --git a/src/TimelineItem.cc b/src/TimelineItem.cc
index b1c58e6a..cf8d5e85 100644
--- a/src/TimelineItem.cc
+++ b/src/TimelineItem.cc
@@ -17,8 +17,10 @@
 
 #include <QDateTime>
 #include <QDebug>
+#include <QFontDatabase>
 #include <QRegExp>
 
+#include "AvatarProvider.h"
 #include "ImageItem.h"
 #include "TimelineItem.h"
 #include "TimelineViewManager.h"
@@ -29,65 +31,119 @@ static const QString URL_HTML = "<a href=\"\\1\" style=\"color: #333333\">\\1</a
 namespace events = matrix::events;
 namespace msgs = matrix::events::messages;
 
+void TimelineItem::init()
+{
+	userAvatar_ = nullptr;
+	timestamp_ = nullptr;
+	userName_ = nullptr;
+	body_ = nullptr;
+
+	QFontDatabase db;
+
+	bodyFont_ = db.font("Open Sans", "Regular", 10);
+	usernameFont_ = db.font("Open Sans", "Bold", 10);
+	timestampFont_ = db.font("Open Sans", "Regular", 7);
+
+	topLayout_ = new QHBoxLayout(this);
+	sideLayout_ = new QVBoxLayout();
+	mainLayout_ = new QVBoxLayout();
+	headerLayout_ = new QHBoxLayout();
+
+	topLayout_->setContentsMargins(7, 0, 0, 0);
+	topLayout_->setSpacing(9);
+
+	topLayout_->addLayout(sideLayout_);
+	topLayout_->addLayout(mainLayout_, 1);
+}
+
 TimelineItem::TimelineItem(const QString &userid, const QString &color, QString body, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	body.replace(URL_REGEX, URL_HTML);
+	auto displayName = TimelineViewManager::displayName(userid);
 
 	generateTimestamp(QDateTime::currentDateTime());
-	generateBody(TimelineViewManager::displayName(userid), color, body);
-	setupLayout();
+	generateBody(displayName, color, body);
+
+	setupAvatarLayout(displayName);
+
+	mainLayout_->addLayout(headerLayout_);
+	mainLayout_->addWidget(body_);
+	mainLayout_->setMargin(0);
+	mainLayout_->setSpacing(0);
+
+	AvatarProvider::resolve(userid, this);
 }
 
 TimelineItem::TimelineItem(QString body, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	body.replace(URL_REGEX, URL_HTML);
 
 	generateTimestamp(QDateTime::currentDateTime());
 	generateBody(body);
-	setupLayout();
+
+	setupSimpleLayout();
+
+	mainLayout_->addWidget(body_);
+	mainLayout_->setMargin(0);
+	mainLayout_->setSpacing(2);
 }
 
 TimelineItem::TimelineItem(ImageItem *image, const events::MessageEvent<msgs::Image> &event, const QString &color, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp());
+	auto displayName = TimelineViewManager::displayName(event.sender());
+
 	generateTimestamp(timestamp);
-	generateBody(TimelineViewManager::displayName(event.sender()), color, "");
+	generateBody(displayName, color, "");
 
-	top_layout_ = new QHBoxLayout();
-	top_layout_->setMargin(0);
-	top_layout_->addWidget(time_label_);
+	setupAvatarLayout(displayName);
 
-	auto right_layout = new QVBoxLayout();
-	right_layout->addWidget(content_label_);
-	right_layout->addWidget(image);
+	auto imageLayout = new QHBoxLayout();
+	imageLayout->addWidget(image);
+	imageLayout->addStretch(1);
 
-	top_layout_->addLayout(right_layout);
-	top_layout_->addStretch(1);
+	mainLayout_->addLayout(headerLayout_);
+	mainLayout_->addLayout(imageLayout);
+	mainLayout_->setContentsMargins(0, 4, 0, 0);
+	mainLayout_->setSpacing(0);
 
-	setLayout(top_layout_);
+	AvatarProvider::resolve(event.sender(), this);
 }
 
 TimelineItem::TimelineItem(ImageItem *image, const events::MessageEvent<msgs::Image> &event, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp());
 	generateTimestamp(timestamp);
 
-	top_layout_ = new QHBoxLayout();
-	top_layout_->setMargin(0);
-	top_layout_->addWidget(time_label_);
-	top_layout_->addWidget(image, 1);
-	top_layout_->addStretch(1);
+	setupSimpleLayout();
 
-	setLayout(top_layout_);
+	auto imageLayout = new QHBoxLayout();
+	imageLayout->setMargin(0);
+	imageLayout->addWidget(image);
+	imageLayout->addStretch(1);
+
+	mainLayout_->addLayout(imageLayout);
+	mainLayout_->setContentsMargins(0, 4, 0, 0);
+	mainLayout_->setSpacing(2);
 }
 
 TimelineItem::TimelineItem(const events::MessageEvent<msgs::Notice> &event, bool with_sender, const QString &color, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	auto body = event.content().body().trimmed().toHtmlEscaped();
 	auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp());
 
@@ -96,17 +152,34 @@ TimelineItem::TimelineItem(const events::MessageEvent<msgs::Notice> &event, bool
 	body.replace(URL_REGEX, URL_HTML);
 	body = "<i style=\"color: #565E5E\">" + body + "</i>";
 
-	if (with_sender)
-		generateBody(TimelineViewManager::displayName(event.sender()), color, body);
-	else
+	if (with_sender) {
+		auto displayName = TimelineViewManager::displayName(event.sender());
+
+		generateBody(displayName, color, body);
+		setupAvatarLayout(displayName);
+
+		mainLayout_->addLayout(headerLayout_);
+		mainLayout_->addWidget(body_);
+		mainLayout_->setMargin(0);
+		mainLayout_->setSpacing(0);
+
+		AvatarProvider::resolve(event.sender(), this);
+	} else {
 		generateBody(body);
 
-	setupLayout();
+		setupSimpleLayout();
+
+		mainLayout_->addWidget(body_);
+		mainLayout_->setMargin(0);
+		mainLayout_->setSpacing(2);
+	}
 }
 
 TimelineItem::TimelineItem(const events::MessageEvent<msgs::Text> &event, bool with_sender, const QString &color, QWidget *parent)
     : QWidget(parent)
 {
+	init();
+
 	auto body = event.content().body().trimmed().toHtmlEscaped();
 	auto timestamp = QDateTime::fromMSecsSinceEpoch(event.timestamp());
 
@@ -114,34 +187,45 @@ TimelineItem::TimelineItem(const events::MessageEvent<msgs::Text> &event, bool w
 
 	body.replace(URL_REGEX, URL_HTML);
 
-	if (with_sender)
-		generateBody(TimelineViewManager::displayName(event.sender()), color, body);
-	else
+	if (with_sender) {
+		auto displayName = TimelineViewManager::displayName(event.sender());
+		generateBody(displayName, color, body);
+
+		setupAvatarLayout(displayName);
+
+		mainLayout_->addLayout(headerLayout_);
+		mainLayout_->addWidget(body_);
+		mainLayout_->setMargin(0);
+		mainLayout_->setSpacing(0);
+
+		AvatarProvider::resolve(event.sender(), this);
+	} else {
 		generateBody(body);
 
-	setupLayout();
+		setupSimpleLayout();
+
+		mainLayout_->addWidget(body_);
+		mainLayout_->setMargin(0);
+		mainLayout_->setSpacing(2);
+	}
 }
 
+// Only the body is displayed.
 void TimelineItem::generateBody(const QString &body)
 {
-	content_label_ = new QLabel(this);
-	content_label_->setWordWrap(true);
-	content_label_->setAlignment(Qt::AlignTop);
-	content_label_->setStyleSheet("margin: 0;");
-	QString content(
-		"<html>"
-		"<head/>"
-		"<body>"
-		"   <span style=\"font-size: 10pt; color: #171919;\">"
-		"   %1"
-		"   </span>"
-		"</body>"
-		"</html>");
-	content_label_->setText(content.arg(replaceEmoji(body)));
-	content_label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction);
-	content_label_->setOpenExternalLinks(true);
+	QString content("<span style=\"color: #171919;\">%1</span>");
+
+	body_ = new QLabel(this);
+	body_->setWordWrap(true);
+	body_->setFont(bodyFont_);
+	body_->setText(content.arg(replaceEmoji(body)));
+	body_->setAlignment(Qt::AlignTop);
+
+	body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction);
+	body_->setOpenExternalLinks(true);
 }
 
+// The username/timestamp is displayed along with the message body.
 void TimelineItem::generateBody(const QString &userid, const QString &color, const QString &body)
 {
 	auto sender = userid;
@@ -150,64 +234,35 @@ void TimelineItem::generateBody(const QString &userid, const QString &color, con
 	if (userid.split(":")[0].split("@").size() > 1)
 		sender = userid.split(":")[0].split("@")[1];
 
-	content_label_ = new QLabel(this);
-	content_label_->setWordWrap(true);
-	content_label_->setAlignment(Qt::AlignTop);
-	content_label_->setStyleSheet("margin: 0;");
-	QString content(
-		"<html>"
-		"<head/>"
-		"<body>"
-		"   <span style=\"font-size: 10pt; font-weight: 600; color: %1\">"
-		"   %2"
-		"   </span>"
-		"   <span style=\"font-size: 10pt; color: #171919;\">"
-		"   %3"
-		"   </span>"
-		"</body>"
-		"</html>");
-	content_label_->setText(content.arg(color).arg(sender).arg(replaceEmoji(body)));
-	content_label_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction);
-	content_label_->setOpenExternalLinks(true);
-}
+	QString userContent("<span style=\"color: %1\"> %2 </span>");
+	QString bodyContent("<span style=\"color: #171717;\"> %1 </span>");
 
-void TimelineItem::generateTimestamp(const QDateTime &time)
-{
-	auto local_time = time.toString("HH:mm");
-
-	time_label_ = new QLabel(this);
-	QString msg(
-		"<html>"
-		"<head/>"
-		"<body>"
-		"   <span style=\"font-size: 7pt; color: #5d6565;\">"
-		"   %1"
-		"   </span>"
-		"</body>"
-		"</html>");
-	time_label_->setText(msg.arg(local_time));
-	time_label_->setStyleSheet("margin-left: 7px; margin-right: 7px; margin-top: 0;");
-	time_label_->setAlignment(Qt::AlignTop);
-}
+	userName_ = new QLabel(this);
+	userName_->setFont(usernameFont_);
+	userName_->setText(userContent.arg(color).arg(sender));
+	userName_->setAlignment(Qt::AlignTop);
 
-void TimelineItem::setupLayout()
-{
-	if (time_label_ == nullptr) {
-		qWarning() << "TimelineItem: Time label is not initialized";
+	if (body.isEmpty())
 		return;
-	}
 
-	if (content_label_ == nullptr) {
-		qWarning() << "TimelineItem: Content label is not initialized";
-		return;
-	}
+	body_ = new QLabel(this);
+	body_->setFont(bodyFont_);
+	body_->setWordWrap(true);
+	body_->setAlignment(Qt::AlignTop);
+	body_->setText(bodyContent.arg(replaceEmoji(body)));
+	body_->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextBrowserInteraction);
+	body_->setOpenExternalLinks(true);
+}
 
-	top_layout_ = new QHBoxLayout();
-	top_layout_->setMargin(0);
-	top_layout_->addWidget(time_label_);
-	top_layout_->addWidget(content_label_, 1);
+void TimelineItem::generateTimestamp(const QDateTime &time)
+{
+	QString msg("<span style=\"color: #5d6565;\"> %1 </span>");
 
-	setLayout(top_layout_);
+	timestamp_ = new QLabel(this);
+	timestamp_->setFont(timestampFont_);
+	timestamp_->setText(msg.arg(time.toString("HH:mm")));
+	timestamp_->setAlignment(Qt::AlignTop);
+	timestamp_->setStyleSheet("margin-top: 2px;");
 }
 
 QString TimelineItem::replaceEmoji(const QString &body)
@@ -227,6 +282,46 @@ QString TimelineItem::replaceEmoji(const QString &body)
 	return fmtBody;
 }
 
+void TimelineItem::setupAvatarLayout(const QString &userName)
+{
+	topLayout_->setContentsMargins(7, 6, 0, 0);
+
+	userAvatar_ = new Avatar(this);
+	userAvatar_->setLetter(QChar(userName[0]).toUpper());
+	userAvatar_->setBackgroundColor(QColor("#eee"));
+	userAvatar_->setTextColor(QColor("black"));
+	userAvatar_->setSize(32);
+
+	// TODO: The provided user name should be a UserId class
+	if (userName[0] == '@' && userName.size() > 1)
+		userAvatar_->setLetter(QChar(userName[1]).toUpper());
+
+	sideLayout_->addWidget(userAvatar_);
+	sideLayout_->addStretch(1);
+	sideLayout_->setMargin(0);
+	sideLayout_->setSpacing(0);
+
+	headerLayout_->addWidget(userName_);
+	headerLayout_->addWidget(timestamp_, 1);
+	headerLayout_->setMargin(0);
+}
+
+void TimelineItem::setupSimpleLayout()
+{
+	sideLayout_->addWidget(timestamp_);
+	sideLayout_->addStretch(1);
+
+	topLayout_->setContentsMargins(9, 0, 0, 0);
+}
+
+void TimelineItem::setUserAvatar(const QImage &avatar)
+{
+	if (userAvatar_ == nullptr)
+		return;
+
+	userAvatar_->setImage(avatar);
+}
+
 TimelineItem::~TimelineItem()
 {
 }
diff --git a/src/main.cc b/src/main.cc
index e6d4c4e7..bf165cab 100644
--- a/src/main.cc
+++ b/src/main.cc
@@ -36,9 +36,7 @@ int main(int argc, char *argv[])
 	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Regular.ttf");
 	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Italic.ttf");
 	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Bold.ttf");
-	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-BoldItalic.ttf");
 	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-Semibold.ttf");
-	QFontDatabase::addApplicationFont(":/fonts/fonts/OpenSans/OpenSans-SemiboldItalic.ttf");
 	QFontDatabase::addApplicationFont(":/fonts/fonts/EmojiOne/emojione-android.ttf");
 
 	app.setWindowIcon(QIcon(":/logos/nheko.png"));