#include "../include/ChatWindow.hpp" #include "../include/ChatMessage.hpp" #include "../include/InputDialog.hpp" #include "../include/Window.hpp" #include "../include/Topbar.hpp" #include #include #include #include #include #include namespace dchat { // Merge all messages that are written by the same user without interrupt within a timeframe const int MERGE_MESSAGE_TIMESTAMP_DIFF_SEC = 60; ChatWindow::ChatWindow(Window *_window) : roomSettingsWindow(this), roomNotificationsWindow(this), userSettingsWindow(this), createRoomButton("images/add_button_small.png", " Create room"), joinRoomButton("images/add_button_small.png", " Join room"), userSettingsButton("images/settings-icon.png", " User settings"), roomCount(0), currentRoomData(nullptr), window(_window), chatInputShowPlaceholder(true), chatInputChangeByPlaceholder(false) { assert(window); stack.set_homogeneous(false); stack.set_transition_type(Gtk::StackTransitionType::STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT); stack.set_transition_duration(250); leftPanelUsersStack.set_homogeneous(false); messageAreaStack.set_homogeneous(false); setupTopbar(); attach(stack, 0, 1, 2, 2); stack.add(chatPage, "chat"); stack.add(roomSettingsWindow, "room-settings"); stack.add(roomNotificationsWindow, "notifications"); stack.add(userSettingsWindow, "user-settings"); Gtk::ResponsivePaned *sidePanels = Gtk::manage(new Gtk::ResponsivePaned(Gtk::ORIENTATION_HORIZONTAL)); sidePanels->get_style_context()->add_class("side-panels"); sidePanels->set_wide_handle(true); chatPage.attach(*sidePanels, 0, 1, 1, 2); setupLeftPanel(sidePanels); Gtk::Grid *rightPanel = Gtk::manage(new Gtk::Grid()); rightPanel->set_vexpand(true); rightPanel->set_hexpand(true); sidePanels->add2(*rightPanel); setupMessageArea(rightPanel); setupChatInput(rightPanel); set_vexpand(true); set_hexpand(true); chatPage.show_all(); stack.show(); stack.set_visible_child("chat"); } ChatWindow::~ChatWindow() { for(auto &it : roomDataById) { delete it.second->leftPanelUsersLayout; delete it.second->messageAreaLayout; delete it.second->button; delete it.second; } } void ChatWindow::setupTopbar() { topbar = Gtk::manage(new Topbar()); topbar->show_all(); attach(*topbar, 0, 0, 2, 1); topbar->roomSettingsButton.signal_clicked().connect([this] { if(!currentRoom) { window->windowNotification->show("You need to be inside a room to go to settings"); return; } if(!currentRoom->localUser) { window->windowNotification->show("You need to be a member of the room to go to room settings"); return; } roomSettingsWindow.show_all(); roomSettingsWindow.selectRoom(currentRoom); stack.set_visible_child("room-settings"); }); topbar->roomNotificationsButton.signal_clicked().connect([this] { if(!currentRoom) { window->windowNotification->show("You need to be inside a room to go to room notifications"); return; } if(!currentRoom->localUser) { window->windowNotification->show("You need to be a member of the room to go to room notifications"); return; } roomNotificationsWindow.show_all(); stack.set_visible_child("notifications"); }); } void ChatWindow::setupLeftPanel(Gtk::ResponsivePaned *sidePanels) { Gtk::Grid *leftPanelLayout = Gtk::manage(new Gtk::Grid()); leftPanelLayout->set_vexpand(true); leftPanelLayout->set_size_request(190); leftPanelLayout->get_style_context()->add_class("left-panel"); sidePanels->add1(*leftPanelLayout); Gtk::ResponsivePaned *leftPanel = Gtk::manage(new Gtk::ResponsivePaned(Gtk::ORIENTATION_VERTICAL)); leftPanel->set_vexpand(true); leftPanelLayout->attach(*leftPanel, 0, 0, 1, 2); Gtk::Grid *channelsLayout = Gtk::manage(new Gtk::Grid()); channelsLayout->set_orientation(Gtk::ORIENTATION_VERTICAL); channelsLayout->set_vexpand(true); channelsLayout->set_hexpand(true); leftPanel->add1(*channelsLayout); Gtk::Label *channelsTitle = Gtk::manage(new Gtk::Label()); channelsTitle->set_name("channels-title"); channelsTitle->set_text("Channels"); channelsTitle->set_halign(Gtk::ALIGN_START); channelsLayout->attach(*channelsTitle, 0, 0, 1, 1); Gtk::ScrolledWindow *channelsScrollWindow = Gtk::manage(new Gtk::ScrolledWindow()); channelsScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_NEVER); channelsScrollWindow->set_vexpand(true); channelsScrollWindow->set_hexpand(true); channelsScrollWindow->set_overlay_scrolling(false); channelsLayout->attach_next_to(*channelsScrollWindow, *channelsTitle, Gtk::POS_BOTTOM, 1, 2); leftPanelChannels.set_stack(messageAreaStack); //leftPanelChannels.set_row_spacing(5); //leftPanelChannels.set_vexpand(true); //leftPanelChannels.set_hexpand(true); channelsScrollWindow->add(leftPanelChannels); //// Gtk::Grid *userLayout = Gtk::manage(new Gtk::Grid()); leftPanel->add2(*userLayout); Gtk::Label *usersTitle = Gtk::manage(new Gtk::Label()); usersTitle->get_style_context()->add_class("users-title"); usersTitle->set_text("Users"); usersTitle->set_halign(Gtk::ALIGN_START); userLayout->attach(*usersTitle, 0, 0, 1, 1); //leftPanelUsersStack.set_vexpand(true); userLayout->attach_next_to(leftPanelUsersStack, *usersTitle, Gtk::POS_BOTTOM, 1, 2); createRoomButton.set_halign(Gtk::ALIGN_START); createRoomButton.set_valign(Gtk::ALIGN_END); leftPanelLayout->attach_next_to(createRoomButton, *leftPanel, Gtk::POS_BOTTOM, 1, 1); createRoomButton.signal_clicked().connect([this]() { InputDialog createRoomDialog("Create a new room", { "Room name" }); switch(createRoomDialog.run()) { case Gtk::RESPONSE_ACCEPT: { // TODO: Show error inline in the create room dialog Glib::ustring roomName = createRoomDialog.entries[0]->get_text(); if(roomName.size() == 0 || roomName.size() > 32) window->windowNotification->show("Room name has to be between 1 and 32 characters"); else { window->rooms->createRoom(roomName); } break; } default: break; } }); joinRoomButton.set_halign(Gtk::ALIGN_START); leftPanelLayout->attach_next_to(joinRoomButton, createRoomButton, Gtk::POS_BOTTOM, 1, 1); joinRoomButton.signal_clicked().connect([this]() { InputDialog joinRoomDialog("Join room", { "Invite key", "Message to send to admin" }); switch(joinRoomDialog.run()) { case Gtk::RESPONSE_ACCEPT: { // TODO: Show error inline in the join room dialog Glib::ustring inviteKey = joinRoomDialog.entries[0]->get_text(); Glib::ustring message = joinRoomDialog.entries[1]->get_text(); if(inviteKey.size() != 130) window->windowNotification->show("Invite key has to be 130 characters"); else window->rooms->requestJoinRoom(inviteKey, message); break; } default: break; } }); userSettingsButton.set_halign(Gtk::ALIGN_START); leftPanelLayout->attach_next_to(userSettingsButton, joinRoomButton, Gtk::POS_BOTTOM, 1, 1); userSettingsButton.signal_clicked().connect([this]() { if(!currentRoom) { window->windowNotification->show("You need to be inside a room to go to user settings"); return; } if(!currentRoom->localUser) { window->windowNotification->show("You need to be a member of the room to go to user settings"); return; } userSettingsWindow.show_all(); stack.set_visible_child("user-settings"); }); } void ChatWindow::setupMessageArea(Gtk::Grid *rightPanel) { messageAreaStack.set_name("message-area-layout"); messageAreaStack.set_vexpand(true); rightPanel->attach(messageAreaStack, 0, 0, 1, 2); } void ChatWindow::setupChatInput(Gtk::Grid *rightPanel) { Gtk::Grid *chatArea = Gtk::manage(new Gtk::Grid()); chatArea->set_name("chat-area"); rightPanel->attach(*chatArea, 0, 3, 1, 1); Gtk::ScrolledWindow *chatScrollWindow = Gtk::manage(new Gtk::ScrolledWindow()); chatScrollWindow->set_hexpand(true); chatScrollWindow->set_overlay_scrolling(false); chatScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_NEVER); chatScrollWindow->set_name("chat-scroll-view"); chatArea->attach(*chatScrollWindow, 0, 0, 1, 1); chatInput.set_hexpand(true); chatInput.set_editable(false); chatInput.set_name("chat-input"); chatInput.set_wrap_mode(Gtk::WrapMode::WRAP_WORD_CHAR); chatInput.get_buffer()->set_text("Type a message..."); chatInput.get_style_context()->add_class("chat-input-unfocused"); chatInputShowPlaceholder = true; chatScrollWindow->add(chatInput); chatInput.signal_focus_in_event().connect([this](GdkEventFocus *event) { if(chatInputShowPlaceholder) { chatInputChangeByPlaceholder = true; chatInput.get_buffer()->set_text(""); } chatInput.get_style_context()->remove_class("chat-input-unfocused"); chatInput.get_style_context()->add_class("chat-input-focused"); return false; }); chatInput.signal_focus_out_event().connect([this](GdkEventFocus *event) { if(chatInputShowPlaceholder) { chatInputChangeByPlaceholder = true; chatInput.get_buffer()->set_text("Type a message..."); chatInput.get_style_context()->remove_class("chat-input-focused"); chatInput.get_style_context()->add_class("chat-input-unfocused"); } return false; }); double fontSize = 18.5;//PANGO_PIXELS(chatInput.get_style_context()->get_font().get_size()); chatPrevNumLines = 1; chatInput.signal_key_press_event().connect([this](GdkEventKey *event) { if((event->keyval == GDK_KEY_Return || event->keyval == GDK_KEY_KP_Enter) && !(event->state & Gdk::SHIFT_MASK)) { if(chatInput.get_editable()) { Glib::ustring str = chatInput.get_buffer()->get_text(); chatInput.get_buffer()->set_text(""); while(gtk_events_pending()) gtk_main_iteration_do(FALSE); currentRoom->publishMessage(str); } return true; } return false; }, false); chatInput.get_buffer()->signal_changed().connect([this, chatScrollWindow, fontSize] { if(chatInputChangeByPlaceholder) { chatInputChangeByPlaceholder = false; } else { chatInputShowPlaceholder = chatInput.get_buffer()->get_char_count() == 0; chatInputChangeByPlaceholder = false; } Gdk::Rectangle rect; chatInput.get_iter_location(chatInput.get_buffer()->end(), rect); int numLines = 1 + (rect.get_y() + rect.get_height()) / fontSize; if(numLines != chatPrevNumLines) { chatScrollWindow->set_min_content_height(fontSize * std::min(numLines, 10)); auto adj = chatScrollWindow->get_vadjustment(); if(numLines <= 11) { chatScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_NEVER); adj->set_value(0); } else { chatScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_ALWAYS); } } chatPrevNumLines = numLines; }); chatScrollWindow->get_vadjustment()->signal_value_changed().connect([this, chatScrollWindow, fontSize]() { Gdk::Rectangle rect; chatInput.get_iter_location(chatInput.get_buffer()->end(), rect); int numLines = 1 + (rect.get_y() + rect.get_height()) / fontSize; auto adj = chatScrollWindow->get_vadjustment(); if(numLines <= 11) { chatScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_NEVER); adj->set_value(0); } else { chatScrollWindow->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_ALWAYS); } }); } void ChatWindow::addRoom(std::shared_ptr room) { std::string roomIdStr = room->id->toString(); Gtk::Grid *leftPanelUsersLayout = new Gtk::Grid(); leftPanelUsersLayout->set_vexpand(true); leftPanelUsersLayout->show(); leftPanelUsersStack.add(*leftPanelUsersLayout, roomIdStr); Gtk::ScrolledWindow *messageArea = Gtk::manage(new Gtk::ScrolledWindow()); messageArea->set_vexpand(true); messageArea->set_overlay_scrolling(false); messageArea->set_policy(Gtk::PolicyType::POLICY_NEVER, Gtk::PolicyType::POLICY_ALWAYS); messageAreaStack.add(*messageArea, roomIdStr, "Room name"); if(!messageAreaStack.get_visible_child()) messageAreaStack.set_visible_child(roomIdStr); Gtk::Grid *messageAreaLayout = new Gtk::Grid(); messageAreaLayout->set_valign(Gtk::ALIGN_START); messageArea->add(*messageAreaLayout); messageArea->show_all(); fprintf(stderr, "Added channel %s\n", room->id->toString().c_str()); Gtk::RadioButton *roomButton = new Gtk::RadioButton("Room name"); roomButton->property_draw_indicator().set_value(false); roomButton->get_style_context()->add_class("room-button"); roomButton->set_hexpand(true); roomButton->show(); roomButton->signal_clicked().connect([this, room] { setCurrentRoom(room); }); //leftPanelChannels.attach(*roomButton, 0, roomCount, 1, 1); ++roomCount; uint numMessages = 0; uint numUsers = 0; RoomData *roomData = new RoomData { leftPanelUsersLayout, messageAreaLayout, numMessages, numUsers, roomButton }; roomDataById[*room->id] = roomData; if(!currentRoom) { roomButton->set_active(true); currentRoom = room; currentRoomData = roomData; if(room->localUser) chatInput.set_editable(true); else chatInput.set_editable(false); leftPanelUsersStack.set_visible_child(roomIdStr); messageAreaStack.set_visible_child(roomIdStr); } } void ChatWindow::setCurrentRoom(std::shared_ptr room) { std::string roomIdStr = room->id->toString(); leftPanelUsersStack.set_visible_child(roomIdStr); messageAreaStack.set_visible_child(roomIdStr); topbar->setTitle(room->name); currentRoom = room; assert(roomDataById.find(*room->id) != roomDataById.end()); currentRoomData = roomDataById[*room->id]; if(room->localUser) { chatInput.set_editable(true); std::string nickname = "Anonymous"; if(!room->localUser->nickname.empty()) nickname = room->localUser->nickname; std::string avatarUrl = "https://discordemoji.com/assets/emoji/PeepoHide.png"; if(!room->localUser->avatarUrl.empty()) avatarUrl = room->localUser->avatarUrl; userSettingsWindow.setNickname(nickname); userSettingsWindow.setAvatarUrl(avatarUrl); } else chatInput.set_editable(false); // TODO: Instead of scrolling to bottom, remember scroll position (even after restarting application). // We want to show oldest unread message first scrollToBottom(); } void ChatWindow::addMessage(const RoomAddMessageRequest &request) { if(request.prevMessage && request.prevMessage->creator->publicKey == request.message->creator->publicKey) { int64_t msgTimeDiff = (int64_t)request.message->timestampSeconds - (int64_t)request.prevMessage->timestampSeconds; if(msgTimeDiff <= MERGE_MESSAGE_TIMESTAMP_DIFF_SEC) { auto message = messageById[request.prevMessage->id]; message->appendText("\n"); message->appendText(request.message->text); // Since messages that are sent withing a timeframe are combined, several message ids can refer to the same message messageById[request.message->id] = message; if(!request.loadedFromCache && *request.room->id == *currentRoom->id) { currentRoomData->messageAreaLayout->signal_draw(); scrollToBottom(); } else { currentRoomData->messageAreaLayout->signal_draw(); } return; } } std::string userNickname = request.message->creator->nickname; if(userNickname.empty()) userNickname = "Anonymous"; ChatMessage *message = Gtk::manage(new ChatMessage(userNickname, request.message->text, request.message->timestampSeconds)); if(request.message->creator->avatarUrl.empty()) message->avatar.url = "https://discordemoji.com/assets/emoji/PeepoHide.png"; else message->avatar.url = request.message->creator->avatarUrl; message->set_valign(Gtk::Align::ALIGN_START); message->set_hexpand(true); message->show_all(); messageById[request.message->id] = message; RoomData *roomData = roomDataById[*request.room->id]; roomData->messageAreaLayout->attach(*message, 0, roomData->numMessages++, 1, 1); // TODO: When we get a message in the current room we scroll to the bottom, but this should only be done if we are not manually scrolling to view old messages if(!request.loadedFromCache && *request.room->id == *currentRoom->id) { roomData->messageAreaLayout->signal_draw(); scrollToBottom(); } } void ChatWindow::addUser(const RoomAddUserRequest &request) { Gtk::Label *username = Gtk::manage(new Gtk::Label("Anonymous")); username->set_halign(Gtk::ALIGN_START); username->show(); username->get_style_context()->add_class("username-list-username"); request.user->userdata = username; RoomData *roomData = roomDataById[*request.room->id]; roomData->leftPanelUsersLayout->attach(*username, 0, roomData->numUsers++, 1, 1); fprintf(stderr, "Added user %s\n", request.user->publicKey.toString().c_str()); if(roomData == currentRoomData) { if(request.room->localUser) { chatInput.set_editable(true); userSettingsWindow.setNickname("Anonymous"); userSettingsWindow.setAvatarUrl("https://discordemoji.com/assets/emoji/PeepoHide.png"); } else chatInput.set_editable(false); } if(!request.loadedFromCache && request.isLocalUser) { Glib::ustring msg = "You were added to room "; msg += request.room->name + " by " + request.addedByUser->nickname; window->windowNotification->show(msg); } } void ChatWindow::setUserNickname(const UserChangeNicknameRequest &request) { Gtk::Label *userNicknameLabel = (Gtk::Label*)request.user->userdata; userNicknameLabel->set_text(request.newNickname); if(request.user == request.room->localUser) userSettingsWindow.setNickname(request.newNickname); fprintf(stderr, "Set nickname for user %s to %s\n", request.user->publicKey.toString().c_str(), request.newNickname.c_str()); } void ChatWindow::setUserAvatar(const UserChangeAvatarRequest &request) { request.user->avatarUrl = request.url; if(request.user == request.room->localUser) userSettingsWindow.setAvatarUrl(request.url); fprintf(stderr, "Set avatar for user %s to %s\n", request.user->publicKey.toString().c_str(), request.url.c_str()); } void ChatWindow::changeRoomName(const RoomChangeNameRequest &request) { RoomData *roomData = roomDataById[*request.room->id]; Gtk::RadioButton *button = roomData->button; button->set_label(request.newName); if(*request.room->id == *currentRoom->id) topbar->setTitle(request.newName); fprintf(stderr, "Changed room %s name to %s\n", request.room->id->toString().c_str(), request.newName.c_str()); } void ChatWindow::addInviteRequest(const InviteUserRequest &request) { auto notification = Gio::Notification::create("Invite"); Glib::ustring body = "User "; body += request.userPublicKey.toString(); body += " wants to join your room " + request.room->name + ". Message from user: "; body += request.message; notification->set_body(body); window->get_application()->send_notification(notification); roomNotificationsWindow.addInviteRequest(request); } void ChatWindow::scrollToBottom() { //while(gtk_events_pending()) // gtk_main_iteration_do(FALSE); //auto adj = messageArea.get_vadjustment(); //adj->set_value(adj->get_upper()); } }