1 /*
2 Drawpile - a collaborative drawing program.
3
4 Copyright (C) 2006-2020 Calle Laakkonen
5
6 Drawpile is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, either version 3 of the License, or
9 (at your option) any later version.
10
11 Drawpile is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with Drawpile. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20 #include "joindialog.h"
21 #include "addserverdialog.h"
22
23 #include "utils/mandatoryfields.h"
24 #include "utils/usernamevalidator.h"
25 #include "utils/listservermodel.h"
26 #include "utils/sessionfilterproxymodel.h"
27 #include "utils/images.h"
28 #include "utils/newversion.h"
29 #include "utils/icon.h"
30 #include "../libshared/listings/announcementapi.h"
31 #include "parentalcontrols/parentalcontrols.h"
32
33 #ifdef HAVE_DNSSD
34 #include "../libshared/listings/zeroconfdiscovery.h"
35 #endif
36
37 #include "ui_joindialog.h"
38
39 #include <QPushButton>
40 #include <QSettings>
41 #include <QTimer>
42 #include <QUrl>
43 #include <QUrlQuery>
44 #include <QDebug>
45 #include <QFileDialog>
46
47 namespace dialogs {
48
49 // Height below which the session listing widgets are hidden.
50 static const int COMPACT_MODE_THRESHOLD = 300;
51
52 // How often the listing view should be refreshed (in seconds)
53 static const int REFRESH_INTERVAL = 60;
54
JoinDialog(const QUrl & url,QWidget * parent)55 JoinDialog::JoinDialog(const QUrl &url, QWidget *parent)
56 : QDialog(parent), m_lastRefresh(0)
57 {
58 m_ui = new Ui_JoinDialog;
59 m_ui->setupUi(this);
60 m_ui->buttons->button(QDialogButtonBox::Ok)->setText(tr("Join"));
61 m_ui->buttons->button(QDialogButtonBox::Ok)->setDefault(true);
62
63 m_addServerButton = m_ui->buttons->addButton(tr("Add Server"), QDialogButtonBox::ActionRole);
64 m_addServerButton->setIcon(icon::fromTheme("list-add"));
65 connect(m_addServerButton, &QPushButton::clicked, this, &JoinDialog::addListServer);
66
67 connect(m_ui->address, &QComboBox::editTextChanged, this, &JoinDialog::addressChanged);
68 connect(m_ui->autoRecord, &QAbstractButton::clicked, this, &JoinDialog::recordingToggled);
69
70 // New version notification (cached)
71 m_ui->newVersionNotification->setVisible(NewVersionCheck::isThereANewSeries());
72
73 // Session listing
74 if(parentalcontrols::level() != parentalcontrols::Level::Unrestricted)
75 m_ui->showNsfw->setEnabled(false);
76
77 m_sessions = new SessionListingModel(this);
78
79 #ifdef HAVE_DNSSD
80 if(ZeroconfDiscovery::isAvailable()) {
81 auto zeroconfDiscovery = new ZeroconfDiscovery(this);
82 m_sessions->setMessage(tr("Nearby"), tr("Loading..."));
83 connect(zeroconfDiscovery, &ZeroconfDiscovery::serverListUpdated, this, [this](const QVector<sessionlisting::Session> &servers) {
84 m_sessions->setList(tr("Nearby"), servers);
85 });
86 zeroconfDiscovery->discover();
87 }
88 #endif
89
90 const auto servers = sessionlisting::ListServerModel::listServers(true);
91 for(const sessionlisting::ListServer &ls : servers) {
92 if(ls.publicListings)
93 m_sessions->setMessage(ls.name, tr("Loading..."));
94 }
95 m_ui->noListServersNotification->setVisible(servers.isEmpty());
96
97 m_filteredSessions = new SessionFilterProxyModel(this);
98 m_filteredSessions->setSourceModel(m_sessions);
99 m_filteredSessions->setFilterCaseSensitivity(Qt::CaseInsensitive);
100 m_filteredSessions->setSortCaseSensitivity(Qt::CaseInsensitive);
101 m_filteredSessions->setFilterKeyColumn(-1);
102 m_filteredSessions->setSortRole(Qt::UserRole);
103
104 m_filteredSessions->setShowNsfw(false);
105 m_filteredSessions->setShowPassworded(false);
106
107 connect(m_ui->showPassworded, &QAbstractButton::toggled,
108 m_filteredSessions, &SessionFilterProxyModel::setShowPassworded);
109 connect(m_ui->showNsfw, &QAbstractButton::toggled,
110 m_filteredSessions, &SessionFilterProxyModel::setShowNsfw);
111 connect(m_ui->showClosed, &QAbstractButton::toggled,
112 m_filteredSessions, &SessionFilterProxyModel::setShowClosed);
113 connect(m_ui->filter, &QLineEdit::textChanged,
114 m_filteredSessions, &SessionFilterProxyModel::setFilterFixedString);
115
116 m_ui->listing->setModel(m_filteredSessions);
117 m_ui->listing->expandAll();
118
119 QHeaderView *header = m_ui->listing->header();
120 header->setSectionResizeMode(0, QHeaderView::Stretch);
121 header->setSectionResizeMode(1, QHeaderView::ResizeToContents);
122 header->setSectionResizeMode(2, QHeaderView::ResizeToContents);
123 header->setSectionResizeMode(3, QHeaderView::ResizeToContents);
124 header->setSectionResizeMode(4, QHeaderView::ResizeToContents);
125
126 connect(m_ui->listing, &QTreeView::clicked, this, [this](const QModelIndex &index) {
127 // Set the server URL when clicking on an item
128 if((index.flags() & Qt::ItemIsEnabled))
129 m_ui->address->setCurrentText(index.data(SessionListingModel::UrlRole).value<QUrl>().toString());
130 });
131
132 connect(m_ui->listing, &QTreeView::doubleClicked, [this](const QModelIndex &index) {
133 // Shortcut: double click to OK
134 if((index.flags() & Qt::ItemIsEnabled) && m_ui->buttons->button(QDialogButtonBox::Ok)->isEnabled())
135 accept();
136 });
137
138 new MandatoryFields(this, m_ui->buttons->button(QDialogButtonBox::Ok));
139
140 restoreSettings();
141
142 if(!url.isEmpty()) {
143 m_ui->address->setCurrentText(url.toString());
144
145 const QUrlQuery q(url);
146 QUrl addListServer = q.queryItemValue("list-server");
147 if(addListServer.isValid() && !addListServer.isEmpty()) {
148 addListServerUrl(addListServer);
149 }
150 }
151
152 // Periodically refresh the session listing
153 auto refreshTimer = new QTimer(this);
154 connect(refreshTimer, &QTimer::timeout,
155 this, &JoinDialog::refreshListing);
156 refreshTimer->setSingleShot(false);
157 refreshTimer->start(1000 * (REFRESH_INTERVAL + 1));
158
159 refreshListing();
160 }
161
~JoinDialog()162 JoinDialog::~JoinDialog()
163 {
164 // Always remember these settings
165 QSettings cfg;
166 cfg.beginGroup("history");
167 cfg.setValue("filterlocked", m_ui->showPassworded->isChecked());
168 cfg.setValue("filternsfw", m_ui->showNsfw->isChecked());
169 cfg.setValue("filterclosed", m_ui->showClosed->isChecked());
170
171 delete m_ui;
172 }
173
resizeEvent(QResizeEvent * event)174 void JoinDialog::resizeEvent(QResizeEvent *event)
175 {
176 QDialog::resizeEvent(event);
177 bool show = false;
178 bool change = false;
179 if(height() < COMPACT_MODE_THRESHOLD && !m_ui->filter->isHidden()) {
180 show = false;
181 change = true;
182 } else if(height() > COMPACT_MODE_THRESHOLD && m_ui->filter->isHidden()) {
183 show = true;
184 change = true;
185 }
186
187 if(change)
188 setListingVisible(show);
189 }
190
setListingVisible(bool show)191 void JoinDialog::setListingVisible(bool show)
192 {
193 m_ui->filter->setVisible(show);
194 m_ui->filterLabel->setVisible(show);
195 m_ui->showPassworded->setVisible(show);
196 m_ui->showNsfw->setVisible(show);
197 m_ui->showClosed->setVisible(show);
198 m_ui->listing->setVisible(show);
199 m_ui->line->setVisible(show);
200
201 if(show)
202 refreshListing();
203 }
204
cleanAddress(const QString & addr)205 static QString cleanAddress(const QString &addr)
206 {
207 if(addr.startsWith("drawpile://")) {
208 QUrl url(addr);
209 if(url.isValid()) {
210 QString a = url.host();
211 if(url.port()!=-1)
212 a += ":" + QString::number(url.port());
213 return a;
214 }
215 }
216 return addr;
217 }
218
isRoomcode(const QString & str)219 static bool isRoomcode(const QString &str) {
220 // Roomcodes are always exactly 5 letters long
221 if(str.length() != 5)
222 return false;
223
224 // And consist of characters in range A-Z
225 for(int i=0;i<str.length();++i)
226 if(str.at(i) < 'A' || str.at(i) > 'Z')
227 return false;
228
229 return true;
230 }
231
addressChanged(const QString & addr)232 void JoinDialog::addressChanged(const QString &addr)
233 {
234 m_addServerButton->setEnabled(!addr.isEmpty());
235
236 if(isRoomcode(addr)) {
237 // A room code was just entered. Trigger session URL query
238 m_ui->address->setEditText(QString());
239 m_ui->address->lineEdit()->setPlaceholderText(tr("Searching..."));
240 m_ui->address->lineEdit()->setReadOnly(true);
241
242 QStringList servers;
243 for(const auto &s : sessionlisting::ListServerModel::listServers(true)) {
244 if(s.privateListings)
245 servers << s.url;
246 }
247 resolveRoomcode(addr, servers);
248 }
249 }
250
recordingToggled(bool checked)251 void JoinDialog::recordingToggled(bool checked)
252 {
253 if(checked) {
254 m_recordingFilename = QFileDialog::getSaveFileName(
255 this,
256 tr("Record"),
257 m_recordingFilename,
258 utils::fileFormatFilter(utils::FileFormatOption::SaveRecordings)
259 );
260 if(m_recordingFilename.isEmpty())
261 m_ui->autoRecord->setChecked(false);
262 }
263 }
264
autoRecordFilename() const265 QString JoinDialog::autoRecordFilename() const
266 {
267 return m_ui->autoRecord->isChecked() ? m_recordingFilename : QString();
268 }
269
refreshListing()270 void JoinDialog::refreshListing()
271 {
272 if(m_ui->listing->isHidden() || QDateTime::currentSecsSinceEpoch() - m_lastRefresh < REFRESH_INTERVAL)
273 return;
274 m_lastRefresh = QDateTime::currentSecsSinceEpoch();
275
276 auto listservers = sessionlisting::ListServerModel::listServers(true);
277 for(const sessionlisting::ListServer &ls : listservers) {
278 if(!ls.publicListings)
279 continue;
280
281 const QUrl url = ls.url;
282 if(!url.isValid()) {
283 qWarning("Invalid list server URL: %s", qPrintable(ls.url));
284 continue;
285 }
286
287 auto response = sessionlisting::getSessionList(url);
288
289 connect(response, &sessionlisting::AnnouncementApiResponse::serverGone,
290 [ls]() {
291 qInfo() << "List server at" << ls.url << "is gone. Removing.";
292 sessionlisting::ListServerModel servers(true);
293 if(servers.removeServer(ls.url))
294 servers.saveServers();
295 });
296 connect(response, &sessionlisting::AnnouncementApiResponse::finished,
297 this, [this, ls](const QVariant &result, const QString &message, const QString &error)
298 {
299 Q_UNUSED(message)
300 if(error.isEmpty())
301 m_sessions->setList(ls.name, result.value<QVector<sessionlisting::Session>>());
302 else
303 m_sessions->setMessage(ls.name, error);
304 });
305 connect(response, &sessionlisting::AnnouncementApiResponse::finished, response, &QObject::deleteLater);
306 }
307 }
308
resolveRoomcode(const QString & roomcode,const QStringList & servers)309 void JoinDialog::resolveRoomcode(const QString &roomcode, const QStringList &servers)
310 {
311 if(servers.isEmpty()) {
312 // Tried all the servers and didn't find the code
313 m_ui->address->lineEdit()->setPlaceholderText(tr("Room code not found!"));
314 QTimer::singleShot(1500, this, [this]() {
315 m_ui->address->setEditText(QString());
316 m_ui->address->lineEdit()->setReadOnly(false);
317 m_ui->address->lineEdit()->setPlaceholderText(QString());
318 m_ui->address->setFocus();
319 });
320
321 return;
322 }
323
324 const QUrl listServer = servers.first();
325 qDebug() << "Querying join code" << roomcode << "at server:" << listServer;
326 auto response = sessionlisting::queryRoomcode(listServer, roomcode);
327 connect(response, &sessionlisting::AnnouncementApiResponse::finished,
328 this, [this, roomcode, servers](const QVariant &result, const QString &message, const QString &error)
329 {
330 Q_UNUSED(message)
331 if(!error.isEmpty()) {
332 // Not found. Try the next server.
333 resolveRoomcode(roomcode, servers.mid(1));
334 return;
335 }
336
337 auto session = result.value<sessionlisting::Session>();
338
339 QString url = "drawpile://" + session.host;
340 if(session.port != 27750)
341 url += QStringLiteral(":%1").arg(session.port);
342 url += '/';
343 url += session.id;
344 m_ui->address->lineEdit()->setReadOnly(false);
345 m_ui->address->lineEdit()->setPlaceholderText(QString());
346 m_ui->address->setEditText(url);
347 m_ui->address->setEnabled(true);
348 }
349 );
350 connect(response, &sessionlisting::AnnouncementApiResponse::finished, response, &QObject::deleteLater);
351 }
352
restoreSettings()353 void JoinDialog::restoreSettings()
354 {
355 QSettings cfg;
356 cfg.beginGroup("history");
357
358 const QSize oldSize = cfg.value("joindlgsize").toSize();
359 if(oldSize.isValid()) {
360 if(oldSize.height() < COMPACT_MODE_THRESHOLD)
361 setListingVisible(false);
362 resize(oldSize);
363 }
364
365 m_ui->address->insertItems(0, cfg.value("recenthosts").toStringList());
366
367 m_ui->showPassworded->setChecked(cfg.value("filterlocked", true).toBool());
368 m_ui->showClosed->setChecked(cfg.value("filterclosed", true).toBool());
369
370 if(m_ui->showNsfw->isEnabled())
371 m_ui->showNsfw->setChecked(cfg.value("filternsfw", true).toBool());
372 else
373 m_ui->showNsfw->setChecked(false);
374
375 const int sortColumn = cfg.value("listsortcol", 1).toInt();
376 m_ui->listing->sortByColumn(
377 qAbs(sortColumn)-1,
378 sortColumn > 0 ? Qt::AscendingOrder : Qt::DescendingOrder
379 );
380 }
381
rememberSettings() const382 void JoinDialog::rememberSettings() const
383 {
384 QSettings cfg;
385 cfg.beginGroup("history");
386
387 cfg.setValue("joindlgsize", size());
388
389 QStringList hosts;
390 // Move current item to the top of the list
391 const QString current = cleanAddress(m_ui->address->currentText());
392 int curindex = m_ui->address->findText(current);
393 if(curindex>=0)
394 m_ui->address->removeItem(curindex);
395 hosts << current;
396 for(int i=0;i<qMin(8, m_ui->address->count());++i) {
397 if(!m_ui->address->itemText(i).isEmpty())
398 hosts << m_ui->address->itemText(i);
399 }
400 cfg.setValue("recenthosts", hosts);
401
402 const auto *listingHeader = m_ui->listing->header();
403
404 cfg.setValue("listsortcol",
405 (listingHeader->sortIndicatorSection() + 1) *
406 (listingHeader->sortIndicatorOrder() == Qt::AscendingOrder ? 1 : -1)
407 );
408 }
409
getAddress() const410 QString JoinDialog::getAddress() const {
411 return m_ui->address->currentText().trimmed();
412 }
413
getUrl() const414 QUrl JoinDialog::getUrl() const
415 {
416 const QString address = getAddress();
417
418 QString scheme;
419 if(!address.startsWith("drawpile://"))
420 scheme = "drawpile://";
421
422 const QUrl url = QUrl(scheme + address, QUrl::TolerantMode);
423 if(!url.isValid() || url.host().isEmpty())
424 return QUrl();
425
426 return url;
427 }
428
addListServer()429 void JoinDialog::addListServer()
430 {
431 // This is the "simplified" way of adding list servers:
432 // The application will fetch the server's root page (http://DOMAIN/)
433 // and see if there is a <meta name="drawpile:list-server"> tag.
434 // If there is, it will follow it and add the list server.
435 QString urlStr = m_ui->address->currentText();
436 QUrl url;
437 if(!urlStr.contains('/')) {
438 url = QUrl { "http://" + urlStr };
439 } else {
440 url = QUrl { "http://" + QUrl{urlStr}.host() };
441 }
442
443 addListServerUrl(url);
444 }
445
addListServerUrl(const QUrl & url)446 void JoinDialog::addListServerUrl(const QUrl &url)
447 {
448 m_addServerButton->setEnabled(false);
449
450 auto *dlg = new AddServerDialog(this);
451
452 connect(dlg, &QObject::destroyed, [this]() { m_addServerButton->setEnabled(true); });
453
454 connect(dlg, &AddServerDialog::serverAdded, this, [this](const QString &name) {
455 m_sessions->setMessage(name, tr("Loading..."));
456 m_ui->noListServersNotification->hide();
457
458 if(height() < COMPACT_MODE_THRESHOLD)
459 resize(width(), COMPACT_MODE_THRESHOLD + 10);
460
461 const auto index = m_sessions->index(m_sessions->rowCount()-1, 0);
462 m_ui->listing->expandAll();
463 m_ui->listing->scrollTo(index);
464
465 m_lastRefresh = 0;
466 refreshListing();
467 });
468
469 dlg->query(url);
470 }
471
472 }
473
474