Merge pull request #335 from Nheko-Reborn/qml-text-input

Qml text input
This commit is contained in:
DeepBlueV7.X 2020-11-25 21:59:23 +01:00 committed by GitHub
commit 54d75466c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1801 additions and 10153 deletions

1
.gitignore vendored
View file

@ -57,6 +57,7 @@ ui_*.h
# Vim
*.swp
*.swo
*.swn
#####=== CMake ===#####

View file

@ -241,17 +241,13 @@ set(SRC_FILES
src/dialogs/RoomSettings.cpp
# Emoji
src/emoji/Category.cpp
src/emoji/EmojiModel.cpp
src/emoji/ItemDelegate.cpp
src/emoji/Panel.cpp
src/emoji/PickButton.cpp
src/emoji/Provider.cpp
src/emoji/Provider_new.cpp
# Timeline
src/timeline/EventStore.cpp
src/timeline/InputBar.cpp
src/timeline/Reaction.cpp
src/timeline/TimelineViewManager.cpp
src/timeline/TimelineModel.cpp
@ -261,22 +257,23 @@ set(SRC_FILES
src/ui/Avatar.cpp
src/ui/Badge.cpp
src/ui/DropShadow.cpp
src/ui/LoadingIndicator.cpp
src/ui/InfoMessage.cpp
src/ui/FlatButton.cpp
src/ui/FloatingButton.cpp
src/ui/InfoMessage.cpp
src/ui/Label.cpp
src/ui/LoadingIndicator.cpp
src/ui/NhekoDropArea.cpp
src/ui/OverlayModal.cpp
src/ui/SnackBar.cpp
src/ui/OverlayWidget.cpp
src/ui/RaisedButton.cpp
src/ui/Ripple.cpp
src/ui/RippleOverlay.cpp
src/ui/OverlayWidget.cpp
src/ui/SnackBar.cpp
src/ui/TextField.cpp
src/ui/TextLabel.cpp
src/ui/ToggleButton.cpp
src/ui/Theme.cpp
src/ui/ThemeManager.cpp
src/ui/ToggleButton.cpp
src/ui/UserProfile.cpp
src/AvatarProvider.cpp
@ -287,6 +284,7 @@ set(SRC_FILES
src/ColorImageProvider.cpp
src/CommunitiesList.cpp
src/CommunitiesListItem.cpp
src/CompletionProxyModel.cpp
src/DeviceVerificationFlow.cpp
src/EventAccessors.cpp
src/InviteeItem.cpp
@ -303,10 +301,10 @@ set(SRC_FILES
src/SSOHandler.cpp
src/SideBarActions.cpp
src/Splitter.cpp
src/TextInputWidget.cpp
src/TrayIcon.cpp
src/UserInfoWidget.cpp
src/UserSettingsPage.cpp
src/UsersModel.cpp
src/Utils.cpp
src/WebRTCSession.cpp
src/WelcomePage.cpp
@ -454,15 +452,12 @@ qt5_wrap_cpp(MOC_HEADERS
src/dialogs/RoomSettings.h
# Emoji
src/emoji/Category.h
src/emoji/EmojiModel.h
src/emoji/ItemDelegate.h
src/emoji/Panel.h
src/emoji/PickButton.h
src/emoji/Provider.h
# Timeline
src/timeline/EventStore.h
src/timeline/InputBar.h
src/timeline/Reaction.h
src/timeline/TimelineViewManager.h
src/timeline/TimelineModel.h
@ -477,6 +472,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/ui/Label.h
src/ui/FloatingButton.h
src/ui/Menu.h
src/ui/NhekoDropArea.h
src/ui/OverlayWidget.h
src/ui/SnackBar.h
src/ui/RaisedButton.h
@ -498,6 +494,7 @@ qt5_wrap_cpp(MOC_HEADERS
src/ChatPage.h
src/CommunitiesList.h
src/CommunitiesListItem.h
src/CompletionProxyModel.h
src/DeviceVerificationFlow.h
src/InviteeItem.h
src/LoginPage.h
@ -510,10 +507,10 @@ qt5_wrap_cpp(MOC_HEADERS
src/SSOHandler.h
src/SideBarActions.h
src/Splitter.h
src/TextInputWidget.h
src/TrayIcon.h
src/UserInfoWidget.h
src/UserSettingsPage.h
src/UsersModel.h
src/WebRTCSession.h
src/WelcomePage.h
src/popups/PopupItem.h

View file

@ -12,8 +12,11 @@ Rectangle {
MouseArea {
anchors.fill: parent
onClicked: if (TimelineManager.onVideoCall)
stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1;
onClicked: {
if (TimelineManager.onVideoCall)
stackLayout.currentIndex = stackLayout.currentIndex ? 0 : 1;
}
}
RowLayout {
@ -39,8 +42,7 @@ Rectangle {
Image {
Layout.preferredWidth: 24
Layout.preferredHeight: 24
source: TimelineManager.onVideoCall ?
"qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
source: TimelineManager.onVideoCall ? "qrc:/icons/icons/ui/video-call.png" : "qrc:/icons/icons/ui/place-call.png"
}
Label {
@ -51,7 +53,6 @@ Rectangle {
Connections {
target: TimelineManager
onCallStateChanged: {
switch (state) {
case WebRTCState.INITIATING:
@ -69,6 +70,7 @@ Rectangle {
callTimer.startTime = Math.floor(d.getTime() / 1000);
if (TimelineManager.onVideoCall)
stackLayout.currentIndex = 1;
break;
case WebRTCState.DISCONNECTED:
callStateLabel.text = "";

178
resources/qml/Completer.qml Normal file
View file

@ -0,0 +1,178 @@
import QtQuick 2.9
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import im.nheko 1.0
Popup {
id: popup
property int currentIndex: -1
property string completerName
property var completer
property bool bottomToTop: true
signal completionClicked(string completion)
function up() {
if (bottomToTop)
down_();
else
up_();
}
function down() {
if (bottomToTop)
up_();
else
down_();
}
function up_() {
currentIndex = currentIndex - 1;
if (currentIndex == -2)
currentIndex = listView.count - 1;
}
function down_() {
currentIndex = currentIndex + 1;
if (currentIndex >= listView.count)
currentIndex = -1;
}
function currentCompletion() {
if (currentIndex > -1 && currentIndex < listView.count)
return completer.completionAt(currentIndex);
else
return null;
}
onCompleterNameChanged: {
if (completerName) {
completer = TimelineManager.timeline.input.completerFor(completerName);
completer.setSearchString("");
} else {
completer = undefined;
}
}
padding: 0
onAboutToShow: currentIndex = -1
height: listView.contentHeight
Connections {
onTimelineChanged: completer = null
target: TimelineManager
}
ListView {
id: listView
anchors.fill: parent
implicitWidth: contentItem.childrenRect.width
model: completer
verticalLayoutDirection: popup.bottomToTop ? ListView.BottomToTop : ListView.TopToBottom
delegate: Rectangle {
color: model.index == popup.currentIndex ? colors.highlight : colors.base
height: chooser.childrenRect.height + 4
implicitWidth: chooser.childrenRect.width + 4
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: popup.currentIndex = model.index
onClicked: popup.completionClicked(completer.completionAt(model.index))
}
DelegateChooser {
id: chooser
roleValue: popup.completerName
anchors.centerIn: parent
DelegateChoice {
roleValue: "user"
RowLayout {
id: del
anchors.centerIn: parent
Avatar {
height: 24
width: 24
displayName: model.displayName
url: model.avatarUrl.replace("mxc://", "image://MxcImage/")
}
Label {
text: model.displayName
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
}
Label {
text: "(" + model.userid + ")"
color: model.index == popup.currentIndex ? colors.highlightedText : colors.buttonText
}
}
}
DelegateChoice {
roleValue: "emoji"
RowLayout {
id: del
anchors.centerIn: parent
Label {
text: model.unicode
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
font: Settings.emojiFont
}
Label {
text: model.shortName
color: model.index == popup.currentIndex ? colors.highlightedText : colors.text
}
}
}
}
}
}
enter: Transition {
NumberAnimation {
property: "opacity"
from: 0
to: 1
duration: 100
}
}
exit: Transition {
NumberAnimation {
property: "opacity"
from: 1
to: 0
duration: 100
}
}
background: Rectangle {
color: colors.base
implicitHeight: popup.contentHeight
implicitWidth: popup.contentWidth
}
}

View file

@ -8,6 +8,7 @@ AbstractButton {
property color highlightColor: colors.highlight
property color buttonTextColor: colors.buttonText
focusPolicy: Qt.NoFocus
width: 16
height: 16

View file

@ -5,6 +5,7 @@ import im.nheko 1.0
TextEdit {
textFormat: TextEdit.RichText
readOnly: true
focus: false
wrapMode: Text.Wrap
selectByMouse: !Settings.mobileMode
color: colors.text

View file

@ -2,6 +2,7 @@ import QtQuick 2.9
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
import QtQuick.Window 2.2
import im.nheko 1.0
Rectangle {
color: colors.window
@ -16,14 +17,18 @@ Rectangle {
spacing: 16
ImageButton {
visible: TimelineManager.callsSupported
Layout.alignment: Qt.AlignBottom
hoverEnabled: true
width: 22
height: 22
image: ":/icons/icons/ui/place-call.png"
image: TimelineManager.isOnCall ? ":/icons/icons/ui/end-call.png" : ":/icons/icons/ui/place-call.png"
ToolTip.visible: hovered
ToolTip.text: TimelineManager.isOnCall ? qsTr("Hang up") : qsTr("Place a call")
Layout.topMargin: 8
Layout.bottomMargin: 8
Layout.leftMargin: 16
onClicked: TimelineManager.timeline.input.callButton()
}
ImageButton {
@ -34,6 +39,23 @@ Rectangle {
image: ":/icons/icons/ui/paper-clip-outline.png"
Layout.topMargin: 8
Layout.bottomMargin: 8
Layout.leftMargin: TimelineManager.callsSupported ? 0 : 16
onClicked: TimelineManager.timeline.input.openFileSelection()
ToolTip.visible: hovered
ToolTip.text: qsTr("Send a file")
Rectangle {
anchors.fill: parent
color: colors.window
visible: TimelineManager.timeline.input.uploading
NhekoBusyIndicator {
anchors.fill: parent
running: parent.visible
}
}
}
ScrollView {
@ -44,16 +66,145 @@ Rectangle {
Layout.fillWidth: true
TextArea {
id: textArea
property int completerTriggeredAt: -1
function insertCompletion(completion) {
textArea.remove(completerTriggeredAt, cursorPosition);
textArea.insert(cursorPosition, completion);
}
function openCompleter(pos, type) {
completerTriggeredAt = pos;
popup.completerName = type;
popup.open();
popup.completer.setSearchString(textArea.getText(completerTriggeredAt, cursorPosition));
}
placeholderText: qsTr("Write a message...")
placeholderTextColor: colors.buttonText
color: colors.text
wrapMode: TextEdit.Wrap
focus: true
onTextChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
onCursorPositionChanged: {
TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text);
if (cursorPosition <= completerTriggeredAt) {
completerTriggeredAt = -1;
popup.close();
}
if (popup.opened)
popup.completer.setSearchString(textArea.getText(completerTriggeredAt, cursorPosition));
}
onSelectionStartChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
onSelectionEndChanged: TimelineManager.timeline.input.updateState(selectionStart, selectionEnd, cursorPosition, text)
// Ensure that we get escape key press events first.
Keys.onShortcutOverride: event.accepted = (completerTriggeredAt != -1 && (event.key === Qt.Key_Escape || event.key === Qt.Key_Tab || event.key === Qt.Key_Enter))
Keys.onPressed: {
if (event.matches(StandardKey.Paste)) {
TimelineManager.timeline.input.paste(false);
event.accepted = true;
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_U) {
textArea.clear();
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_P) {
textArea.text = TimelineManager.timeline.input.previousText();
} else if (event.modifiers == Qt.ControlModifier && event.key == Qt.Key_N) {
textArea.text = TimelineManager.timeline.input.nextText();
} else if (event.key == Qt.Key_At) {
textArea.openCompleter(cursorPosition, "user");
popup.open();
} else if (event.key == Qt.Key_Colon) {
textArea.openCompleter(cursorPosition, "emoji");
popup.open();
} else if (event.key == Qt.Key_Escape && popup.opened) {
completerTriggeredAt = -1;
popup.completerName = "";
event.accepted = true;
popup.close();
} else if (event.matches(StandardKey.InsertParagraphSeparator)) {
if (popup.opened) {
var currentCompletion = popup.currentCompletion();
popup.completerName = "";
popup.close();
if (currentCompletion) {
textArea.insertCompletion(currentCompletion);
event.accepted = true;
return ;
}
}
TimelineManager.timeline.input.send();
textArea.clear();
event.accepted = true;
} else if (event.key == Qt.Key_Tab) {
event.accepted = true;
if (popup.opened) {
popup.up();
} else {
var pos = cursorPosition - 1;
while (pos > -1) {
var t = textArea.getText(pos, pos + 1);
console.log('"' + t + '"');
if (t == '@' || t == ' ' || t == '\t') {
textArea.openCompleter(pos, "user");
return ;
} else if (t == ':') {
textArea.openCompleter(pos, "emoji");
return ;
}
pos = pos - 1;
}
// At start of input
textArea.openCompleter(0, "user");
}
} else if (event.key == Qt.Key_Up && popup.opened) {
event.accepted = true;
popup.up();
} else if (event.key == Qt.Key_Down && popup.opened) {
event.accepted = true;
popup.down();
}
}
Connections {
onTimelineChanged: {
textArea.clear();
textArea.append(TimelineManager.timeline.input.text());
textArea.completerTriggeredAt = -1;
popup.completerName = "";
}
target: TimelineManager
}
Connections {
onCompletionClicked: textArea.insertCompletion(completion)
target: popup
}
Completer {
id: popup
x: textArea.positionToRectangle(textArea.completerTriggeredAt).x
y: textArea.positionToRectangle(textArea.completerTriggeredAt).y - height
}
Connections {
onInsertText: textArea.insert(textArea.cursorPosition, text)
target: TimelineManager.timeline.input
}
MouseArea {
// workaround for wrong cursor shape on some platforms
anchors.fill: parent
acceptedButtons: Qt.NoButton
acceptedButtons: Qt.MiddleButton
cursorShape: Qt.IBeamCursor
onClicked: TimelineManager.timeline.input.paste(true)
}
NhekoDropArea {
anchors.fill: parent
roomid: TimelineManager.timeline.roomId()
}
background: Rectangle {
@ -65,6 +216,8 @@ Rectangle {
}
ImageButton {
id: emojiButton
Layout.alignment: Qt.AlignRight | Qt.AlignBottom
hoverEnabled: true
width: 22
@ -72,6 +225,11 @@ Rectangle {
image: ":/icons/icons/ui/smile.png"
Layout.topMargin: 8
Layout.bottomMargin: 8
ToolTip.visible: hovered
ToolTip.text: qsTr("Emoji")
onClicked: emojiPopup.visible ? emojiPopup.close() : emojiPopup.show(emojiButton, function(emoji) {
textArea.insert(textArea.cursorPosition, emoji);
})
}
ImageButton {
@ -83,6 +241,12 @@ Rectangle {
Layout.topMargin: 8
Layout.bottomMargin: 8
Layout.rightMargin: 16
ToolTip.visible: hovered
ToolTip.text: qsTr("Send")
onClicked: {
TimelineManager.timeline.input.send();
textArea.clear();
}
}
}

View file

@ -28,6 +28,7 @@ ListView {
ScrollHelper {
flickable: parent
anchors.fill: parent
enabled: !Settings.mobileMode
}
Shortcut {
@ -181,7 +182,6 @@ ListView {
Connections {
target: chat
onMovementEnded: {
if (y + height + 2 * chat.spacing > chat.contentY + chat.height && y < chat.contentY + chat.height)
chat.model.currentIndex = index;

View file

@ -0,0 +1,64 @@
import QtQuick 2.9
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.2
BusyIndicator {
id: control
contentItem: Item {
implicitWidth: 64
implicitHeight: 64
Item {
id: item
height: Math.min(parent.height, parent.width)
width: height
opacity: control.running ? 1 : 0
RotationAnimator {
target: item
running: control.visible && control.running
from: 0
to: 360
loops: Animation.Infinite
duration: 2000
}
Repeater {
id: repeater
model: 6
Rectangle {
implicitWidth: radius * 2
implicitHeight: radius * 2
radius: item.height / 6
color: colors.text
opacity: (index + 2) / (repeater.count + 2)
transform: [
Translate {
y: -Math.min(item.width, item.height) * 0.5 + item.height / 6
},
Rotation {
angle: index / repeater.count * 360
origin.x: item.height / 2
origin.y: item.height / 2
}
]
}
}
Behavior on opacity {
OpacityAnimator {
duration: 250
}
}
}
}
}

View file

@ -72,7 +72,9 @@ Page {
MenuItem {
text: qsTr("React")
onClicked: emojiPopup.show(messageContextMenu.parent, messageContextMenu.eventId)
onClicked: emojiPopup.show(messageContextMenu.parent, function(emoji) {
TimelineManager.queueReactionMessage(messageContextMenu.eventId, emoji);
})
}
MenuItem {
@ -95,6 +97,7 @@ Page {
}
MenuItem {
// TODO(Nico): Fix this still being iterated over, when using keyboard to select options
visible: messageContextMenu.isEncrypted
height: visible ? implicitHeight : 0
text: qsTr("View decrypted raw message")
@ -129,7 +132,6 @@ Page {
Connections {
target: TimelineManager
onNewDeviceVerificationRequest: {
var dialog = deviceVerificationDialog.createObject(timelineRoot, {
"flow": flow
@ -140,7 +142,6 @@ Page {
Connections {
target: TimelineManager.timeline
onOpenProfile: {
var userProfile = userProfileComponent.createObject(timelineRoot, {
"profile": profile
@ -192,13 +193,15 @@ Page {
StackLayout {
id: stackLayout
currentIndex: 0
Connections {
target: TimelineManager
function onActiveTimelineChanged() {
stackLayout.currentIndex = 0;
}
target: TimelineManager
}
MessageView {
@ -210,6 +213,7 @@ Page {
source: TimelineManager.onVideoCall ? "VideoCall.qml" : ""
onLoaded: TimelineManager.setVideoCallItem()
}
}
TypingIndicator {
@ -234,8 +238,8 @@ Page {
ReplyPopup {
}
//MessageInput {
//}
MessageInput {
}
}

View file

@ -1,7 +1,6 @@
import QtQuick 2.9
import org.freedesktop.gstreamer.GLVideoItem 1.0
GstGLVideoItem {
objectName: "videoCallItem"
objectName: "videoCallItem"
}

View file

@ -12,5 +12,7 @@ ImageButton {
property string event_id
image: ":/icons/icons/ui/smile.png"
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, event_id)
onClicked: emojiPicker.visible ? emojiPicker.close() : emojiPicker.show(emojiButton, function(emoji) {
TimelineManager.queueReactionMessage(event_id, emoji);
})
}

View file

@ -9,7 +9,7 @@ import im.nheko.EmojiModel 1.0
Popup {
id: emojiPopup
property string event_id
property var callback
property var colors
property alias model: gridView.model
property var textArea
@ -18,14 +18,14 @@ Popup {
property real highlightSat: colors.highlight.hslSaturation
property real highlightLight: colors.highlight.hslLightness
function show(showAt, event_id) {
console.debug("Showing emojiPicker for " + event_id);
function show(showAt, callback) {
console.debug("Showing emojiPicker");
if (showAt) {
parent = showAt;
x = Math.round((showAt.width - width) / 2);
y = showAt.height;
}
emojiPopup.event_id = event_id;
emojiPopup.callback = callback;
open();
}
@ -70,9 +70,9 @@ Popup {
ToolTip.visible: hovered
// TODO: maybe add favorites at some point?
onClicked: {
console.debug("Picked " + model.unicode + "in response to " + emojiPopup.event_id);
console.debug("Picked " + model.unicode);
emojiPopup.close();
TimelineManager.queueReactionMessage(emojiPopup.event_id, model.unicode);
callback(model.unicode);
}
// give the emoji a little oomf

View file

@ -123,20 +123,22 @@
<file>qtquickcontrols2.conf</file>
<file>qml/TimelineView.qml</file>
<file>qml/TopBar.qml</file>
<file>qml/MessageView.qml</file>
<file>qml/MessageInput.qml</file>
<file>qml/TypingIndicator.qml</file>
<file>qml/ReplyPopup.qml</file>
<file>qml/ActiveCallBar.qml</file>
<file>qml/Avatar.qml</file>
<file>qml/Completer.qml</file>
<file>qml/EncryptionIndicator.qml</file>
<file>qml/ImageButton.qml</file>
<file>qml/MatrixText.qml</file>
<file>qml/StatusIndicator.qml</file>
<file>qml/EncryptionIndicator.qml</file>
<file>qml/MessageInput.qml</file>
<file>qml/MessageView.qml</file>
<file>qml/NhekoBusyIndicator.qml</file>
<file>qml/Reactions.qml</file>
<file>qml/ReplyPopup.qml</file>
<file>qml/ScrollHelper.qml</file>
<file>qml/StatusIndicator.qml</file>
<file>qml/TimelineRow.qml</file>
<file>qml/TopBar.qml</file>
<file>qml/TypingIndicator.qml</file>
<file>qml/VideoCall.qml</file>
<file>qml/emoji/EmojiButton.qml</file>
<file>qml/emoji/EmojiPicker.qml</file>

View file

@ -85,8 +85,6 @@ constexpr auto OUTBOUND_MEGOLM_SESSIONS_DB("outbound_megolm_sessions");
using CachedReceipts = std::multimap<uint64_t, std::string, std::greater<uint64_t>>;
using Receipts = std::map<std::string, std::map<std::string, uint64_t>>;
Q_DECLARE_METATYPE(SearchResult)
Q_DECLARE_METATYPE(std::vector<SearchResult>)
Q_DECLARE_METATYPE(RoomMember)
Q_DECLARE_METATYPE(mtx::responses::Timeline)
Q_DECLARE_METATYPE(RoomSearchResult)
@ -2334,39 +2332,6 @@ Cache::searchRooms(const std::string &query, std::uint8_t max_items)
return results;
}
std::vector<SearchResult>
Cache::searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items)
{
std::multimap<int, std::pair<std::string, std::string>> items;
auto txn = lmdb::txn::begin(env_, nullptr, MDB_RDONLY);
auto cursor = lmdb::cursor::open(txn, getMembersDb(txn, room_id));
std::string user_id, user_data;
while (cursor.get(user_id, user_data, MDB_NEXT)) {
const auto display_name = displayName(room_id, user_id);
const int score = utils::levenshtein_distance(query, display_name);
items.emplace(score, std::make_pair(user_id, display_name));
}
auto end = items.begin();
if (items.size() >= max_items)
std::advance(end, max_items);
else if (items.size() > 0)
std::advance(end, items.size());
std::vector<SearchResult> results;
for (auto it = items.begin(); it != end; it++) {
const auto user = it->second;
results.push_back(SearchResult{QString::fromStdString(user.first),
QString::fromStdString(user.second)});
}
return results;
}
std::vector<RoomMember>
Cache::getMembers(const std::string &room_id, std::size_t startIndex, std::size_t len)
{
@ -3762,8 +3727,6 @@ namespace cache {
void
init(const QString &user_id)
{
qRegisterMetaType<SearchResult>();
qRegisterMetaType<std::vector<SearchResult>>();
qRegisterMetaType<RoomMember>();
qRegisterMetaType<RoomSearchResult>();
qRegisterMetaType<RoomInfo>();
@ -4075,11 +4038,6 @@ calculateRoomReadStatus()
instance_->calculateRoomReadStatus();
}
std::vector<SearchResult>
searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items)
{
return instance_->searchUsers(room_id, query, max_items);
}
std::vector<RoomSearchResult>
searchRooms(const std::string &query, std::uint8_t max_items)
{

View file

@ -194,8 +194,6 @@ calculateRoomReadStatus(const std::string &room_id);
void
calculateRoomReadStatus();
std::vector<SearchResult>
searchUsers(const std::string &room_id, const std::string &query, std::uint8_t max_items = 5);
std::vector<RoomSearchResult>
searchRooms(const std::string &query, std::uint8_t max_items = 5);

View file

@ -24,12 +24,6 @@ struct RoomMember
QImage avatar;
};
struct SearchResult
{
QString user_id;
QString display_name;
};
//! Used to uniquely identify a list of read receipts.
struct ReadReceiptKey
{

View file

@ -164,9 +164,6 @@ public:
bool calculateRoomReadStatus(const std::string &room_id);
void calculateRoomReadStatus();
std::vector<SearchResult> searchUsers(const std::string &room_id,
const std::string &query,
std::uint8_t max_items = 5);
std::vector<RoomSearchResult> searchRooms(const std::string &query,
std::uint8_t max_items = 5);

View file

@ -39,7 +39,6 @@
#include "RoomList.h"
#include "SideBarActions.h"
#include "Splitter.h"
#include "TextInputWidget.h"
#include "UserInfoWidget.h"
#include "UserSettingsPage.h"
#include "Utils.h"
@ -138,21 +137,13 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
splitter->addWidget(content_);
splitter->restoreSizes(parent->width());
text_input_ = new TextInputWidget(this);
contentLayout_->addWidget(text_input_);
typingRefresher_ = new QTimer(this);
typingRefresher_->setInterval(TYPING_REFRESH_TIMEOUT);
connect(this, &ChatPage::connectionLost, this, [this]() {
nhlog::net()->info("connectivity lost");
isConnected_ = false;
http::client()->shutdown();
text_input_->disableInput();
});
connect(this, &ChatPage::connectionRestored, this, [this]() {
nhlog::net()->info("trying to re-connect");
text_input_->enableInput();
isConnected_ = true;
// Drop all pending connections.
@ -160,15 +151,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
trySync();
});
connect(text_input_,
&TextInputWidget::clearRoomTimeline,
view_manager_,
&TimelineViewManager::clearCurrentRoomTimeline);
connect(text_input_, &TextInputWidget::rotateMegolmSession, this, [this]() {
cache::dropOutboundMegolmSession(current_room_.toStdString());
});
connect(
new QShortcut(QKeySequence("Ctrl+Down"), this), &QShortcut::activated, this, [this]() {
if (isVisible())
@ -230,9 +212,7 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
connect(room_list_, &RoomList::roomChanged, this, [this](QString room_id) {
this->current_room_ = room_id;
});
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::stopTyping);
connect(room_list_, &RoomList::roomChanged, splitter, &Splitter::showChatView);
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::focusLineEdit);
connect(
room_list_, &RoomList::roomChanged, view_manager_, &TimelineViewManager::setHistoryView);
@ -246,27 +226,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
room_list_->removeRoom(room_id, currentRoom() == room_id);
});
connect(
text_input_, &TextInputWidget::startedTyping, this, &ChatPage::sendTypingNotifications);
connect(typingRefresher_, &QTimer::timeout, this, &ChatPage::sendTypingNotifications);
connect(text_input_, &TextInputWidget::stoppedTyping, this, [this]() {
if (!userSettings_->typingNotifications())
return;
typingRefresher_->stop();
if (current_room_.isEmpty())
return;
http::client()->stop_typing(
current_room_.toStdString(), [](mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn("failed to stop typing notifications: {}",
err->matrix_error.error);
}
});
});
connect(view_manager_,
&TimelineViewManager::updateRoomsLastMessage,
room_list_,
@ -277,197 +236,6 @@ ChatPage::ChatPage(QSharedPointer<UserSettings> userSettings, QWidget *parent)
this,
SIGNAL(unreadMessages(int)));
connect(text_input_,
&TextInputWidget::sendTextMessage,
view_manager_,
&TimelineViewManager::queueTextMessage);
connect(text_input_,
&TextInputWidget::sendEmoteMessage,
view_manager_,
&TimelineViewManager::queueEmoteMessage);
connect(text_input_, &TextInputWidget::sendJoinRoomRequest, this, &ChatPage::joinRoom);
// invites and bans via quick command
connect(text_input_, &TextInputWidget::sendInviteRoomRequest, this, &ChatPage::inviteUser);
connect(text_input_, &TextInputWidget::sendKickRoomRequest, this, &ChatPage::kickUser);
connect(text_input_, &TextInputWidget::sendBanRoomRequest, this, &ChatPage::banUser);
connect(text_input_, &TextInputWidget::sendUnbanRoomRequest, this, &ChatPage::unbanUser);
connect(
text_input_, &TextInputWidget::changeRoomNick, this, [this](const QString &displayName) {
mtx::events::state::Member member;
member.display_name = displayName.toStdString();
member.avatar_url =
cache::avatarUrl(currentRoom(),
QString::fromStdString(http::client()->user_id().to_string()))
.toStdString();
member.membership = mtx::events::state::Membership::Join;
http::client()->send_state_event(
currentRoom().toStdString(),
http::client()->user_id().to_string(),
member,
[](mtx::responses::EventId, mtx::http::RequestErr err) {
if (err)
nhlog::net()->error("Failed to set room displayname: {}",
err->matrix_error.error);
});
});
connect(
text_input_,
&TextInputWidget::uploadMedia,
this,
[this](QSharedPointer<QIODevice> dev, QString mimeClass, const QString &fn) {
if (!dev->open(QIODevice::ReadOnly)) {
emit uploadFailed(
QString("Error while reading media: %1").arg(dev->errorString()));
return;
}
auto bin = dev->readAll();
QMimeDatabase db;
QMimeType mime = db.mimeTypeForData(bin);
auto payload = std::string(bin.data(), bin.size());
std::optional<mtx::crypto::EncryptedFile> encryptedFile;
if (cache::isRoomEncrypted(current_room_.toStdString())) {
mtx::crypto::BinaryBuf buf;
std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
payload = mtx::crypto::to_string(buf);
}
QSize dimensions;
QString blurhash;
if (mimeClass == "image") {
QImage img = utils::readImage(&bin);
dimensions = img.size();
if (img.height() > 200 && img.width() > 360)
img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
std::vector<unsigned char> data;
for (int y = 0; y < img.height(); y++) {
for (int x = 0; x < img.width(); x++) {
auto p = img.pixel(x, y);
data.push_back(static_cast<unsigned char>(qRed(p)));
data.push_back(static_cast<unsigned char>(qGreen(p)));
data.push_back(static_cast<unsigned char>(qBlue(p)));
}
}
blurhash = QString::fromStdString(
blurhash::encode(data.data(), img.width(), img.height(), 4, 3));
}
http::client()->upload(
payload,
encryptedFile ? "application/octet-stream" : mime.name().toStdString(),
QFileInfo(fn).fileName().toStdString(),
[this,
room_id = current_room_,
filename = fn,
encryptedFile,
mimeClass,
mime = mime.name(),
size = payload.size(),
dimensions,
blurhash](const mtx::responses::ContentURI &res, mtx::http::RequestErr err) {
if (err) {
emit uploadFailed(
tr("Failed to upload media. Please try again."));
nhlog::net()->warn("failed to upload media: {} {} ({})",
err->matrix_error.error,
to_string(err->matrix_error.errcode),
static_cast<int>(err->status_code));
return;
}
emit mediaUploaded(room_id,
filename,
encryptedFile,
QString::fromStdString(res.content_uri),
mimeClass,
mime,
size,
dimensions,
blurhash);
});
});
connect(this, &ChatPage::uploadFailed, this, [this](const QString &msg) {
text_input_->hideUploadSpinner();
emit showNotification(msg);
});
connect(this,
&ChatPage::mediaUploaded,
this,
[this](QString roomid,
QString filename,
std::optional<mtx::crypto::EncryptedFile> encryptedFile,
QString url,
QString mimeClass,
QString mime,
qint64 dsize,
QSize dimensions,
QString blurhash) {
text_input_->hideUploadSpinner();
if (encryptedFile)
encryptedFile->url = url.toStdString();
if (mimeClass == "image")
view_manager_->queueImageMessage(roomid,
filename,
encryptedFile,
url,
mime,
dsize,
dimensions,
blurhash);
else if (mimeClass == "audio")
view_manager_->queueAudioMessage(
roomid, filename, encryptedFile, url, mime, dsize);
else if (mimeClass == "video")
view_manager_->queueVideoMessage(
roomid, filename, encryptedFile, url, mime, dsize);
else
view_manager_->queueFileMessage(
roomid, filename, encryptedFile, url, mime, dsize);
});
connect(text_input_, &TextInputWidget::callButtonPress, this, [this]() {
if (callManager_->onActiveCall()) {
callManager_->hangUp();
} else {
if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
roomInfo.member_count != 2) {
showNotification("Calls are limited to 1:1 rooms.");
} else {
std::vector<RoomMember> members(
cache::getMembers(current_room_.toStdString()));
const RoomMember &callee =
members.front().user_id == utils::localUser() ? members.back()
: members.front();
auto dialog = new dialogs::PlaceCall(
callee.user_id,
callee.display_name,
QString::fromStdString(roomInfo.name),
QString::fromStdString(roomInfo.avatar_url),
userSettings_,
MainWindow::instance());
connect(dialog, &dialogs::PlaceCall::voice, this, [this]() {
callManager_->sendInvite(current_room_, false);
});
connect(dialog, &dialogs::PlaceCall::video, this, [this]() {
callManager_->sendInvite(current_room_, true);
});
utils::centerWidget(dialog, MainWindow::instance());
dialog->show();
}
}
});
connect(
this, &ChatPage::updateGroupsInfo, communitiesList_, &CommunitiesList::setCommunities);
@ -635,12 +403,6 @@ ChatPage::resetUI()
emit unreadMessages(0);
}
void
ChatPage::focusMessageInput()
{
this->text_input_->focusLineEdit();
}
void
ChatPage::deleteConfigs()
{
@ -805,7 +567,6 @@ ChatPage::showQuickSwitcher()
connect(dialog, &QuickSwitcher::roomSelected, room_list_, &RoomList::highlightSelectedRoom);
connect(dialog, &QuickSwitcher::closing, this, [this]() {
MainWindow::instance()->hideOverlay();
text_input_->setFocus(Qt::FocusReason::PopupFocusReason);
});
MainWindow::instance()->showTransparentOverlayModal(dialog);
@ -1299,21 +1060,6 @@ ChatPage::receivedSessionKey(const std::string &room_id, const std::string &sess
view_manager_->receivedSessionKey(room_id, session_id);
}
void
ChatPage::sendTypingNotifications()
{
if (!userSettings_->typingNotifications())
return;
http::client()->start_typing(
current_room_.toStdString(), 10'000, [](mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn("failed to send typing notification: {}",
err->matrix_error.error);
}
});
}
QString
ChatPage::status() const
{

View file

@ -46,7 +46,6 @@ class QuickSwitcher;
class RoomList;
class SideBarActions;
class Splitter;
class TextInputWidget;
class TimelineViewManager;
class UserInfoWidget;
class UserSettings;
@ -88,6 +87,8 @@ public:
static ChatPage *instance() { return instance_; }
QSharedPointer<UserSettings> userSettings() { return userSettings_; }
CallManager *callManager() { return callManager_; }
TimelineViewManager *timelineManager() { return view_manager_; }
void deleteConfigs();
CommunitiesList *communitiesList() { return communitiesList_; }
@ -99,7 +100,6 @@ public:
//! Show the room/group list (if it was visible).
void showSideBars();
void initiateLogout();
void focusMessageInput();
QString status() const;
void setStatus(const QString &status);
@ -109,6 +109,7 @@ public:
public slots:
void leaveRoom(const QString &room_id);
void createRoom(const mtx::requests::CreateRoom &req);
void joinRoom(const QString &room);
void inviteUser(QString userid, QString reason);
void kickUser(QString userid, QString reason);
@ -125,17 +126,6 @@ signals:
void highlightedNotifsRetrieved(const mtx::responses::Notifications &,
const QPoint widgetPos);
void uploadFailed(const QString &msg);
void mediaUploaded(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mimeClass,
const QString &mime,
qint64 dsize,
const QSize &dimensions,
const QString &blurhash);
void contentLoaded();
void closing();
void changeWindowTitle(const int);
@ -200,8 +190,6 @@ private slots:
void removeRoom(const QString &room_id);
void dropToLoginPage(const QString &msg);
void joinRoom(const QString &room);
void sendTypingNotifications();
void handleSyncResponse(const mtx::responses::Sync &res);
private:
@ -263,8 +251,6 @@ private:
TimelineViewManager *view_manager_;
SideBarActions *sidebarActions_;
TextInputWidget *text_input_;
QTimer connectivityTimer_;
std::atomic_bool isConnected_;
@ -275,8 +261,6 @@ private:
popups::UserMentions *user_mentions_popup_;
QTimer *typingRefresher_;
// Global user settings.
QSharedPointer<UserSettings> userSettings_;

View file

@ -1,20 +0,0 @@
#pragma once
// Class for showing a limited amount of completions at a time
#include <QSortFilterProxyModel>
class CompletionModel : public QSortFilterProxyModel
{
public:
CompletionModel(QAbstractItemModel *model, QObject *parent = nullptr)
: QSortFilterProxyModel(parent)
{
setSourceModel(model);
}
int rowCount(const QModelIndex &parent) const override
{
auto row_count = QSortFilterProxyModel::rowCount(parent);
return (row_count < 7) ? row_count : 7;
}
};

View file

@ -0,0 +1,15 @@
#pragma once
#include <QAbstractItemModel>
// Interface for completion models
namespace CompletionModel {
// Start at Qt::UserRole * 2 to prevent clashes
enum Roles
{
CompletionRole = Qt::UserRole * 2, // The string to replace the active completion
SearchRole, // String completer uses for search
SearchRole2, // Secondary string completer uses for search
};
}

View file

@ -0,0 +1,133 @@
#include "CompletionProxyModel.h"
#include <QRegularExpression>
#include "CompletionModelRoles.h"
#include "Logging.h"
#include "Utils.h"
CompletionProxyModel::CompletionProxyModel(QAbstractItemModel *model, QObject *parent)
: QAbstractProxyModel(parent)
{
setSourceModel(model);
QRegularExpression splitPoints("\\s+|-");
for (int i = 0; i < sourceModel()->rowCount(); i++) {
if (i < 7)
mapping.push_back(i);
auto string1 = sourceModel()
->data(sourceModel()->index(i, 0), CompletionModel::SearchRole)
.toString()
.toLower();
trie_.insert(string1.toUcs4(), i);
for (const auto &e : string1.split(splitPoints, Qt::SkipEmptyParts)) {
trie_.insert(e.toUcs4(), i);
}
auto string2 = sourceModel()
->data(sourceModel()->index(i, 0), CompletionModel::SearchRole2)
.toString()
.toLower();
if (!string2.isEmpty()) {
trie_.insert(string2.toUcs4(), i);
for (const auto &e : string2.split(splitPoints, Qt::SkipEmptyParts)) {
trie_.insert(e.toUcs4(), i);
}
}
}
connect(
this,
&CompletionProxyModel::newSearchString,
this,
[this](QString s) {
s.remove(":");
s.remove("@");
searchString = s.toLower();
invalidate();
},
Qt::QueuedConnection);
}
void
CompletionProxyModel::invalidate()
{
auto key = searchString.toUcs4();
beginResetModel();
mapping = trie_.search(key, 7);
endResetModel();
std::string temp;
for (auto v : mapping) {
temp += std::to_string(v) + ", ";
}
nhlog::ui()->debug("mapping: {}", temp);
}
QHash<int, QByteArray>
CompletionProxyModel::roleNames() const
{
return this->sourceModel()->roleNames();
}
int
CompletionProxyModel::rowCount(const QModelIndex &) const
{
return (int)mapping.size();
}
QModelIndex
CompletionProxyModel::mapFromSource(const QModelIndex &sourceIndex) const
{
for (int i = 0; i < (int)mapping.size(); i++) {
if (mapping[i] == sourceIndex.row()) {
return index(i, 0);
}
}
return QModelIndex();
}
QModelIndex
CompletionProxyModel::mapToSource(const QModelIndex &proxyIndex) const
{
auto row = proxyIndex.row();
if (row < 0 || row >= (int)mapping.size())
return QModelIndex();
return sourceModel()->index(mapping[row], 0);
}
QModelIndex
CompletionProxyModel::index(int row, int column, const QModelIndex &) const
{
return createIndex(row, column);
}
QModelIndex
CompletionProxyModel::parent(const QModelIndex &) const
{
return QModelIndex{};
}
int
CompletionProxyModel::columnCount(const QModelIndex &) const
{
return sourceModel()->columnCount();
}
QVariant
CompletionProxyModel::completionAt(int i) const
{
if (i >= 0 && i < rowCount())
return data(index(i, 0), CompletionModel::CompletionRole);
else
return {};
}
void
CompletionProxyModel::setSearchString(QString s)
{
emit newSearchString(s);
}

159
src/CompletionProxyModel.h Normal file
View file

@ -0,0 +1,159 @@
#pragma once
// Class for showing a limited amount of completions at a time
#include <QAbstractProxyModel>
template<typename Key, typename Value>
struct trie
{
std::vector<Value> values;
std::map<Key, trie> next;
void insert(const QVector<Key> &keys, const Value &v)
{
auto t = this;
for (const auto k : keys) {
t = &t->next[k];
}
t->values.push_back(v);
}
std::vector<Value> valuesAndSubvalues(size_t limit = -1) const
{
std::vector<Value> ret;
if (limit < 200)
ret.reserve(limit);
for (const auto &v : values) {
if (ret.size() >= limit)
return ret;
else
ret.push_back(v);
}
for (const auto &[k, t] : next) {
(void)k;
if (ret.size() >= limit)
return ret;
else {
auto temp = t.valuesAndSubvalues(limit - ret.size());
for (auto &&v : temp) {
if (ret.size() >= limit)
return ret;
if (std::find(ret.begin(), ret.end(), v) == ret.end()) {
ret.push_back(std::move(v));
}
}
}
}
return ret;
}
std::vector<Value> search(const QVector<Key> &keys, //< TODO(Nico): replace this with a span
size_t limit,
size_t max_distance = 2) const
{
std::vector<Value> ret;
if (!limit)
return ret;
if (keys.isEmpty())
return valuesAndSubvalues(limit);
auto append = [&ret, limit](std::vector<Value> &&in) {
for (auto &&v : in) {
if (ret.size() >= limit)
return;
if (std::find(ret.begin(), ret.end(), v) == ret.end()) {
ret.push_back(std::move(v));
}
}
};
if (auto e = this->next.find(keys[0]); e != this->next.end()) {
append(e->second.search(keys.mid(1), limit, max_distance));
}
if (max_distance && ret.size() < limit) {
max_distance -= 1;
// swap chars case
if (keys.size() >= 2) {
auto t = this;
for (int i = 1; i >= 0; i--) {
if (auto e = t->next.find(keys[i]); e != t->next.end()) {
t = &e->second;
} else {
t = nullptr;
break;
}
}
if (t) {
append(t->search(
keys.mid(2), (limit - ret.size()) * 2, max_distance));
}
}
// delete character case
append(this->search(keys.mid(1), (limit - ret.size()) * 2, max_distance));
// substitute and insert cases
for (const auto &[k, t] : this->next) {
if (k == keys[0] || ret.size() >= limit)
break;
// substitute
append(t.search(keys.mid(1), limit - ret.size(), max_distance));
if (ret.size() >= limit)
break;
// insert
append(t.search(keys, limit - ret.size(), max_distance));
}
}
return ret;
}
};
class CompletionProxyModel : public QAbstractProxyModel
{
Q_OBJECT
public:
CompletionProxyModel(QAbstractItemModel *model, QObject *parent = nullptr);
void invalidate();
QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
int columnCount(const QModelIndex &) const override;
QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override;
QModelIndex mapToSource(const QModelIndex &proxyIndex) const override;
QModelIndex index(int row,
int column,
const QModelIndex &parent = QModelIndex()) const override;
QModelIndex parent(const QModelIndex &) const override;
public slots:
QVariant completionAt(int i) const;
void setSearchString(QString s);
signals:
void newSearchString(QString);
private:
QString searchString;
trie<uint, int> trie_;
std::vector<int> mapping;
};

View file

@ -118,7 +118,7 @@ QuickSwitcher::QuickSwitcher(QWidget *parent)
connect(roomSearch_, &RoomSearchInput::hiding, this, [this]() { popup_.hide(); });
connect(roomSearch_, &QLineEdit::returnPressed, this, [this]() {
reset();
popup_.selectHoveredSuggestion<RoomItem>();
popup_.selectHoveredSuggestion();
});
}

View file

@ -1,796 +0,0 @@
/*
* 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 <QAbstractItemView>
#include <QAbstractTextDocumentLayout>
#include <QBuffer>
#include <QClipboard>
#include <QCompleter>
#include <QFileDialog>
#include <QMimeData>
#include <QMimeDatabase>
#include <QMimeType>
#include <QPainter>
#include <QStyleOption>
#include <QtConcurrent>
#include "Cache.h"
#include "ChatPage.h"
#include "CompletionModel.h"
#include "Logging.h"
#include "TextInputWidget.h"
#include "Utils.h"
#include "emoji/EmojiSearchModel.h"
#include "emoji/Provider.h"
#include "ui/FlatButton.h"
#include "ui/LoadingIndicator.h"
#if defined(Q_OS_MAC)
#include "emoji/MacHelper.h"
#endif
static constexpr size_t INPUT_HISTORY_SIZE = 127;
static constexpr int MAX_TEXTINPUT_HEIGHT = 120;
static constexpr int ButtonHeight = 22;
FilteredTextEdit::FilteredTextEdit(QWidget *parent)
: QTextEdit{parent}
, history_index_{0}
, suggestionsPopup_{parent}
, previewDialog_{parent}
{
setFrameStyle(QFrame::NoFrame);
connect(document()->documentLayout(),
&QAbstractTextDocumentLayout::documentSizeChanged,
this,
&FilteredTextEdit::updateGeometry);
connect(document()->documentLayout(),
&QAbstractTextDocumentLayout::documentSizeChanged,
this,
[this]() { emit heightChanged(document()->size().toSize().height()); });
working_history_.push_back("");
connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged);
setAcceptRichText(false);
completer_ = new QCompleter(this);
completer_->setWidget(this);
auto model = new emoji::EmojiSearchModel(this);
model->sort(0, Qt::AscendingOrder);
completer_->setModel((emoji_completion_model_ = new CompletionModel(model, this)));
completer_->setModelSorting(QCompleter::UnsortedModel);
completer_->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
completer_->popup()->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
connect(completer_,
QOverload<const QModelIndex &>::of(&QCompleter::activated),
[this](auto &index) {
emoji_popup_open_ = false;
auto emoji = index.data(emoji::EmojiModel::Unicode).toString();
insertCompletion(emoji);
});
typingTimer_ = new QTimer(this);
typingTimer_->setInterval(1000);
typingTimer_->setSingleShot(true);
connect(typingTimer_, &QTimer::timeout, this, &FilteredTextEdit::stopTyping);
connect(&previewDialog_,
&dialogs::PreviewUploadOverlay::confirmUpload,
this,
&FilteredTextEdit::uploadData);
connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults);
connect(
&suggestionsPopup_, &SuggestionsPopup::itemSelected, this, [this](const QString &text) {
suggestionsPopup_.hide();
auto cursor = textCursor();
const int end = cursor.position();
cursor.setPosition(atTriggerPosition_, QTextCursor::MoveAnchor);
cursor.setPosition(end, QTextCursor::KeepAnchor);
cursor.removeSelectedText();
cursor.insertText(text);
});
// For cycling through the suggestions by hitting tab.
connect(this,
&FilteredTextEdit::selectNextSuggestion,
&suggestionsPopup_,
&SuggestionsPopup::selectNextSuggestion);
connect(this,
&FilteredTextEdit::selectPreviousSuggestion,
&suggestionsPopup_,
&SuggestionsPopup::selectPreviousSuggestion);
connect(this, &FilteredTextEdit::selectHoveredSuggestion, this, [this]() {
suggestionsPopup_.selectHoveredSuggestion<UserItem>();
});
previewDialog_.hide();
}
void
FilteredTextEdit::insertCompletion(QString completion)
{
// Paint the current word and replace it with 'completion'
auto cur_text = textAfterPosition(trigger_pos_);
auto tc = textCursor();
tc.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, cur_text.length());
tc.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, cur_text.length());
tc.insertText(completion);
setTextCursor(tc);
}
void
FilteredTextEdit::showResults(const std::vector<SearchResult> &results)
{
QPoint pos;
if (isAnchorValid()) {
auto cursor = textCursor();
cursor.setPosition(atTriggerPosition_);
pos = viewport()->mapToGlobal(cursorRect(cursor).topLeft());
} else {
auto rect = cursorRect();
pos = viewport()->mapToGlobal(rect.topLeft());
}
suggestionsPopup_.addUsers(results);
suggestionsPopup_.move(pos.x(), pos.y() - suggestionsPopup_.height() - 10);
suggestionsPopup_.show();
}
void
FilteredTextEdit::keyPressEvent(QKeyEvent *event)
{
const bool isModifier = (event->modifiers() != Qt::NoModifier);
#if defined(Q_OS_MAC)
if (event->modifiers() == (Qt::ControlModifier | Qt::MetaModifier) &&
event->key() == Qt::Key_Space)
MacHelper::showEmojiWindow();
#endif
if (event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_U)
QTextEdit::setText("");
if (!isModifier) {
if (!typingTimer_->isActive())
emit startedTyping();
typingTimer_->start();
}
// calculate the new query
if (textCursor().position() < atTriggerPosition_ || !isAnchorValid()) {
resetAnchor();
closeSuggestions();
}
if (suggestionsPopup_.isVisible()) {
switch (event->key()) {
case Qt::Key_Down:
case Qt::Key_Tab:
emit selectNextSuggestion();
return;
case Qt::Key_Enter:
case Qt::Key_Return:
emit selectHoveredSuggestion();
return;
case Qt::Key_Escape:
closeSuggestions();
return;
case Qt::Key_Up:
case Qt::Key_Backtab: {
emit selectPreviousSuggestion();
return;
}
default:
break;
}
}
if (emoji_popup_open_) {
auto fake_key = (event->key() == Qt::Key_Backtab) ? Qt::Key_Up : Qt::Key_Down;
switch (event->key()) {
case Qt::Key_Backtab:
case Qt::Key_Tab: {
// Simulate up/down arrow press
auto ev = new QKeyEvent(QEvent::KeyPress, fake_key, Qt::NoModifier);
QCoreApplication::postEvent(completer_->popup(), ev);
return;
}
default:
break;
}
}
switch (event->key()) {
case Qt::Key_At:
atTriggerPosition_ = textCursor().position();
anchorType_ = AnchorType::Sigil;
QTextEdit::keyPressEvent(event);
break;
case Qt::Key_Tab: {
auto cursor = textCursor();
const int initialPos = cursor.position();
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
auto word = cursor.selectedText();
const int startOfWord = cursor.position();
// There is a word to complete.
if (initialPos != startOfWord) {
atTriggerPosition_ = startOfWord;
anchorType_ = AnchorType::Tab;
emit showSuggestions(word);
} else {
QTextEdit::keyPressEvent(event);
}
break;
}
case Qt::Key_Colon: {
QTextEdit::keyPressEvent(event);
trigger_pos_ = textCursor().position() - 1;
emoji_completion_model_->setFilterRegExp("");
emoji_popup_open_ = true;
break;
}
case Qt::Key_Return:
case Qt::Key_Enter:
if (emoji_popup_open_) {
if (!completer_->popup()->currentIndex().isValid()) {
// No completion to select, do normal behavior
completer_->popup()->hide();
emoji_popup_open_ = false;
} else {
event->ignore();
return;
}
}
if (!(event->modifiers() & Qt::ShiftModifier)) {
stopTyping();
submit();
} else {
QTextEdit::keyPressEvent(event);
}
break;
case Qt::Key_Up: {
auto initial_cursor = textCursor();
QTextEdit::keyPressEvent(event);
if (textCursor() == initial_cursor && textCursor().atStart() &&
history_index_ + 1 < working_history_.size()) {
++history_index_;
setPlainText(working_history_[history_index_]);
moveCursor(QTextCursor::End);
} else if (textCursor() == initial_cursor) {
// Move to the start of the text if there aren't any lines to move up to.
initial_cursor.movePosition(QTextCursor::Start, QTextCursor::MoveAnchor, 1);
setTextCursor(initial_cursor);
}
break;
}
case Qt::Key_Down: {
auto initial_cursor = textCursor();
QTextEdit::keyPressEvent(event);
if (textCursor() == initial_cursor && textCursor().atEnd() && history_index_ > 0) {
--history_index_;
setPlainText(working_history_[history_index_]);
moveCursor(QTextCursor::End);
} else if (textCursor() == initial_cursor) {
// Move to the end of the text if there aren't any lines to move down to.
initial_cursor.movePosition(QTextCursor::End, QTextCursor::MoveAnchor, 1);
setTextCursor(initial_cursor);
}
break;
}
default:
QTextEdit::keyPressEvent(event);
if (isModifier)
return;
if (emoji_popup_open_ && textAfterPosition(trigger_pos_).length() > 2) {
// Update completion
emoji_completion_model_->setFilterRegExp(textAfterPosition(trigger_pos_));
completer_->complete(completerRect());
}
if (emoji_popup_open_ && (completer_->completionCount() < 1 ||
!textAfterPosition(trigger_pos_)
.contains(QRegularExpression(":[^\r\n\t\f\v :]+$")))) {
// No completions for this word or another word than the completer was
// started with
emoji_popup_open_ = false;
completer_->popup()->hide();
}
if (textCursor().position() == 0) {
resetAnchor();
closeSuggestions();
return;
}
// Check if the current word should be autocompleted.
auto cursor = textCursor();
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
auto word = cursor.selectedText();
if (hasAnchor(cursor.position(), anchorType_) && isAnchorValid()) {
if (word.isEmpty()) {
closeSuggestions();
return;
}
emit showSuggestions(word);
} else {
resetAnchor();
closeSuggestions();
}
break;
}
}
bool
FilteredTextEdit::canInsertFromMimeData(const QMimeData *source) const
{
return (source->hasImage() || QTextEdit::canInsertFromMimeData(source));
}
void
FilteredTextEdit::insertFromMimeData(const QMimeData *source)
{
qInfo() << "Got mime formats: \n" << source->formats();
const auto formats = source->formats().filter("/");
const auto image = formats.filter("image/", Qt::CaseInsensitive);
const auto audio = formats.filter("audio/", Qt::CaseInsensitive);
const auto video = formats.filter("video/", Qt::CaseInsensitive);
if (!image.empty() && source->hasImage()) {
QImage img = qvariant_cast<QImage>(source->imageData());
previewDialog_.setPreview(img, image.front());
} else if (!audio.empty()) {
showPreview(source, audio);
} else if (!video.empty()) {
showPreview(source, video);
} else if (source->hasUrls()) {
// Generic file path for any platform.
QString path;
for (auto &&u : source->urls()) {
if (u.isLocalFile()) {
path = u.toLocalFile();
break;
}
}
if (!path.isEmpty() && QFileInfo{path}.exists()) {
previewDialog_.setPreview(path);
} else {
qWarning()
<< "Clipboard does not contain any valid file paths:" << source->urls();
}
} else if (source->hasFormat("x-special/gnome-copied-files")) {
// Special case for X11 users. See "Notes for X11 Users" in source.
// Source: http://doc.qt.io/qt-5/qclipboard.html
// This MIME type returns a string with multiple lines separated by '\n'. The first
// line is the command to perform with the clipboard (not useful to us). The
// following lines are the file URIs.
//
// Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function
// nautilus_clipboard_get_uri_list_from_selection_data()
// https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c
auto data = source->data("x-special/gnome-copied-files").split('\n');
if (data.size() < 2) {
qWarning() << "MIME format is malformed, cannot perform paste.";
return;
}
QString path;
for (int i = 1; i < data.size(); ++i) {
QUrl url{data[i]};
if (url.isLocalFile()) {
path = url.toLocalFile();
break;
}
}
if (!path.isEmpty()) {
previewDialog_.setPreview(path);
} else {
qWarning() << "Clipboard does not contain any valid file paths:" << data;
}
} else {
QTextEdit::insertFromMimeData(source);
}
}
void
FilteredTextEdit::stopTyping()
{
typingTimer_->stop();
emit stoppedTyping();
}
QRect
FilteredTextEdit::completerRect()
{
// Move left edge to the beginning of the word
auto cursor = textCursor();
auto rect = cursorRect();
cursor.movePosition(
QTextCursor::Left, QTextCursor::MoveAnchor, textAfterPosition(trigger_pos_).length());
auto cursor_global_x = viewport()->mapToGlobal(cursorRect(cursor).topLeft()).x();
auto rect_global_left = viewport()->mapToGlobal(rect.bottomLeft()).x();
auto dx = qAbs(rect_global_left - cursor_global_x);
rect.moveLeft(rect.left() - dx);
auto item_height = completer_->popup()->sizeHintForRow(0);
auto max_height = item_height * completer_->maxVisibleItems();
auto height = (completer_->completionCount() > completer_->maxVisibleItems())
? max_height
: completer_->completionCount() * item_height;
rect.setWidth(completer_->popup()->sizeHintForColumn(0));
rect.moveBottom(-height);
return rect;
}
QSize
FilteredTextEdit::sizeHint() const
{
ensurePolished();
auto margins = viewportMargins();
margins += document()->documentMargin();
QSize size = document()->size().toSize();
size.rwidth() += margins.left() + margins.right();
size.rheight() += margins.top() + margins.bottom();
return size;
}
QSize
FilteredTextEdit::minimumSizeHint() const
{
ensurePolished();
auto margins = viewportMargins();
margins += document()->documentMargin();
margins += contentsMargins();
QSize size(fontMetrics().averageCharWidth() * 10,
fontMetrics().lineSpacing() + margins.top() + margins.bottom());
return size;
}
void
FilteredTextEdit::submit()
{
if (toPlainText().trimmed().isEmpty())
return;
if (true_history_.size() == INPUT_HISTORY_SIZE)
true_history_.pop_back();
true_history_.push_front(toPlainText());
working_history_ = true_history_;
working_history_.push_front("");
history_index_ = 0;
QString text = toPlainText();
if (text.startsWith('/')) {
int command_end = text.indexOf(' ');
if (command_end == -1)
command_end = text.size();
auto name = text.mid(1, command_end - 1);
auto args = text.mid(command_end + 1);
if (name.isEmpty() || name == "/") {
message(args);
} else {
command(name, args);
}
} else {
message(std::move(text));
}
clear();
}
void
FilteredTextEdit::textChanged()
{
working_history_[history_index_] = toPlainText();
}
void
FilteredTextEdit::uploadData(const QByteArray data,
const QString &mediaType,
const QString &filename)
{
QSharedPointer<QBuffer> buffer{new QBuffer{this}};
buffer->setData(data);
emit startedUpload();
emit media(buffer, mediaType, filename);
}
void
FilteredTextEdit::showPreview(const QMimeData *source, const QStringList &formats)
{
// Retrieve data as MIME type.
auto const &mime = formats.first();
QByteArray data = source->data(mime);
previewDialog_.setPreview(data, mime);
}
TextInputWidget::TextInputWidget(QWidget *parent)
: QWidget(parent)
{
QFont f;
f.setPointSizeF(f.pointSizeF());
const int fontHeight = QFontMetrics(f).height();
const int contentHeight = static_cast<int>(fontHeight * 2.5);
const int InputHeight = static_cast<int>(fontHeight * 1.5);
setFixedHeight(contentHeight);
setCursor(Qt::ArrowCursor);
topLayout_ = new QHBoxLayout();
topLayout_->setSpacing(0);
topLayout_->setContentsMargins(13, 1, 13, 0);
#ifdef GSTREAMER_AVAILABLE
callBtn_ = new FlatButton(this);
changeCallButtonState(webrtc::State::DISCONNECTED);
connect(&WebRTCSession::instance(),
&WebRTCSession::stateChanged,
this,
&TextInputWidget::changeCallButtonState);
#endif
QIcon send_file_icon;
send_file_icon.addFile(":/icons/icons/ui/paper-clip-outline.png");
sendFileBtn_ = new FlatButton(this);
sendFileBtn_->setToolTip(tr("Send a file"));
sendFileBtn_->setIcon(send_file_icon);
sendFileBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
spinner_ = new LoadingIndicator(this);
spinner_->setFixedHeight(InputHeight);
spinner_->setFixedWidth(InputHeight);
spinner_->setObjectName("FileUploadSpinner");
spinner_->hide();
input_ = new FilteredTextEdit(this);
input_->setFixedHeight(InputHeight);
input_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
input_->setPlaceholderText(tr("Write a message..."));
connect(input_,
&FilteredTextEdit::heightChanged,
this,
[this, InputHeight, contentHeight](int height) {
int widgetHeight =
std::min(MAX_TEXTINPUT_HEIGHT, std::max(height, contentHeight));
int textInputHeight =
std::min(widgetHeight - 1, std::max(height, InputHeight));
setFixedHeight(widgetHeight);
input_->setFixedHeight(textInputHeight);
emit heightChanged(widgetHeight);
});
connect(input_, &FilteredTextEdit::showSuggestions, this, [this](const QString &q) {
if (q.isEmpty())
return;
QtConcurrent::run([this, q = q.toLower().toStdString()]() {
try {
emit input_->resultsRetrieved(cache::searchUsers(
ChatPage::instance()->currentRoom().toStdString(), q));
} catch (const lmdb::error &e) {
nhlog::db()->error("Suggestion retrieval failed: {}", e.what());
}
});
});
sendMessageBtn_ = new FlatButton(this);
sendMessageBtn_->setToolTip(tr("Send a message"));
QIcon send_message_icon;
send_message_icon.addFile(":/icons/icons/ui/cursor.png");
sendMessageBtn_->setIcon(send_message_icon);
sendMessageBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
emojiBtn_ = new emoji::PickButton(this);
emojiBtn_->setToolTip(tr("Emoji"));
#if defined(Q_OS_MAC)
// macOS has a native emoji picker.
emojiBtn_->hide();
#endif
QIcon emoji_icon;
emoji_icon.addFile(":/icons/icons/ui/smile.png");
emojiBtn_->setIcon(emoji_icon);
emojiBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight));
#ifdef GSTREAMER_AVAILABLE
topLayout_->addWidget(callBtn_);
#endif
topLayout_->addWidget(sendFileBtn_);
topLayout_->addWidget(input_);
topLayout_->addWidget(emojiBtn_);
topLayout_->addWidget(sendMessageBtn_);
setLayout(topLayout_);
#ifdef GSTREAMER_AVAILABLE
connect(callBtn_, &FlatButton::clicked, this, &TextInputWidget::callButtonPress);
#endif
connect(sendMessageBtn_, &FlatButton::clicked, input_, &FilteredTextEdit::submit);
connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection()));
connect(input_, &FilteredTextEdit::message, this, &TextInputWidget::sendTextMessage);
connect(input_, &FilteredTextEdit::command, this, &TextInputWidget::command);
connect(input_, &FilteredTextEdit::media, this, &TextInputWidget::uploadMedia);
connect(emojiBtn_,
SIGNAL(emojiSelected(const QString &)),
this,
SLOT(addSelectedEmoji(const QString &)));
connect(input_, &FilteredTextEdit::startedTyping, this, &TextInputWidget::startedTyping);
connect(input_, &FilteredTextEdit::stoppedTyping, this, &TextInputWidget::stoppedTyping);
connect(
input_, &FilteredTextEdit::startedUpload, this, &TextInputWidget::showUploadSpinner);
}
void
TextInputWidget::addSelectedEmoji(const QString &emoji)
{
QTextCursor cursor = input_->textCursor();
QTextCharFormat charfmt;
input_->setCurrentCharFormat(charfmt);
input_->insertPlainText(emoji);
cursor.movePosition(QTextCursor::End);
input_->setCurrentCharFormat(charfmt);
input_->show();
}
void
TextInputWidget::command(QString command, QString args)
{
if (command == "me") {
emit sendEmoteMessage(args);
} else if (command == "join") {
emit sendJoinRoomRequest(args);
} else if (command == "invite") {
emit sendInviteRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "kick") {
emit sendKickRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "ban") {
emit sendBanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "unban") {
emit sendUnbanRoomRequest(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "roomnick") {
emit changeRoomNick(args);
} else if (command == "shrug") {
emit sendTextMessage("¯\\_(ツ)_/¯" + (args.isEmpty() ? "" : " " + args));
} else if (command == "fliptable") {
emit sendTextMessage("(╯°□°) ");
} else if (command == "unfliptable") {
emit sendTextMessage(" ┯━┯╭( º _ º╭)");
} else if (command == "sovietflip") {
emit sendTextMessage("ノ┬─┬ノ ︵ ( \\o°o)\\");
} else if (command == "clear-timeline") {
emit clearRoomTimeline();
} else if (command == "rotate-megolm-session") {
emit rotateMegolmSession();
}
}
void
TextInputWidget::openFileSelection()
{
const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
const auto fileName =
QFileDialog::getOpenFileName(this, tr("Select a file"), homeFolder, tr("All Files (*)"));
if (fileName.isEmpty())
return;
QMimeDatabase db;
QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
const auto format = mime.name().split("/")[0];
QSharedPointer<QFile> file{new QFile{fileName, this}};
emit uploadMedia(file, format, QFileInfo(fileName).fileName());
showUploadSpinner();
}
void
TextInputWidget::showUploadSpinner()
{
topLayout_->removeWidget(sendFileBtn_);
sendFileBtn_->hide();
topLayout_->insertWidget(1, spinner_);
spinner_->start();
}
void
TextInputWidget::hideUploadSpinner()
{
topLayout_->removeWidget(spinner_);
topLayout_->insertWidget(1, sendFileBtn_);
sendFileBtn_->show();
spinner_->stop();
}
void
TextInputWidget::stopTyping()
{
input_->stopTyping();
}
void
TextInputWidget::focusInEvent(QFocusEvent *event)
{
input_->setFocus(event->reason());
}
void
TextInputWidget::paintEvent(QPaintEvent *)
{
QStyleOption opt;
opt.init(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}
void
TextInputWidget::changeCallButtonState(webrtc::State state)
{
QIcon icon;
if (state == webrtc::State::ICEFAILED || state == webrtc::State::DISCONNECTED) {
callBtn_->setToolTip(tr("Place a call"));
icon.addFile(":/icons/icons/ui/place-call.png");
} else {
callBtn_->setToolTip(tr("Hang up"));
icon.addFile(":/icons/icons/ui/end-call.png");
}
callBtn_->setIcon(icon);
callBtn_->setIconSize(QSize(ButtonHeight * 1.1, ButtonHeight * 1.1));
}

View file

@ -1,213 +0,0 @@
/*
* 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 <deque>
#include <optional>
#include <QCoreApplication>
#include <QHBoxLayout>
#include <QPaintEvent>
#include <QTextEdit>
#include <QWidget>
#include "WebRTCSession.h"
#include "dialogs/PreviewUploadOverlay.h"
#include "emoji/PickButton.h"
#include "popups/SuggestionsPopup.h"
struct SearchResult;
class CompletionModel;
class FlatButton;
class LoadingIndicator;
class QCompleter;
class FilteredTextEdit : public QTextEdit
{
Q_OBJECT
public:
explicit FilteredTextEdit(QWidget *parent = nullptr);
void stopTyping();
QSize sizeHint() const override;
QSize minimumSizeHint() const override;
void submit();
signals:
void heightChanged(int height);
void startedTyping();
void stoppedTyping();
void startedUpload();
void message(QString msg);
void command(QString name, QString args);
void media(QSharedPointer<QIODevice> data, QString mimeClass, const QString &filename);
//! Trigger the suggestion popup.
void showSuggestions(const QString &query);
void resultsRetrieved(const std::vector<SearchResult> &results);
void selectNextSuggestion();
void selectPreviousSuggestion();
void selectHoveredSuggestion();
public slots:
void showResults(const std::vector<SearchResult> &results);
protected:
void keyPressEvent(QKeyEvent *event) override;
bool canInsertFromMimeData(const QMimeData *source) const override;
void insertFromMimeData(const QMimeData *source) override;
void focusOutEvent(QFocusEvent *event) override
{
suggestionsPopup_.hide();
QTextEdit::focusOutEvent(event);
}
private:
bool emoji_popup_open_ = false;
CompletionModel *emoji_completion_model_;
std::deque<QString> true_history_, working_history_;
int trigger_pos_; // Where emoji completer was triggered
size_t history_index_;
QCompleter *completer_;
QTimer *typingTimer_;
SuggestionsPopup suggestionsPopup_;
enum class AnchorType
{
Tab = 0,
Sigil = 1,
};
AnchorType anchorType_ = AnchorType::Sigil;
int anchorWidth(AnchorType anchor) { return static_cast<int>(anchor); }
void closeSuggestions() { suggestionsPopup_.hide(); }
void resetAnchor() { atTriggerPosition_ = -1; }
bool isAnchorValid() { return atTriggerPosition_ != -1; }
bool hasAnchor(int pos, AnchorType anchor)
{
return pos == atTriggerPosition_ + anchorWidth(anchor);
}
QRect completerRect();
QString query()
{
auto cursor = textCursor();
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
return cursor.selectedText();
}
QString textAfterPosition(int pos)
{
auto tc = textCursor();
tc.setPosition(pos);
tc.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor);
return tc.selectedText();
}
dialogs::PreviewUploadOverlay previewDialog_;
//! Latest position of the '@' character that triggers the username completer.
int atTriggerPosition_ = -1;
void insertCompletion(QString completion);
void textChanged();
void uploadData(const QByteArray data, const QString &media, const QString &filename);
void afterCompletion(int);
void showPreview(const QMimeData *source, const QStringList &formats);
};
class TextInputWidget : public QWidget
{
Q_OBJECT
Q_PROPERTY(QColor borderColor READ borderColor WRITE setBorderColor)
public:
TextInputWidget(QWidget *parent = nullptr);
void stopTyping();
QColor borderColor() const { return borderColor_; }
void setBorderColor(QColor &color) { borderColor_ = color; }
void disableInput()
{
input_->setEnabled(false);
input_->setPlaceholderText(tr("Connection lost. Nheko is trying to re-connect..."));
}
void enableInput()
{
input_->setEnabled(true);
input_->setPlaceholderText(tr("Write a message..."));
}
public slots:
void openFileSelection();
void hideUploadSpinner();
void focusLineEdit() { input_->setFocus(); }
void changeCallButtonState(webrtc::State);
private slots:
void addSelectedEmoji(const QString &emoji);
signals:
void sendTextMessage(const QString &msg);
void sendEmoteMessage(QString msg);
void clearRoomTimeline();
void heightChanged(int height);
void uploadMedia(const QSharedPointer<QIODevice> data,
QString mimeClass,
const QString &filename);
void callButtonPress();
void sendJoinRoomRequest(const QString &room);
void sendInviteRoomRequest(const QString &userid, const QString &reason);
void sendKickRoomRequest(const QString &userid, const QString &reason);
void sendBanRoomRequest(const QString &userid, const QString &reason);
void sendUnbanRoomRequest(const QString &userid, const QString &reason);
void changeRoomNick(const QString &displayname);
void rotateMegolmSession();
void startedTyping();
void stoppedTyping();
protected:
void focusInEvent(QFocusEvent *event) override;
void paintEvent(QPaintEvent *) override;
private:
void showUploadSpinner();
void command(QString name, QString args);
QHBoxLayout *topLayout_;
FilteredTextEdit *input_;
LoadingIndicator *spinner_;
FlatButton *callBtn_;
FlatButton *sendFileBtn_;
FlatButton *sendMessageBtn_;
emoji::PickButton *emojiBtn_;
QColor borderColor_;
};

53
src/UsersModel.cpp Normal file
View file

@ -0,0 +1,53 @@
#include "UsersModel.h"
#include "Cache.h"
#include "CompletionModelRoles.h"
UsersModel::UsersModel(const std::string &roomId, QObject *parent)
: QAbstractListModel(parent)
, room_id(roomId)
{
roomMembers_ = cache::roomMembers(roomId);
for (const auto &m : roomMembers_) {
displayNames.push_back(QString::fromStdString(cache::displayName(room_id, m)));
userids.push_back(QString::fromStdString(m));
}
}
QHash<int, QByteArray>
UsersModel::roleNames() const
{
return {
{CompletionModel::CompletionRole, "completionRole"},
{CompletionModel::SearchRole, "searchRole"},
{CompletionModel::SearchRole2, "searchRole2"},
{Roles::DisplayName, "displayName"},
{Roles::AvatarUrl, "avatarUrl"},
{Roles::UserID, "userid"},
};
}
QVariant
UsersModel::data(const QModelIndex &index, int role) const
{
if (hasIndex(index.row(), index.column(), index.parent())) {
switch (role) {
case CompletionModel::CompletionRole:
return QString("[%1](https://matrix.to/#/%2)")
.arg(displayNames[index.row()])
.arg(userids[index.row()]);
case CompletionModel::SearchRole:
case Qt::DisplayRole:
case Roles::DisplayName:
return displayNames[index.row()];
case CompletionModel::SearchRole2:
return userids[index.row()];
case Roles::AvatarUrl:
return cache::avatarUrl(QString::fromStdString(room_id),
QString::fromStdString(roomMembers_[index.row()]));
case Roles::UserID:
return userids[index.row()];
}
}
return {};
}

29
src/UsersModel.h Normal file
View file

@ -0,0 +1,29 @@
#pragma once
#include <QAbstractListModel>
class UsersModel : public QAbstractListModel
{
public:
enum Roles
{
AvatarUrl = Qt::UserRole,
DisplayName,
UserID,
};
UsersModel(const std::string &roomId, QObject *parent = nullptr);
QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent = QModelIndex()) const override
{
(void)parent;
return roomMembers_.size();
}
QVariant data(const QModelIndex &index, int role) const override;
private:
std::string room_id;
std::vector<std::string> roomMembers_;
std::vector<QString> displayNames;
std::vector<QString> userids;
};

View file

@ -677,9 +677,10 @@ utils::restoreCombobox(QComboBox *combo, const QString &value)
}
QImage
utils::readImage(QByteArray *data)
utils::readImage(const QByteArray *data)
{
QBuffer buf(data);
QBuffer buf;
buf.setData(*data);
QImageReader reader(&buf);
reader.setAutoTransform(true);
return reader.read();

View file

@ -303,5 +303,5 @@ restoreCombobox(QComboBox *combo, const QString &value);
//! Read image respecting exif orientation
QImage
readImage(QByteArray *data);
readImage(const QByteArray *data);
}

View file

@ -60,7 +60,10 @@ PreviewUploadOverlay::PreviewUploadOverlay(QWidget *parent)
emit confirmUpload(data_, mediaType_, fileName_.text());
close();
});
connect(&cancel_, &QPushButton::clicked, this, &PreviewUploadOverlay::close);
connect(&cancel_, &QPushButton::clicked, this, [this]() {
emit aborted();
close();
});
}
void
@ -115,7 +118,7 @@ PreviewUploadOverlay::init()
void
PreviewUploadOverlay::setLabels(const QString &type, const QString &mime, uint64_t upload_size)
{
if (mediaType_ == "image") {
if (mediaType_.split('/')[0] == "image") {
if (!image_.loadFromData(data_)) {
titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
} else {
@ -151,7 +154,7 @@ PreviewUploadOverlay::setPreview(const QImage &src, const QString &mime)
else
titleLabel_.setText(QString{tr(ERR_MSG)}.arg(type));
mediaType_ = split[0];
mediaType_ = mime;
filePath_ = "clipboard." + type;
image_.convertFromImage(src);
isImage_ = true;
@ -167,7 +170,7 @@ PreviewUploadOverlay::setPreview(const QByteArray data, const QString &mime)
auto const &type = split[1];
data_ = data;
mediaType_ = split[0];
mediaType_ = mime;
filePath_ = "clipboard." + type;
isImage_ = false;
@ -199,7 +202,7 @@ PreviewUploadOverlay::setPreview(const QString &path)
auto const &split = mime.name().split('/');
mediaType_ = split[0];
mediaType_ = mime.name();
filePath_ = file.fileName();
isImage_ = false;

View file

@ -40,6 +40,7 @@ public:
signals:
void confirmUpload(const QByteArray data, const QString &media, const QString &filename);
void aborted();
private:
void init();

View file

@ -1,95 +0,0 @@
/*
* 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 <QLabel>
#include <QListView>
#include <QPainter>
#include <QScrollBar>
#include <QStyleOption>
#include <QVBoxLayout>
#include "Config.h"
#include "emoji/Category.h"
using namespace emoji;
Category::Category(QString category, std::vector<Emoji> emoji, QWidget *parent)
: QWidget(parent)
{
mainLayout_ = new QVBoxLayout(this);
mainLayout_->setMargin(0);
mainLayout_->setSpacing(0);
emojiListView_ = new QListView();
itemModel_ = new QStandardItemModel(this);
delegate_ = new ItemDelegate(this);
data_ = new Emoji;
emojiListView_->setItemDelegate(delegate_);
emojiListView_->setModel(itemModel_);
emojiListView_->setViewMode(QListView::IconMode);
emojiListView_->setFlow(QListView::LeftToRight);
emojiListView_->setResizeMode(QListView::Adjust);
emojiListView_->setMouseTracking(true);
emojiListView_->verticalScrollBar()->setEnabled(false);
emojiListView_->horizontalScrollBar()->setEnabled(false);
const int cols = 7;
const int rows = emoji.size() / 7 + 1;
const int emojiSize = 48;
const int gridSize = emojiSize + 4;
// TODO: Be precise here. Take the parent into consideration.
emojiListView_->setFixedSize(cols * gridSize + 20, rows * gridSize);
emojiListView_->setGridSize(QSize(gridSize, gridSize));
emojiListView_->setDragEnabled(false);
emojiListView_->setEditTriggers(QAbstractItemView::NoEditTriggers);
for (const auto &e : emoji) {
data_->unicode = e.unicode;
auto item = new QStandardItem;
item->setSizeHint(QSize(emojiSize, emojiSize));
QVariant unicode(data_->unicode);
item->setData(unicode.toString(), Qt::UserRole);
itemModel_->appendRow(item);
}
QFont font;
font.setWeight(QFont::Medium);
category_ = new QLabel(category, this);
category_->setFont(font);
mainLayout_->addWidget(category_);
mainLayout_->addWidget(emojiListView_);
connect(emojiListView_, &QListView::clicked, this, &Category::clickIndex);
}
void
Category::paintEvent(QPaintEvent *)
{
QStyleOption opt;
opt.init(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

View file

@ -1,67 +0,0 @@
/*
* 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 <QColor>
#include "ItemDelegate.h"
class QLabel;
class QListView;
class QStandardItemModel;
class QVBoxLayout;
namespace emoji {
class Category : public QWidget
{
Q_OBJECT
Q_PROPERTY(
QColor hoverBackgroundColor READ hoverBackgroundColor WRITE setHoverBackgroundColor)
public:
Category(QString category, std::vector<Emoji> emoji, QWidget *parent = nullptr);
QColor hoverBackgroundColor() const { return hoverBackgroundColor_; }
void setHoverBackgroundColor(QColor color) { hoverBackgroundColor_ = color; }
signals:
void emojiSelected(const QString &emoji);
protected:
void paintEvent(QPaintEvent *event) override;
private slots:
void clickIndex(const QModelIndex &index)
{
emit emojiSelected(index.data(Qt::UserRole).toString());
};
private:
QVBoxLayout *mainLayout_;
QStandardItemModel *itemModel_;
QListView *emojiListView_;
emoji::Emoji *data_;
emoji::ItemDelegate *delegate_;
QLabel *category_;
QColor hoverBackgroundColor_;
};
} // namespace emoji

View file

@ -3,6 +3,8 @@
#include <Cache.h>
#include <MatrixClient.h>
#include "CompletionModelRoles.h"
using namespace emoji;
QHash<int, QByteArray>
@ -35,10 +37,12 @@ EmojiModel::data(const QModelIndex &index, int role) const
if (hasIndex(index.row(), index.column(), index.parent())) {
switch (role) {
case Qt::DisplayRole:
case CompletionModel::CompletionRole:
case static_cast<int>(EmojiModel::Roles::Unicode):
return Provider::emoji[index.row()].unicode;
case Qt::ToolTipRole:
case CompletionModel::SearchRole:
case static_cast<int>(EmojiModel::Roles::ShortName):
return Provider::emoji[index.row()].shortName;

View file

@ -2,6 +2,7 @@
#include "EmojiModel.h"
#include <CompletionModelRoles.h>
#include <QDebug>
#include <QEvent>
#include <QSortFilterProxyModel>
@ -19,12 +20,21 @@ public:
}
QVariant data(const QModelIndex &index, int role = Qt::UserRole + 1) const override
{
if (role == Qt::DisplayRole) {
switch (role) {
case Qt::DisplayRole: {
auto emoji = QSortFilterProxyModel::data(index, role).toString();
return emoji + " :" +
toShortcode(data(index, EmojiModel::ShortName).toString()) + ":";
}
return QSortFilterProxyModel::data(index, role);
case CompletionModel::CompletionRole:
return QSortFilterProxyModel::data(index, EmojiModel::Unicode);
case CompletionModel::SearchRole: {
return toShortcode(
QSortFilterProxyModel::data(index, EmojiModel::ShortName).toString());
}
default:
return QSortFilterProxyModel::data(index, role);
}
}
private:

View file

@ -1,66 +0,0 @@
/*
* 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 <QPainter>
#include <QSettings>
#include "emoji/ItemDelegate.h"
using namespace emoji;
ItemDelegate::ItemDelegate(QObject *parent)
: QStyledItemDelegate(parent)
{
data_ = new Emoji;
}
ItemDelegate::~ItemDelegate() { delete data_; }
void
ItemDelegate::paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const
{
Q_UNUSED(index);
painter->save();
QStyleOptionViewItem viewOption(option);
auto emoji = index.data(Qt::UserRole).toString();
QSettings settings;
QFont font;
QString userFontFamily = settings.value("user/emoji_font_family", "emoji").toString();
if (!userFontFamily.isEmpty()) {
font.setFamily(userFontFamily);
} else {
font.setFamily("emoji");
}
font.setPixelSize(36);
painter->setFont(font);
if (option.state & QStyle::State_MouseOver) {
painter->setBackgroundMode(Qt::OpaqueMode);
QColor hoverColor = parent()->property("hoverBackgroundColor").value<QColor>();
painter->setBackground(hoverColor);
}
painter->drawText(viewOption.rect, Qt::AlignCenter, emoji);
painter->restore();
}

View file

@ -1,43 +0,0 @@
/*
* 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 <QModelIndex>
#include <QStandardItemModel>
#include <QStyledItemDelegate>
#include "Provider.h"
namespace emoji {
class ItemDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
explicit ItemDelegate(QObject *parent = nullptr);
~ItemDelegate() override;
void paint(QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index) const override;
private:
Emoji *data_;
};
} // namespace emoji

View file

@ -1,231 +0,0 @@
/*
* 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 <QPainter>
#include <QPushButton>
#include <QScrollBar>
#include <QVBoxLayout>
#include "ui/DropShadow.h"
#include "ui/FlatButton.h"
#include "emoji/Category.h"
#include "emoji/Panel.h"
using namespace emoji;
Panel::Panel(QWidget *parent)
: QWidget(parent)
, shadowMargin_{2}
, width_{370}
, height_{350}
, categoryIconSize_{20}
{
setAttribute(Qt::WA_ShowWithoutActivating, true);
setWindowFlags(Qt::Tool | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint);
auto mainWidget = new QWidget(this);
mainWidget->setMaximumSize(width_, height_);
auto topLayout = new QVBoxLayout(this);
topLayout->addWidget(mainWidget);
topLayout->setMargin(shadowMargin_);
topLayout->setSpacing(0);
auto contentLayout = new QVBoxLayout(mainWidget);
contentLayout->setMargin(0);
contentLayout->setSpacing(0);
auto emojiCategories = new QFrame(mainWidget);
auto categoriesLayout = new QHBoxLayout(emojiCategories);
categoriesLayout->setSpacing(0);
categoriesLayout->setMargin(0);
QIcon icon;
auto peopleCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/people.png");
peopleCategory->setIcon(icon);
peopleCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto natureCategory_ = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/nature.png");
natureCategory_->setIcon(icon);
natureCategory_->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto foodCategory_ = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/foods.png");
foodCategory_->setIcon(icon);
foodCategory_->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto activityCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/activity.png");
activityCategory->setIcon(icon);
activityCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto travelCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/travel.png");
travelCategory->setIcon(icon);
travelCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto objectsCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/objects.png");
objectsCategory->setIcon(icon);
objectsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto symbolsCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/symbols.png");
symbolsCategory->setIcon(icon);
symbolsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
auto flagsCategory = new FlatButton(emojiCategories);
icon.addFile(":/icons/icons/emoji-categories/flags.png");
flagsCategory->setIcon(icon);
flagsCategory->setIconSize(QSize(categoryIconSize_, categoryIconSize_));
categoriesLayout->addWidget(peopleCategory);
categoriesLayout->addWidget(natureCategory_);
categoriesLayout->addWidget(foodCategory_);
categoriesLayout->addWidget(activityCategory);
categoriesLayout->addWidget(travelCategory);
categoriesLayout->addWidget(objectsCategory);
categoriesLayout->addWidget(symbolsCategory);
categoriesLayout->addWidget(flagsCategory);
scrollArea_ = new QScrollArea(this);
scrollArea_->setWidgetResizable(true);
scrollArea_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
auto scrollWidget = new QWidget(this);
auto scrollLayout = new QVBoxLayout(scrollWidget);
scrollLayout->setMargin(0);
scrollLayout->setSpacing(0);
scrollArea_->setWidget(scrollWidget);
auto peopleEmoji =
new Category(tr("Smileys & People"), emoji_provider_.people, scrollWidget);
scrollLayout->addWidget(peopleEmoji);
auto natureEmoji =
new Category(tr("Animals & Nature"), emoji_provider_.nature, scrollWidget);
scrollLayout->addWidget(natureEmoji);
auto foodEmoji = new Category(tr("Food & Drink"), emoji_provider_.food, scrollWidget);
scrollLayout->addWidget(foodEmoji);
auto activityEmoji = new Category(tr("Activity"), emoji_provider_.activity, scrollWidget);
scrollLayout->addWidget(activityEmoji);
auto travelEmoji =
new Category(tr("Travel & Places"), emoji_provider_.travel, scrollWidget);
scrollLayout->addWidget(travelEmoji);
auto objectsEmoji = new Category(tr("Objects"), emoji_provider_.objects, scrollWidget);
scrollLayout->addWidget(objectsEmoji);
auto symbolsEmoji = new Category(tr("Symbols"), emoji_provider_.symbols, scrollWidget);
scrollLayout->addWidget(symbolsEmoji);
auto flagsEmoji = new Category(tr("Flags"), emoji_provider_.flags, scrollWidget);
scrollLayout->addWidget(flagsEmoji);
contentLayout->addWidget(scrollArea_);
contentLayout->addWidget(emojiCategories);
connect(peopleEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(peopleCategory, &QPushButton::clicked, [this, peopleEmoji]() {
this->showCategory(peopleEmoji);
});
connect(natureEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(natureCategory_, &QPushButton::clicked, [this, natureEmoji]() {
this->showCategory(natureEmoji);
});
connect(foodEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(foodCategory_, &QPushButton::clicked, [this, foodEmoji]() {
this->showCategory(foodEmoji);
});
connect(activityEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(activityCategory, &QPushButton::clicked, [this, activityEmoji]() {
this->showCategory(activityEmoji);
});
connect(travelEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(travelCategory, &QPushButton::clicked, [this, travelEmoji]() {
this->showCategory(travelEmoji);
});
connect(objectsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(objectsCategory, &QPushButton::clicked, [this, objectsEmoji]() {
this->showCategory(objectsEmoji);
});
connect(symbolsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(symbolsCategory, &QPushButton::clicked, [this, symbolsEmoji]() {
this->showCategory(symbolsEmoji);
});
connect(flagsEmoji, &Category::emojiSelected, this, &Panel::emojiSelected);
connect(flagsCategory, &QPushButton::clicked, [this, flagsEmoji]() {
this->showCategory(flagsEmoji);
});
}
void
Panel::showCategory(const Category *category)
{
auto posToGo = category->mapToParent(QPoint()).y();
auto current = scrollArea_->verticalScrollBar()->value();
if (current == posToGo)
return;
// HACK
// We want the top of the category to be visible, so scroll to the top first and then to the
// category
if (current > posToGo)
this->scrollArea_->ensureVisible(0, 0, 0, 0);
posToGo += scrollArea_->height();
this->scrollArea_->ensureVisible(0, posToGo, 0, 0);
}
void
Panel::paintEvent(QPaintEvent *event)
{
Q_UNUSED(event);
QStyleOption opt;
opt.init(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
DropShadow::draw(p,
shadowMargin_,
4.0,
QColor(120, 120, 120, 92),
QColor(255, 255, 255, 0),
0.0,
1.0,
0.6,
width(),
height());
}

View file

@ -1,66 +0,0 @@
/*
* 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 <QScrollArea>
#include "Provider.h"
namespace emoji {
class Category;
class Panel : public QWidget
{
Q_OBJECT
public:
Panel(QWidget *parent = nullptr);
signals:
void mouseLeft();
void emojiSelected(const QString &emoji);
protected:
void leaveEvent(QEvent *event) override
{
emit leaving();
QWidget::leaveEvent(event);
}
void paintEvent(QPaintEvent *event) override;
signals:
void leaving();
private:
void showCategory(const Category *category);
Provider emoji_provider_;
QScrollArea *scrollArea_;
int shadowMargin_;
// Panel dimensions.
int width_;
int height_;
int categoryIconSize_;
};
} // namespace emoji

View file

@ -1,82 +0,0 @@
/*
* 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 <QDebug>
#include "emoji/Panel.h"
#include "emoji/PickButton.h"
using namespace emoji;
// Number of milliseconds after which the panel will be hidden
// if the mouse cursor is not on top of the widget.
constexpr int HIDE_TIMEOUT = 300;
PickButton::PickButton(QWidget *parent)
: FlatButton(parent)
, panel_{nullptr}
{
connect(&hideTimer_, &QTimer::timeout, this, &PickButton::hidePanel);
connect(this, &QPushButton::clicked, this, [this]() {
if (panel_ && panel_->isVisible()) {
hidePanel();
return;
}
showPanel();
});
}
void
PickButton::hidePanel()
{
if (panel_ && !panel_->underMouse()) {
hideTimer_.stop();
panel_->hide();
}
}
void
PickButton::showPanel()
{
if (panel_.isNull()) {
panel_ = QSharedPointer<Panel>(new Panel(this));
connect(panel_.data(), &Panel::emojiSelected, this, &PickButton::emojiSelected);
connect(panel_.data(), &Panel::leaving, this, [this]() { panel_->hide(); });
}
if (panel_->isVisible())
return;
QPoint pos(rect().x(), rect().y());
pos = this->mapToGlobal(pos);
auto panel_size = panel_->sizeHint();
auto x = pos.x() - panel_size.width() + horizontal_distance_;
auto y = pos.y() - panel_size.height() - vertical_distance_;
panel_->move(x, y);
panel_->show();
}
void
PickButton::leaveEvent(QEvent *e)
{
hideTimer_.start(HIDE_TIMEOUT);
FlatButton::leaveEvent(e);
}

View file

@ -1,55 +0,0 @@
/*
* 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 <QEvent>
#include <QTimer>
#include <QWidget>
#include "ui/FlatButton.h"
namespace emoji {
class Panel;
class PickButton : public FlatButton
{
Q_OBJECT
public:
explicit PickButton(QWidget *parent = nullptr);
signals:
void emojiSelected(const QString &emoji);
protected:
void leaveEvent(QEvent *e) override;
private:
void showPanel();
void hidePanel();
// Vertical distance from panel's bottom.
int vertical_distance_ = 10;
// Horizontal distance from panel's bottom right corner.
int horizontal_distance_ = 70;
QSharedPointer<Panel> panel_;
QTimer hideTimer_;
};
} // namespace emoji

File diff suppressed because it is too large Load diff

View file

@ -59,14 +59,6 @@ class Provider
public:
// all emoji for QML purposes
static const QVector<Emoji> emoji;
static const std::vector<Emoji> people;
static const std::vector<Emoji> nature;
static const std::vector<Emoji> food;
static const std::vector<Emoji> activity;
static const std::vector<Emoji> travel;
static const std::vector<Emoji> objects;
static const std::vector<Emoji> symbols;
static const std::vector<Emoji> flags;
};
} // namespace emoji

View file

@ -6,6 +6,8 @@
#include "../Utils.h"
#include "../ui/Avatar.h"
#include "../ui/DropShadow.h"
#include "ChatPage.h"
#include "PopupItem.h"
#include "SuggestionsPopup.h"
SuggestionsPopup::SuggestionsPopup(QWidget *parent)
@ -65,44 +67,6 @@ SuggestionsPopup::addRooms(const std::vector<RoomSearchResult> &rooms)
selectNextSuggestion();
}
void
SuggestionsPopup::addUsers(const std::vector<SearchResult> &users)
{
if (users.empty()) {
hide();
return;
}
const size_t layoutCount = layout_->count();
const size_t userCount = users.size();
// Remove the extra widgets from the layout.
if (userCount < layoutCount)
removeLayoutItemsAfter(userCount - 1);
for (size_t i = 0; i < userCount; ++i) {
auto item = layout_->itemAt(i);
// Create a new widget if there isn't already one in that
// layout position.
if (!item) {
auto user = new UserItem(this, users.at(i).user_id);
connect(user, &UserItem::clicked, this, &SuggestionsPopup::itemSelected);
layout_->addWidget(user);
} else {
// Update the current widget with the new data.
auto userWidget = qobject_cast<UserItem *>(item->widget());
if (userWidget)
userWidget->updateItem(users.at(i).user_id);
}
}
resetSelection();
adjustSize();
selectNextSuggestion();
}
void
SuggestionsPopup::hoverSelection()
{
@ -111,6 +75,19 @@ SuggestionsPopup::hoverSelection()
update();
}
void
SuggestionsPopup::selectHoveredSuggestion()
{
const auto item = layout_->itemAt(selectedItem_);
if (!item)
return;
const auto &widget = qobject_cast<RoomItem *>(item->widget());
emit itemSelected(displayName(ChatPage::instance()->currentRoom(), widget->selectedText()));
resetSelection();
}
void
SuggestionsPopup::selectNextSuggestion()
{
@ -160,3 +137,23 @@ SuggestionsPopup::paintEvent(QPaintEvent *)
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}
void
SuggestionsPopup::selectLastItem()
{
selectedItem_ = layout_->count() - 1;
}
void
SuggestionsPopup::removeLayoutItemsAfter(size_t startingPos)
{
size_t posToRemove = layout_->count() - 1;
QLayoutItem *item;
while (startingPos <= posToRemove && (item = layout_->takeAt(posToRemove)) != nullptr) {
delete item->widget();
delete item;
posToRemove = layout_->count() - 1;
}
}

View file

@ -3,8 +3,9 @@
#include <QWidget>
#include "CacheStructs.h"
#include "ChatPage.h"
#include "PopupItem.h"
class QVBoxLayout;
class QLayoutItem;
class SuggestionsPopup : public QWidget
{
@ -13,22 +14,9 @@ class SuggestionsPopup : public QWidget
public:
explicit SuggestionsPopup(QWidget *parent = nullptr);
template<class Item>
void selectHoveredSuggestion()
{
const auto item = layout_->itemAt(selectedItem_);
if (!item)
return;
const auto &widget = qobject_cast<Item *>(item->widget());
emit itemSelected(
displayName(ChatPage::instance()->currentRoom(), widget->selectedText()));
resetSelection();
}
void selectHoveredSuggestion();
public slots:
void addUsers(const std::vector<SearchResult> &users);
void addRooms(const std::vector<RoomSearchResult> &rooms);
//! Move to the next available suggestion item.
@ -51,20 +39,8 @@ private:
void hoverSelection();
void resetSelection() { selectedItem_ = -1; }
void selectFirstItem() { selectedItem_ = 0; }
void selectLastItem() { selectedItem_ = layout_->count() - 1; }
void removeLayoutItemsAfter(size_t startingPos)
{
size_t posToRemove = layout_->count() - 1;
QLayoutItem *item;
while (startingPos <= posToRemove &&
(item = layout_->takeAt(posToRemove)) != nullptr) {
delete item->widget();
delete item;
posToRemove = layout_->count() - 1;
}
}
void selectLastItem();
void removeLayoutItemsAfter(size_t startingPos);
QVBoxLayout *layout_;

666
src/timeline/InputBar.cpp Normal file
View file

@ -0,0 +1,666 @@
#include "InputBar.h"
#include <QClipboard>
#include <QDropEvent>
#include <QFileDialog>
#include <QGuiApplication>
#include <QMimeData>
#include <QMimeDatabase>
#include <QStandardPaths>
#include <QUrl>
#include <mtx/responses/common.hpp>
#include <mtx/responses/media.hpp>
#include "Cache.h"
#include "CallManager.h"
#include "ChatPage.h"
#include "CompletionProxyModel.h"
#include "Logging.h"
#include "MainWindow.h"
#include "MatrixClient.h"
#include "Olm.h"
#include "TimelineModel.h"
#include "UserSettingsPage.h"
#include "UsersModel.h"
#include "Utils.h"
#include "dialogs/PlaceCall.h"
#include "dialogs/PreviewUploadOverlay.h"
#include "emoji/EmojiModel.h"
#include "blurhash.hpp"
static constexpr size_t INPUT_HISTORY_SIZE = 10;
void
InputBar::paste(bool fromMouse)
{
const QMimeData *md = nullptr;
if (fromMouse) {
if (QGuiApplication::clipboard()->supportsSelection()) {
md = QGuiApplication::clipboard()->mimeData(QClipboard::Selection);
}
} else {
md = QGuiApplication::clipboard()->mimeData(QClipboard::Clipboard);
}
if (md)
insertMimeData(md);
}
void
InputBar::insertMimeData(const QMimeData *md)
{
if (!md)
return;
nhlog::ui()->debug("Got mime formats: {}", md->formats().join(", ").toStdString());
const auto formats = md->formats().filter("/");
const auto image = formats.filter("image/", Qt::CaseInsensitive);
const auto audio = formats.filter("audio/", Qt::CaseInsensitive);
const auto video = formats.filter("video/", Qt::CaseInsensitive);
if (!image.empty() && md->hasImage()) {
showPreview(*md, "", image);
} else if (!audio.empty()) {
showPreview(*md, "", audio);
} else if (!video.empty()) {
showPreview(*md, "", video);
} else if (md->hasUrls()) {
// Generic file path for any platform.
QString path;
for (auto &&u : md->urls()) {
if (u.isLocalFile()) {
path = u.toLocalFile();
break;
}
}
if (!path.isEmpty() && QFileInfo{path}.exists()) {
showPreview(*md, path, formats);
} else {
nhlog::ui()->warn("Clipboard does not contain any valid file paths.");
}
} else if (md->hasFormat("x-special/gnome-copied-files")) {
// Special case for X11 users. See "Notes for X11 Users" in md.
// Source: http://doc.qt.io/qt-5/qclipboard.html
// This MIME type returns a string with multiple lines separated by '\n'. The first
// line is the command to perform with the clipboard (not useful to us). The
// following lines are the file URIs.
//
// Source: the nautilus source code in file 'src/nautilus-clipboard.c' in function
// nautilus_clipboard_get_uri_list_from_selection_data()
// https://github.com/GNOME/nautilus/blob/master/src/nautilus-clipboard.c
auto data = md->data("x-special/gnome-copied-files").split('\n');
if (data.size() < 2) {
nhlog::ui()->warn("MIME format is malformed, cannot perform paste.");
return;
}
QString path;
for (int i = 1; i < data.size(); ++i) {
QUrl url{data[i]};
if (url.isLocalFile()) {
path = url.toLocalFile();
break;
}
}
if (!path.isEmpty()) {
showPreview(*md, path, formats);
} else {
nhlog::ui()->warn("Clipboard does not contain any valid file paths: {}",
data.join(", ").toStdString());
}
} else if (md->hasText()) {
emit insertText(md->text());
} else {
nhlog::ui()->debug("formats: {}", md->formats().join(", ").toStdString());
}
}
void
InputBar::updateState(int selectionStart_, int selectionEnd_, int cursorPosition_, QString text_)
{
if (text_.isEmpty())
stopTyping();
else
startTyping();
if (text_ != text()) {
if (history_.empty())
history_.push_front(text_);
else
history_.front() = text_;
history_index_ = 0;
}
selectionStart = selectionStart_;
selectionEnd = selectionEnd_;
cursorPosition = cursorPosition_;
}
QString
InputBar::text() const
{
if (history_index_ < history_.size())
return history_.at(history_index_);
return "";
}
QString
InputBar::previousText()
{
history_index_++;
if (history_index_ >= INPUT_HISTORY_SIZE)
history_index_ = INPUT_HISTORY_SIZE;
else if (text().isEmpty())
history_index_--;
return text();
}
QString
InputBar::nextText()
{
history_index_--;
if (history_index_ >= INPUT_HISTORY_SIZE)
history_index_ = 0;
return text();
}
QObject *
InputBar::completerFor(QString completerName)
{
if (completerName == "user") {
auto userModel = new UsersModel(room->roomId().toStdString());
auto proxy = new CompletionProxyModel(userModel);
userModel->setParent(proxy);
return proxy;
} else if (completerName == "emoji") {
auto emojiModel = new emoji::EmojiModel();
auto proxy = new CompletionProxyModel(emojiModel);
emojiModel->setParent(proxy);
return proxy;
}
return nullptr;
}
void
InputBar::send()
{
if (text().trimmed().isEmpty())
return;
if (text().startsWith('/')) {
int command_end = text().indexOf(' ');
if (command_end == -1)
command_end = text().size();
auto name = text().mid(1, command_end - 1);
auto args = text().mid(command_end + 1);
if (name.isEmpty() || name == "/") {
message(args);
} else {
command(name, args);
}
} else {
message(text());
}
nhlog::ui()->debug("Send: {}", text().toStdString());
if (history_.size() == INPUT_HISTORY_SIZE)
history_.pop_back();
history_.push_front("");
history_index_ = 0;
}
void
InputBar::openFileSelection()
{
const QString homeFolder = QStandardPaths::writableLocation(QStandardPaths::HomeLocation);
const auto fileName = QFileDialog::getOpenFileName(
ChatPage::instance(), tr("Select a file"), homeFolder, tr("All Files (*)"));
if (fileName.isEmpty())
return;
QMimeDatabase db;
QMimeType mime = db.mimeTypeForFile(fileName, QMimeDatabase::MatchContent);
QFile file{fileName};
if (!file.open(QIODevice::ReadOnly)) {
emit ChatPage::instance()->showNotification(
QString("Error while reading media: %1").arg(file.errorString()));
return;
}
setUploading(true);
auto bin = file.readAll();
QMimeData data;
data.setData(mime.name(), bin);
showPreview(data, fileName, QStringList{mime.name()});
}
void
InputBar::message(QString msg)
{
mtx::events::msg::Text text = {};
text.body = msg.trimmed().toStdString();
if (ChatPage::instance()->userSettings()->markdown()) {
text.formatted_body = utils::markdownToHtml(msg).toStdString();
// Don't send formatted_body, when we don't need to
if (text.formatted_body.find("<") == std::string::npos)
text.formatted_body = "";
else
text.format = "org.matrix.custom.html";
}
if (!room->reply().isEmpty()) {
auto related = room->relatedInfo(room->reply());
QString body;
bool firstLine = true;
for (const auto &line : related.quoted_body.split("\n")) {
if (firstLine) {
firstLine = false;
body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line);
} else {
body = QString("%1\n> %2\n").arg(body).arg(line);
}
}
text.body = QString("%1\n%2").arg(body).arg(msg).toStdString();
// NOTE(Nico): rich replies always need a formatted_body!
text.format = "org.matrix.custom.html";
if (ChatPage::instance()->userSettings()->markdown())
text.formatted_body =
utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg))
.toStdString();
else
text.formatted_body =
utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
text.relates_to.in_reply_to.event_id = related.related_event;
room->resetReply();
}
room->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
}
void
InputBar::emote(QString msg)
{
auto html = utils::markdownToHtml(msg);
mtx::events::msg::Emote emote;
emote.body = msg.trimmed().toStdString();
if (html != msg.trimmed().toHtmlEscaped() &&
ChatPage::instance()->userSettings()->markdown()) {
emote.formatted_body = html.toStdString();
emote.format = "org.matrix.custom.html";
}
if (!room->reply().isEmpty()) {
emote.relates_to.in_reply_to.event_id = room->reply().toStdString();
room->resetReply();
}
room->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
}
void
InputBar::image(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize,
const QSize &dimensions,
const QString &blurhash)
{
mtx::events::msg::Image image;
image.info.mimetype = mime.toStdString();
image.info.size = dsize;
image.info.blurhash = blurhash.toStdString();
image.body = filename.toStdString();
image.info.h = dimensions.height();
image.info.w = dimensions.width();
if (file)
image.file = file;
else
image.url = url.toStdString();
if (!room->reply().isEmpty()) {
image.relates_to.in_reply_to.event_id = room->reply().toStdString();
room->resetReply();
}
room->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
}
void
InputBar::file(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::File file;
file.info.mimetype = mime.toStdString();
file.info.size = dsize;
file.body = filename.toStdString();
if (encryptedFile)
file.file = encryptedFile;
else
file.url = url.toStdString();
if (!room->reply().isEmpty()) {
file.relates_to.in_reply_to.event_id = room->reply().toStdString();
room->resetReply();
}
room->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
}
void
InputBar::audio(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::Audio audio;
audio.info.mimetype = mime.toStdString();
audio.info.size = dsize;
audio.body = filename.toStdString();
audio.url = url.toStdString();
if (file)
audio.file = file;
else
audio.url = url.toStdString();
if (!room->reply().isEmpty()) {
audio.relates_to.in_reply_to.event_id = room->reply().toStdString();
room->resetReply();
}
room->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
}
void
InputBar::video(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::Video video;
video.info.mimetype = mime.toStdString();
video.info.size = dsize;
video.body = filename.toStdString();
if (file)
video.file = file;
else
video.url = url.toStdString();
if (!room->reply().isEmpty()) {
video.relates_to.in_reply_to.event_id = room->reply().toStdString();
room->resetReply();
}
room->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
}
void
InputBar::command(QString command, QString args)
{
if (command == "me") {
emote(args);
} else if (command == "join") {
ChatPage::instance()->joinRoom(args);
} else if (command == "invite") {
ChatPage::instance()->inviteUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "kick") {
ChatPage::instance()->kickUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "ban") {
ChatPage::instance()->banUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "unban") {
ChatPage::instance()->unbanUser(args.section(' ', 0, 0), args.section(' ', 1, -1));
} else if (command == "roomnick") {
mtx::events::state::Member member;
member.display_name = args.toStdString();
member.avatar_url =
cache::avatarUrl(room->roomId(),
QString::fromStdString(http::client()->user_id().to_string()))
.toStdString();
member.membership = mtx::events::state::Membership::Join;
http::client()->send_state_event(
room->roomId().toStdString(),
http::client()->user_id().to_string(),
member,
[](mtx::responses::EventId, mtx::http::RequestErr err) {
if (err)
nhlog::net()->error("Failed to set room displayname: {}",
err->matrix_error.error);
});
} else if (command == "shrug") {
message("¯\\_(ツ)_/¯" + (args.isEmpty() ? "" : " " + args));
} else if (command == "fliptable") {
message("(╯°□°)╯︵ ┻━┻");
} else if (command == "unfliptable") {
message(" ┯━┯╭( º _ º╭)");
} else if (command == "sovietflip") {
message("ノ┬─┬ノ ︵ ( \\o°o)\\");
} else if (command == "clear-timeline") {
room->clearTimeline();
} else if (command == "rotate-megolm-session") {
cache::dropOutboundMegolmSession(room->roomId().toStdString());
}
}
void
InputBar::showPreview(const QMimeData &source, QString path, const QStringList &formats)
{
dialogs::PreviewUploadOverlay *previewDialog_ =
new dialogs::PreviewUploadOverlay(ChatPage::instance());
previewDialog_->setAttribute(Qt::WA_DeleteOnClose);
if (source.hasImage())
previewDialog_->setPreview(qvariant_cast<QImage>(source.imageData()),
formats.front());
else if (!path.isEmpty())
previewDialog_->setPreview(path);
else if (!formats.isEmpty()) {
auto mime = formats.first();
previewDialog_->setPreview(source.data(mime), mime);
} else {
setUploading(false);
previewDialog_->deleteLater();
return;
}
connect(previewDialog_, &dialogs::PreviewUploadOverlay::aborted, this, [this]() {
setUploading(false);
});
connect(
previewDialog_,
&dialogs::PreviewUploadOverlay::confirmUpload,
this,
[this](const QByteArray data, const QString &mime, const QString &fn) {
setUploading(true);
auto payload = std::string(data.data(), data.size());
std::optional<mtx::crypto::EncryptedFile> encryptedFile;
if (cache::isRoomEncrypted(room->roomId().toStdString())) {
mtx::crypto::BinaryBuf buf;
std::tie(buf, encryptedFile) = mtx::crypto::encrypt_file(payload);
payload = mtx::crypto::to_string(buf);
}
QSize dimensions;
QString blurhash;
auto mimeClass = mime.split("/")[0];
nhlog::ui()->debug("Mime: {}", mime.toStdString());
if (mimeClass == "image") {
QImage img = utils::readImage(&data);
dimensions = img.size();
if (img.height() > 200 && img.width() > 360)
img = img.scaled(360, 200, Qt::KeepAspectRatioByExpanding);
std::vector<unsigned char> data;
for (int y = 0; y < img.height(); y++) {
for (int x = 0; x < img.width(); x++) {
auto p = img.pixel(x, y);
data.push_back(static_cast<unsigned char>(qRed(p)));
data.push_back(static_cast<unsigned char>(qGreen(p)));
data.push_back(static_cast<unsigned char>(qBlue(p)));
}
}
blurhash = QString::fromStdString(
blurhash::encode(data.data(), img.width(), img.height(), 4, 3));
}
http::client()->upload(
payload,
encryptedFile ? "application/octet-stream" : mime.toStdString(),
QFileInfo(fn).fileName().toStdString(),
[this,
filename = fn,
encryptedFile = std::move(encryptedFile),
mimeClass,
mime,
size = payload.size(),
dimensions,
blurhash](const mtx::responses::ContentURI &res,
mtx::http::RequestErr err) mutable {
if (err) {
emit ChatPage::instance()->showNotification(
tr("Failed to upload media. Please try again."));
nhlog::net()->warn("failed to upload media: {} {} ({})",
err->matrix_error.error,
to_string(err->matrix_error.errcode),
static_cast<int>(err->status_code));
setUploading(false);
return;
}
auto url = QString::fromStdString(res.content_uri);
if (encryptedFile)
encryptedFile->url = res.content_uri;
if (mimeClass == "image")
image(filename,
encryptedFile,
url,
mime,
size,
dimensions,
blurhash);
else if (mimeClass == "audio")
audio(filename, encryptedFile, url, mime, size);
else if (mimeClass == "video")
video(filename, encryptedFile, url, mime, size);
else
file(filename, encryptedFile, url, mime, size);
setUploading(false);
});
});
}
void
InputBar::callButton()
{
auto callManager_ = ChatPage::instance()->callManager();
if (callManager_->onActiveCall()) {
callManager_->hangUp();
} else {
auto current_room_ = room->roomId();
if (auto roomInfo = cache::singleRoomInfo(current_room_.toStdString());
roomInfo.member_count != 2) {
ChatPage::instance()->showNotification("Calls are limited to 1:1 rooms.");
} else {
std::vector<RoomMember> members(
cache::getMembers(current_room_.toStdString()));
const RoomMember &callee = members.front().user_id == utils::localUser()
? members.back()
: members.front();
auto dialog =
new dialogs::PlaceCall(callee.user_id,
callee.display_name,
QString::fromStdString(roomInfo.name),
QString::fromStdString(roomInfo.avatar_url),
ChatPage::instance()->userSettings(),
MainWindow::instance());
connect(dialog,
&dialogs::PlaceCall::voice,
callManager_,
[callManager_, current_room_]() {
callManager_->sendInvite(current_room_, false);
});
connect(dialog,
&dialogs::PlaceCall::video,
callManager_,
[callManager_, current_room_]() {
callManager_->sendInvite(current_room_, true);
});
utils::centerWidget(dialog, MainWindow::instance());
dialog->show();
}
}
}
void
InputBar::startTyping()
{
if (!typingRefresh_.isActive()) {
typingRefresh_.start();
if (ChatPage::instance()->userSettings()->typingNotifications()) {
http::client()->start_typing(
room->roomId().toStdString(), 10'000, [](mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn(
"failed to send typing notification: {}",
err->matrix_error.error);
}
});
}
}
typingTimeout_.start();
}
void
InputBar::stopTyping()
{
typingRefresh_.stop();
typingTimeout_.stop();
if (!ChatPage::instance()->userSettings()->typingNotifications())
return;
http::client()->stop_typing(room->roomId().toStdString(), [](mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn("failed to stop typing notifications: {}",
err->matrix_error.error);
}
});
}

99
src/timeline/InputBar.h Normal file
View file

@ -0,0 +1,99 @@
#pragma once
#include <QObject>
#include <QTimer>
#include <deque>
#include <mtx/common.hpp>
#include <mtx/responses/messages.hpp>
class TimelineModel;
class QMimeData;
class QDropEvent;
class QStringList;
class InputBar : public QObject
{
Q_OBJECT
Q_PROPERTY(bool uploading READ uploading NOTIFY uploadingChanged)
public:
InputBar(TimelineModel *parent)
: QObject()
, room(parent)
{
typingRefresh_.setInterval(10'000);
typingRefresh_.setSingleShot(true);
typingTimeout_.setInterval(5'000);
typingTimeout_.setSingleShot(true);
connect(&typingRefresh_, &QTimer::timeout, this, &InputBar::startTyping);
connect(&typingTimeout_, &QTimer::timeout, this, &InputBar::stopTyping);
}
public slots:
QString text() const;
QString previousText();
QString nextText();
void send();
void paste(bool fromMouse);
void insertMimeData(const QMimeData *data);
void updateState(int selectionStart, int selectionEnd, int cursorPosition, QString text);
void openFileSelection();
bool uploading() const { return uploading_; }
void callButton();
QObject *completerFor(QString completerName);
private slots:
void startTyping();
void stopTyping();
signals:
void insertText(QString text);
void uploadingChanged(bool value);
private:
void message(QString body);
void emote(QString body);
void command(QString name, QString args);
void image(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize,
const QSize &dimensions,
const QString &blurhash);
void file(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
const QString &url,
const QString &mime,
uint64_t dsize);
void audio(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize);
void video(const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize);
void showPreview(const QMimeData &source, QString path, const QStringList &formats);
void setUploading(bool value)
{
if (value != uploading_) {
uploading_ = value;
emit uploadingChanged(value);
}
}
QTimer typingRefresh_;
QTimer typingTimeout_;
TimelineModel *room;
std::deque<QString> history_;
std::size_t history_index_ = 0;
int selectionStart = 0, selectionEnd = 0, cursorPosition = 0;
bool uploading_ = false;
};

View file

@ -800,7 +800,6 @@ void
TimelineModel::replyAction(QString id)
{
setReply(id);
ChatPage::instance()->focusMessageInput();
}
RelatedInfo

View file

@ -10,6 +10,7 @@
#include "CacheCryptoStructs.h"
#include "EventStore.h"
#include "InputBar.h"
#include "ui/UserProfile.h"
namespace mtx::http {
@ -149,6 +150,7 @@ class TimelineModel : public QAbstractListModel
Q_PROPERTY(QString roomName READ roomName NOTIFY roomNameChanged)
Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY roomAvatarUrlChanged)
Q_PROPERTY(QString roomTopic READ roomTopic NOTIFY roomTopicChanged)
Q_PROPERTY(InputBar *input READ input CONSTANT)
public:
explicit TimelineModel(TimelineViewManager *manager,
@ -271,6 +273,7 @@ public slots:
QString roomName() const;
QString roomTopic() const;
InputBar *input() { return &input_; }
QString roomAvatarUrl() const;
QString roomId() const { return room_id_; }
@ -320,6 +323,8 @@ private:
TimelineViewManager *manager_;
InputBar input_{this};
friend struct SendMessageVisitor;
};

View file

@ -1,6 +1,7 @@
#include "TimelineViewManager.h"
#include <QDesktopServices>
#include <QDropEvent>
#include <QMetaType>
#include <QPalette>
#include <QQmlContext>
@ -20,6 +21,7 @@
#include "dialogs/ImageOverlay.h"
#include "emoji/EmojiModel.h"
#include "emoji/Provider.h"
#include "ui/NhekoDropArea.h"
#include <iostream> //only for debugging
@ -115,6 +117,7 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
qmlRegisterType<DelegateChoice>("im.nheko", 1, 0, "DelegateChoice");
qmlRegisterType<DelegateChooser>("im.nheko", 1, 0, "DelegateChooser");
qmlRegisterType<NhekoDropArea>("im.nheko", 1, 0, "NhekoDropArea");
qmlRegisterUncreatableType<DeviceVerificationFlow>(
"im.nheko", 1, 0, "DeviceVerificationFlow", "Can't create verification flow from QML!");
qmlRegisterUncreatableType<UserProfile>(
@ -244,6 +247,26 @@ TimelineViewManager::TimelineViewManager(CallManager *callManager, ChatPage *par
&CallManager::newVideoCallState,
this,
&TimelineViewManager::videoCallChanged);
connect(&WebRTCSession::instance(),
&WebRTCSession::stateChanged,
this,
&TimelineViewManager::onCallChanged);
}
bool
TimelineViewManager::isOnCall() const
{
return callManager_->onActiveCall();
}
bool
TimelineViewManager::callsSupported() const
{
#ifdef GSTREAMER_AVAILABLE
return true;
#else
return false;
#endif
}
void
@ -313,6 +336,7 @@ TimelineViewManager::setHistoryView(const QString &room_id)
if (room != models.end()) {
timeline_ = room.value().data();
emit activeTimelineChanged(timeline_);
container->setFocus();
nhlog::ui()->info("Activated room {}", room_id.toStdString());
}
}
@ -474,81 +498,6 @@ TimelineViewManager::initWithMessages(const std::vector<QString> &roomIds)
addRoom(roomId);
}
void
TimelineViewManager::queueTextMessage(const QString &msg)
{
if (!timeline_)
return;
mtx::events::msg::Text text = {};
text.body = msg.trimmed().toStdString();
if (ChatPage::instance()->userSettings()->markdown()) {
text.formatted_body = utils::markdownToHtml(msg).toStdString();
// Don't send formatted_body, when we don't need to
if (text.formatted_body.find("<") == std::string::npos)
text.formatted_body = "";
else
text.format = "org.matrix.custom.html";
}
if (!timeline_->reply().isEmpty()) {
auto related = timeline_->relatedInfo(timeline_->reply());
QString body;
bool firstLine = true;
for (const auto &line : related.quoted_body.split("\n")) {
if (firstLine) {
firstLine = false;
body = QString("> <%1> %2\n").arg(related.quoted_user).arg(line);
} else {
body = QString("%1\n> %2\n").arg(body).arg(line);
}
}
text.body = QString("%1\n%2").arg(body).arg(msg).toStdString();
// NOTE(Nico): rich replies always need a formatted_body!
text.format = "org.matrix.custom.html";
if (ChatPage::instance()->userSettings()->markdown())
text.formatted_body =
utils::getFormattedQuoteBody(related, utils::markdownToHtml(msg))
.toStdString();
else
text.formatted_body =
utils::getFormattedQuoteBody(related, msg.toHtmlEscaped()).toStdString();
text.relates_to.in_reply_to.event_id = related.related_event;
timeline_->resetReply();
}
timeline_->sendMessageEvent(text, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueEmoteMessage(const QString &msg)
{
auto html = utils::markdownToHtml(msg);
mtx::events::msg::Emote emote;
emote.body = msg.trimmed().toStdString();
if (html != msg.trimmed().toHtmlEscaped() &&
ChatPage::instance()->userSettings()->markdown()) {
emote.formatted_body = html.toStdString();
emote.format = "org.matrix.custom.html";
}
if (!timeline_->reply().isEmpty()) {
emote.relates_to.in_reply_to.event_id = timeline_->reply().toStdString();
timeline_->resetReply();
}
if (timeline_)
timeline_->sendMessageEvent(emote, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QString &reactionKey)
{
@ -581,122 +530,6 @@ TimelineViewManager::queueReactionMessage(const QString &reactedEvent, const QSt
timeline_->redactEvent(selfReactedEvent);
}
}
void
TimelineViewManager::queueImageMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize,
const QSize &dimensions,
const QString &blurhash)
{
mtx::events::msg::Image image;
image.info.mimetype = mime.toStdString();
image.info.size = dsize;
image.info.blurhash = blurhash.toStdString();
image.body = filename.toStdString();
image.info.h = dimensions.height();
image.info.w = dimensions.width();
if (file)
image.file = file;
else
image.url = url.toStdString();
auto model = models.value(roomid);
if (!model->reply().isEmpty()) {
image.relates_to.in_reply_to.event_id = model->reply().toStdString();
model->resetReply();
}
model->sendMessageEvent(image, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueFileMessage(
const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &encryptedFile,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::File file;
file.info.mimetype = mime.toStdString();
file.info.size = dsize;
file.body = filename.toStdString();
if (encryptedFile)
file.file = encryptedFile;
else
file.url = url.toStdString();
auto model = models.value(roomid);
if (!model->reply().isEmpty()) {
file.relates_to.in_reply_to.event_id = model->reply().toStdString();
model->resetReply();
}
model->sendMessageEvent(file, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueAudioMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::Audio audio;
audio.info.mimetype = mime.toStdString();
audio.info.size = dsize;
audio.body = filename.toStdString();
audio.url = url.toStdString();
if (file)
audio.file = file;
else
audio.url = url.toStdString();
auto model = models.value(roomid);
if (!model->reply().isEmpty()) {
audio.relates_to.in_reply_to.event_id = model->reply().toStdString();
model->resetReply();
}
model->sendMessageEvent(audio, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueVideoMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize)
{
mtx::events::msg::Video video;
video.info.mimetype = mime.toStdString();
video.info.size = dsize;
video.body = filename.toStdString();
if (file)
video.file = file;
else
video.url = url.toStdString();
auto model = models.value(roomid);
if (!model->reply().isEmpty()) {
video.relates_to.in_reply_to.event_id = model->reply().toStdString();
model->resetReply();
}
model->sendMessageEvent(video, mtx::events::EventType::RoomMessage);
}
void
TimelineViewManager::queueCallMessage(const QString &roomid,
const mtx::events::msg::CallInvite &callInvite)

View file

@ -41,6 +41,8 @@ class TimelineViewManager : public QObject
Q_PROPERTY(QString callPartyName READ callPartyName NOTIFY callPartyChanged)
Q_PROPERTY(QString callPartyAvatarUrl READ callPartyAvatarUrl NOTIFY callPartyChanged)
Q_PROPERTY(bool isMicMuted READ isMicMuted NOTIFY micMuteChanged)
Q_PROPERTY(bool isOnCall READ isOnCall NOTIFY onCallChanged)
Q_PROPERTY(bool callsSupported READ callsSupported CONSTANT)
public:
TimelineViewManager(CallManager *callManager, ChatPage *parent = nullptr);
@ -95,6 +97,7 @@ signals:
void videoCallChanged();
void callPartyChanged();
void micMuteChanged();
void onCallChanged();
public slots:
void updateReadReceipts(const QString &room_id, const std::vector<QString> &event_ids);
@ -102,48 +105,25 @@ public slots:
void initWithMessages(const std::vector<QString> &roomIds);
void setHistoryView(const QString &room_id);
TimelineModel *getHistoryView(const QString &room_id)
{
auto room = models.find(room_id);
if (room != models.end())
return room.value().data();
else
return nullptr;
}
void updateColorPalette();
void queueReactionMessage(const QString &reactedEvent, const QString &reactionKey);
void queueTextMessage(const QString &msg);
void queueEmoteMessage(const QString &msg);
void queueImageMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize,
const QSize &dimensions,
const QString &blurhash);
void queueFileMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize);
void queueAudioMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize);
void queueVideoMessage(const QString &roomid,
const QString &filename,
const std::optional<mtx::crypto::EncryptedFile> &file,
const QString &url,
const QString &mime,
uint64_t dsize);
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallInvite &);
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallCandidates &);
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallAnswer &);
void queueCallMessage(const QString &roomid, const mtx::events::msg::CallHangUp &);
void updateEncryptedDescriptions();
void clearCurrentRoomTimeline()
{
if (timeline_)
timeline_->clearTimeline();
}
bool isOnCall() const;
bool callsSupported() const;
void enableBackButton()
{

39
src/ui/NhekoDropArea.cpp Normal file
View file

@ -0,0 +1,39 @@
#include "NhekoDropArea.h"
#include <QMimeData>
#include "ChatPage.h"
#include "timeline/InputBar.h"
#include "timeline/TimelineModel.h"
#include "timeline/TimelineViewManager.h"
#include "Logging.h"
NhekoDropArea::NhekoDropArea(QQuickItem *parent)
: QQuickItem(parent)
{
setFlags(ItemAcceptsDrops);
}
void
NhekoDropArea::dragEnterEvent(QDragEnterEvent *event)
{
event->acceptProposedAction();
}
void
NhekoDropArea::dragMoveEvent(QDragMoveEvent *event)
{
event->acceptProposedAction();
}
void
NhekoDropArea::dropEvent(QDropEvent *event)
{
if (event) {
auto model = ChatPage::instance()->timelineManager()->getHistoryView(roomid_);
if (model) {
model->input()->insertMimeData(event->mimeData());
}
}
}

30
src/ui/NhekoDropArea.h Normal file
View file

@ -0,0 +1,30 @@
#include <QQuickItem>
class NhekoDropArea : public QQuickItem
{
Q_OBJECT
Q_PROPERTY(QString roomid READ roomid WRITE setRoomid NOTIFY roomidChanged)
public:
NhekoDropArea(QQuickItem *parent = nullptr);
signals:
void roomidChanged(QString roomid);
public slots:
void setRoomid(QString roomid)
{
if (roomid_ != roomid) {
roomid_ = roomid;
emit roomidChanged(roomid);
}
}
QString roomid() const { return roomid_; }
protected:
void dragEnterEvent(QDragEnterEvent *event) override;
void dragMoveEvent(QDragMoveEvent *event) override;
void dropEvent(QDropEvent *event) override;
private:
QString roomid_;
};