1/*
2 * Copyright 2014, Stephan A��mus <superstippi@gmx.de>.
3 * Copyright 2016-2024, Andrew Lindesay <apl@lindesay.co.nz>.
4 * All rights reserved. Distributed under the terms of the MIT License.
5 */
6
7#include "RatePackageWindow.h"
8
9#include <algorithm>
10#include <stdio.h>
11
12#include <Alert.h>
13#include <Autolock.h>
14#include <AutoLocker.h>
15#include <Catalog.h>
16#include <Button.h>
17#include <CheckBox.h>
18#include <LayoutBuilder.h>
19#include <MenuField.h>
20#include <MenuItem.h>
21#include <ScrollView.h>
22#include <StringView.h>
23
24#include "AppUtils.h"
25#include "HaikuDepotConstants.h"
26#include "LanguageMenuUtils.h"
27#include "Logger.h"
28#include "MarkupParser.h"
29#include "RatingView.h"
30#include "ServerHelper.h"
31#include "TextDocumentView.h"
32#include "WebAppInterface.h"
33
34
35#undef B_TRANSLATION_CONTEXT
36#define B_TRANSLATION_CONTEXT "RatePackageWindow"
37
38
39enum {
40	MSG_SEND						= 'send',
41	MSG_PACKAGE_RATED				= 'rpkg',
42	MSG_STABILITY_SELECTED			= 'stbl',
43	MSG_RATING_ACTIVE_CHANGED		= 'rtac',
44	MSG_RATING_DETERMINATE_CHANGED	= 'rdch'
45};
46
47//! Layouts the scrollbar so it looks nice with no border and the document
48// window look.
49class ScrollView : public BScrollView {
50public:
51	ScrollView(const char* name, BView* target)
52		:
53		BScrollView(name, target, 0, false, true, B_FANCY_BORDER)
54	{
55	}
56
57	virtual void DoLayout()
58	{
59		BRect innerFrame = Bounds();
60		innerFrame.InsetBy(2, 2);
61
62		BScrollBar* vScrollBar = ScrollBar(B_VERTICAL);
63		BScrollBar* hScrollBar = ScrollBar(B_HORIZONTAL);
64
65		if (vScrollBar != NULL)
66			innerFrame.right -= vScrollBar->Bounds().Width() - 1;
67		if (hScrollBar != NULL)
68			innerFrame.bottom -= hScrollBar->Bounds().Height() - 1;
69
70		BView* target = Target();
71		if (target != NULL) {
72			Target()->MoveTo(innerFrame.left, innerFrame.top);
73			Target()->ResizeTo(innerFrame.Width(), innerFrame.Height());
74		}
75
76		if (vScrollBar != NULL) {
77			BRect rect = innerFrame;
78			rect.left = rect.right + 1;
79			rect.right = rect.left + vScrollBar->Bounds().Width();
80			rect.top -= 1;
81			rect.bottom += 1;
82
83			vScrollBar->MoveTo(rect.left, rect.top);
84			vScrollBar->ResizeTo(rect.Width(), rect.Height());
85		}
86
87		if (hScrollBar != NULL) {
88			BRect rect = innerFrame;
89			rect.top = rect.bottom + 1;
90			rect.bottom = rect.top + hScrollBar->Bounds().Height();
91			rect.left -= 1;
92			rect.right += 1;
93
94			hScrollBar->MoveTo(rect.left, rect.top);
95			hScrollBar->ResizeTo(rect.Width(), rect.Height());
96		}
97	}
98};
99
100
101class SetRatingView : public RatingView {
102public:
103	SetRatingView()
104		:
105		RatingView("rate package view"),
106		fPermanentRating(0.0f),
107		fRatingDeterminate(true)
108	{
109		SetExplicitMaxSize(BSize(B_SIZE_UNLIMITED, B_SIZE_UNSET));
110		SetRating(fPermanentRating);
111	}
112
113	virtual void MouseMoved(BPoint where, uint32 transit,
114		const BMessage* dragMessage)
115	{
116		if (dragMessage != NULL)
117			return;
118
119		if ((transit != B_INSIDE_VIEW && transit != B_ENTERED_VIEW)
120			|| where.x > MinSize().width) {
121			SetRating(fPermanentRating);
122			return;
123		}
124
125		float hoverRating = _RatingForMousePos(where);
126		SetRating(hoverRating);
127	}
128
129	virtual void MouseDown(BPoint where)
130	{
131		SetPermanentRating(_RatingForMousePos(where));
132		BMessage message(MSG_PACKAGE_RATED);
133		message.AddFloat("rating", fPermanentRating);
134		Window()->PostMessage(&message, Window());
135	}
136
137	void SetPermanentRating(float rating)
138	{
139		fPermanentRating = rating;
140		SetRating(rating);
141	}
142
143/*! By setting this to false, this indicates that there is no rating for the
144    set; ie NULL.  The indeterminate rating is indicated by a pale grey
145    colored star.
146*/
147
148	void SetRatingDeterminate(bool value) {
149		fRatingDeterminate = value;
150		Invalidate();
151	}
152
153protected:
154	virtual const BBitmap* StarBitmap()
155	{
156		if (fRatingDeterminate)
157			return fStarBlueBitmap->Bitmap(BITMAP_SIZE_16);
158		return fStarGrayBitmap->Bitmap(BITMAP_SIZE_16);
159	}
160
161private:
162	float _RatingForMousePos(BPoint where)
163	{
164		return std::min(5.0f, ceilf(5.0f * where.x / MinSize().width));
165	}
166
167	float		fPermanentRating;
168	bool		fRatingDeterminate;
169};
170
171
172RatePackageWindow::RatePackageWindow(BWindow* parent, BRect frame,
173	Model& model)
174	:
175	BWindow(frame, B_TRANSLATE("Rate package"),
176		B_FLOATING_WINDOW_LOOK, B_FLOATING_SUBSET_WINDOW_FEEL,
177		B_ASYNCHRONOUS_CONTROLS | B_AUTO_UPDATE_SIZE_LIMITS),
178	fModel(model),
179	fRatingText(),
180	fTextEditor(new TextEditor(), true),
181	fRating(RATING_NONE),
182	fRatingDeterminate(false),
183	fCommentLanguageId(LANGUAGE_DEFAULT_ID),
184	fWorkerThread(-1)
185{
186	AddToSubset(parent);
187
188	BStringView* ratingLabel = new BStringView("rating label",
189		B_TRANSLATE("Your rating:"));
190
191	fSetRatingView = new SetRatingView();
192	fSetRatingView->SetRatingDeterminate(false);
193	fRatingDeterminateCheckBox = new BCheckBox("has rating", NULL,
194		new BMessage(MSG_RATING_DETERMINATE_CHANGED));
195	fRatingDeterminateCheckBox->SetValue(B_CONTROL_OFF);
196
197	fTextView = new TextDocumentView();
198	ScrollView* textScrollView = new ScrollView(
199		"rating scroll view", fTextView);
200
201	// Get a TextDocument with default paragraph and character style
202	MarkupParser parser;
203	fRatingText = parser.CreateDocumentFromMarkup("");
204
205	fTextView->SetInsets(10.0f);
206	fTextView->SetViewUIColor(B_DOCUMENT_BACKGROUND_COLOR);
207	fTextView->SetTextDocument(fRatingText);
208	fTextView->SetTextEditor(fTextEditor);
209
210	// Construct stability rating popup
211	BPopUpMenu* stabilityMenu = new BPopUpMenu(B_TRANSLATE("Stability"));
212	fStabilityField = new BMenuField("stability",
213		B_TRANSLATE("Stability:"), stabilityMenu);
214	_InitStabilitiesMenu(stabilityMenu);
215
216	// Construct languages popup
217	BPopUpMenu* languagesMenu = new BPopUpMenu(B_TRANSLATE("Language"));
218	fCommentLanguageField = new BMenuField("language",
219		B_TRANSLATE("Comment language:"), languagesMenu);
220	_InitLanguagesMenu(languagesMenu);
221
222	fRatingActiveCheckBox = new BCheckBox("rating active",
223		B_TRANSLATE("This rating is visible to other users"),
224		new BMessage(MSG_RATING_ACTIVE_CHANGED));
225	// Hide the check mark by default, it will be made visible when
226	// the user already made a rating and it is loaded
227	fRatingActiveCheckBox->Hide();
228
229	// Construct buttons
230	fCancelButton = new BButton("cancel", B_TRANSLATE("Cancel"),
231		new BMessage(B_QUIT_REQUESTED));
232
233	fSendButton = new BButton("send", B_TRANSLATE("Send"),
234		new BMessage(MSG_SEND));
235
236	// Build layout
237	BLayoutBuilder::Group<>(this, B_VERTICAL)
238		.AddGrid()
239			.Add(ratingLabel, 0, 0)
240			.AddGroup(B_HORIZONTAL, B_USE_DEFAULT_SPACING, 1, 0)
241				.Add(fRatingDeterminateCheckBox)
242				.Add(fSetRatingView)
243			.End()
244			.AddMenuField(fStabilityField, 0, 1)
245			.AddMenuField(fCommentLanguageField, 0, 2)
246		.End()
247		.Add(textScrollView)
248		.AddGroup(B_HORIZONTAL)
249			.Add(fRatingActiveCheckBox)
250			.AddGlue()
251			.Add(fCancelButton)
252			.Add(fSendButton)
253		.End()
254		.SetInsets(B_USE_WINDOW_INSETS)
255	;
256
257	// NOTE: Do not make Send the default button. The user might want
258	// to type line-breaks instead of sending when hitting RETURN.
259
260	CenterIn(parent->Frame());
261}
262
263
264RatePackageWindow::~RatePackageWindow()
265{
266}
267
268
269void
270RatePackageWindow::_InitLanguagesMenu(BPopUpMenu* menu)
271{
272	AutoLocker<BLocker> locker(fModel.Lock());
273	fCommentLanguageId = fModel.Language()->PreferredLanguage()->ID();
274
275	LanguageMenuUtils::AddLanguagesToMenu(fModel.Language(), menu);
276	menu->SetTargetForItems(this);
277	LanguageMenuUtils::MarkLanguageInMenu(fCommentLanguageId, menu);
278}
279
280
281void
282RatePackageWindow::_InitStabilitiesMenu(BPopUpMenu* menu)
283{
284	AutoLocker<BLocker> locker(fModel.Lock());
285	int32 countStabilities = fModel.CountRatingStabilities();
286
287	menu->SetTargetForItems(this);
288
289	if (0 == countStabilities) {
290		menu->SetEnabled(false);
291		return;
292	}
293
294	for (int32 i = 0; i < countStabilities; i++) {
295		const RatingStabilityRef stability = fModel.RatingStabilityAtIndex(i);
296		BMessage* message = new BMessage(MSG_STABILITY_SELECTED);
297		message->AddString("code", stability->Code());
298		BMenuItem* item = new BMenuItem(stability->Name(), message);
299		menu->AddItem(item);
300
301		if (i == 0) {
302			fStabilityCode = stability->Code();
303			item->SetMarked(true);
304		}
305	}
306}
307
308
309void
310RatePackageWindow::DispatchMessage(BMessage* message, BHandler *handler)
311{
312	if (message->what == B_KEY_DOWN) {
313		int8 key;
314			// if the user presses escape, close the window.
315		if ((message->FindInt8("byte", &key) == B_OK)
316			&& key == B_ESCAPE) {
317			Quit();
318			return;
319		}
320	}
321
322	BWindow::DispatchMessage(message, handler);
323}
324
325
326void
327RatePackageWindow::MessageReceived(BMessage* message)
328{
329	switch (message->what) {
330		case MSG_PACKAGE_RATED:
331			message->FindFloat("rating", &fRating);
332			fRatingDeterminate = true;
333			fSetRatingView->SetRatingDeterminate(true);
334			fRatingDeterminateCheckBox->SetValue(B_CONTROL_ON);
335			break;
336
337		case MSG_STABILITY_SELECTED:
338			message->FindString("code", &fStabilityCode);
339			break;
340
341		case MSG_LANGUAGE_SELECTED:
342			message->FindString("id", &fCommentLanguageId);
343			break;
344
345		case MSG_RATING_DETERMINATE_CHANGED:
346			fRatingDeterminate = fRatingDeterminateCheckBox->Value()
347				== B_CONTROL_ON;
348			fSetRatingView->SetRatingDeterminate(fRatingDeterminate);
349			break;
350
351		case MSG_RATING_ACTIVE_CHANGED:
352		{
353			int32 value;
354			if (message->FindInt32("be:value", &value) == B_OK)
355				fRatingActive = value == B_CONTROL_ON;
356			break;
357		}
358
359		case MSG_DID_ADD_USER_RATING:
360		{
361			BAlert* alert = new(std::nothrow) BAlert(
362				B_TRANSLATE("User rating"),
363				B_TRANSLATE("Your rating was uploaded successfully. "
364					"You can update or remove it at the HaikuDepot Server "
365					"website."),
366				B_TRANSLATE("Close"), NULL, NULL,
367				B_WIDTH_AS_USUAL, B_WARNING_ALERT);
368			alert->Go();
369			_RefreshPackageData();
370			break;
371		}
372
373		case MSG_DID_UPDATE_USER_RATING:
374		{
375			BAlert* alert = new(std::nothrow) BAlert(
376				B_TRANSLATE("User rating"),
377				B_TRANSLATE("Your rating was updated."),
378				B_TRANSLATE("Close"), NULL, NULL,
379				B_WIDTH_AS_USUAL, B_WARNING_ALERT);
380			alert->Go();
381			_RefreshPackageData();
382			break;
383		}
384
385		case MSG_SEND:
386			_SendRating();
387			break;
388
389		default:
390			BWindow::MessageReceived(message);
391			break;
392	}
393}
394
395/*! Refresh the data shown about the current page.  This may be useful, for
396    example when somebody adds a rating and that changes the rating of the
397    package or they add a rating and want to see that immediately.  The logic
398    should round-trip to the server so that actual data is shown.
399*/
400
401void
402RatePackageWindow::_RefreshPackageData()
403{
404	BMessage message(MSG_SERVER_DATA_CHANGED);
405	message.AddString("name", fPackage->Name());
406	be_app->PostMessage(&message);
407}
408
409
410void
411RatePackageWindow::SetPackage(const PackageInfoRef& package)
412{
413	BAutolock locker(this);
414	if (!locker.IsLocked() || fWorkerThread >= 0)
415		return;
416
417	fPackage = package;
418
419	BString windowTitle(B_TRANSLATE("Rate %Package%"));
420	windowTitle.ReplaceAll("%Package%", package->Title());
421	SetTitle(windowTitle);
422
423	// See if the user already made a rating for this package,
424	// pre-fill the UI with that rating. (When sending the rating, the
425	// old one will be replaced.)
426	thread_id thread = spawn_thread(&_QueryRatingThreadEntry,
427		"Query rating", B_NORMAL_PRIORITY, this);
428	if (thread >= 0)
429		_SetWorkerThread(thread);
430}
431
432
433void
434RatePackageWindow::_SendRating()
435{
436	thread_id thread = spawn_thread(&_SendRatingThreadEntry,
437		"Send rating", B_NORMAL_PRIORITY, this);
438	if (thread >= 0)
439		_SetWorkerThread(thread);
440}
441
442
443void
444RatePackageWindow::_SetWorkerThread(thread_id thread)
445{
446	if (!Lock())
447		return;
448
449	bool enabled = thread < 0;
450
451	fStabilityField->SetEnabled(enabled);
452	fCommentLanguageField->SetEnabled(enabled);
453	fSendButton->SetEnabled(enabled);
454
455	if (thread >= 0) {
456		fWorkerThread = thread;
457		resume_thread(fWorkerThread);
458	} else {
459		fWorkerThread = -1;
460	}
461
462	Unlock();
463}
464
465
466/*static*/ int32
467RatePackageWindow::_QueryRatingThreadEntry(void* data)
468{
469	RatePackageWindow* window = reinterpret_cast<RatePackageWindow*>(data);
470	window->_QueryRatingThread();
471	return 0;
472}
473
474
475/*! A server request has been made to the server and the server has responded
476    with some data.  The data is known not to be an error and now the data can
477    be extracted into the user interface elements.
478*/
479
480void
481RatePackageWindow::_RelayServerDataToUI(BMessage& response)
482{
483	if (Lock()) {
484		response.FindString("code", &fRatingID);
485		response.FindBool("active", &fRatingActive);
486		BString comment;
487		if (response.FindString("comment", &comment) == B_OK) {
488			MarkupParser parser;
489			fRatingText = parser.CreateDocumentFromMarkup(comment);
490			fTextView->SetTextDocument(fRatingText);
491		}
492		if (response.FindString("userRatingStabilityCode",
493				&fStabilityCode) == B_OK) {
494			BMenu* menu = fStabilityField->Menu();
495			AppUtils::MarkItemWithKeyValueInMenu(menu, "code", fStabilityCode);
496		}
497		if (response.FindString("naturalLanguageCode",
498			&fCommentLanguageId) == B_OK && !comment.IsEmpty()) {
499			LanguageMenuUtils::MarkLanguageInMenu(
500				fCommentLanguageId, fCommentLanguageField->Menu());
501		}
502		double rating;
503		if (response.FindDouble("rating", &rating) == B_OK) {
504			fRating = (float)rating;
505			fRatingDeterminate = fRating >= 0.0f;
506			fSetRatingView->SetPermanentRating(fRating);
507		} else {
508			fRatingDeterminate = false;
509		}
510
511		fSetRatingView->SetRatingDeterminate(fRatingDeterminate);
512		fRatingDeterminateCheckBox->SetValue(
513			fRatingDeterminate ? B_CONTROL_ON : B_CONTROL_OFF);
514		fRatingActiveCheckBox->SetValue(fRatingActive);
515		fRatingActiveCheckBox->Show();
516
517		fSendButton->SetLabel(B_TRANSLATE("Update"));
518
519		Unlock();
520	} else
521		HDERROR("unable to acquire lock to update the ui");
522}
523
524
525void
526RatePackageWindow::_QueryRatingThread()
527{
528	if (!Lock()) {
529		HDERROR("rating query: Failed to lock window");
530		return;
531	}
532
533	PackageInfoRef package(fPackage);
534
535	Unlock();
536
537	BAutolock locker(fModel.Lock());
538	BString nickname = fModel.Nickname();
539	locker.Unlock();
540
541	if (!package.IsSet()) {
542		HDERROR("rating query: No package");
543		_SetWorkerThread(-1);
544		return;
545	}
546
547	WebAppInterface* interface = fModel.GetWebAppInterface();
548
549	BMessage info;
550	const DepotInfo* depot = fModel.DepotForName(package->DepotName());
551	BString webAppRepositoryCode;
552	BString webAppRepositorySourceCode;
553
554	if (depot != NULL) {
555		webAppRepositoryCode = depot->WebAppRepositoryCode();
556		webAppRepositorySourceCode = depot->WebAppRepositorySourceCode();
557	}
558
559	if (webAppRepositoryCode.IsEmpty()
560			|| webAppRepositorySourceCode.IsEmpty()) {
561		HDERROR("unable to obtain the repository code or repository source "
562			"code for depot; %s", package->DepotName().String());
563		BMessenger(this).SendMessage(B_QUIT_REQUESTED);
564	} else {
565		status_t status = interface->RetrieveUserRatingForPackageAndVersionByUser(
566				package->Name(), package->Version(), package->Architecture(),
567				webAppRepositoryCode, webAppRepositorySourceCode,
568				nickname, info);
569
570		if (status == B_OK) {
571				// could be an error or could be a valid response envelope
572				// containing data.
573			switch (WebAppInterface::ErrorCodeFromResponse(info)) {
574				case ERROR_CODE_NONE:
575				{
576					//info.PrintToStream();
577					BMessage result;
578					if (info.FindMessage("result", &result) == B_OK) {
579						_RelayServerDataToUI(result);
580					} else {
581						HDERROR("bad response envelope missing 'result' entry");
582						ServerHelper::NotifyTransportError(B_BAD_VALUE);
583						BMessenger(this).SendMessage(B_QUIT_REQUESTED);
584					}
585					break;
586				}
587				case ERROR_CODE_OBJECTNOTFOUND:
588						// an expected response
589					HDINFO("there was no previous rating for this"
590						" user on this version of this package so a new rating"
591						" will be added.");
592					break;
593				default:
594					ServerHelper::NotifyServerJsonRpcError(info);
595					BMessenger(this).SendMessage(B_QUIT_REQUESTED);
596					break;
597			}
598		} else {
599			HDERROR("an error has arisen communicating with the"
600				" server to obtain data for an existing rating [%s]",
601				strerror(status));
602			ServerHelper::NotifyTransportError(status);
603			BMessenger(this).SendMessage(B_QUIT_REQUESTED);
604		}
605	}
606
607	_SetWorkerThread(-1);
608}
609
610
611int32
612RatePackageWindow::_SendRatingThreadEntry(void* data)
613{
614	RatePackageWindow* window = reinterpret_cast<RatePackageWindow*>(data);
615	window->_SendRatingThread();
616	return 0;
617}
618
619
620void
621RatePackageWindow::_SendRatingThread()
622{
623	if (!Lock()) {
624		HDERROR("upload rating: Failed to lock window");
625		return;
626	}
627
628	BMessenger messenger = BMessenger(this);
629	BString package = fPackage->Name();
630	BString architecture = fPackage->Architecture();
631	BString webAppRepositoryCode;
632	BString webAppRepositorySourceCode;
633	int rating = (int)fRating;
634	BString stability = fStabilityCode;
635	BString comment = fRatingText->Text();
636	BString languageId = fCommentLanguageId;
637		// note that the language is a "code" in the server and "id" in ICU
638	BString ratingID = fRatingID;
639	bool active = fRatingActive;
640
641	if (!fRatingDeterminate)
642		rating = RATING_NONE;
643
644	const DepotInfo* depot = fModel.DepotForName(fPackage->DepotName());
645
646	if (depot != NULL) {
647		webAppRepositoryCode = depot->WebAppRepositoryCode();
648		webAppRepositorySourceCode = depot->WebAppRepositorySourceCode();
649	}
650
651	WebAppInterface* interface = fModel.GetWebAppInterface();
652
653	Unlock();
654
655	if (webAppRepositoryCode.IsEmpty()) {
656		HDERROR("unable to find the web app repository code for the "
657			"local depot %s",
658			fPackage->DepotName().String());
659		return;
660	}
661
662	if (webAppRepositorySourceCode.IsEmpty()) {
663		HDERROR("unable to find the web app repository source code for the "
664			"local depot %s",
665			fPackage->DepotName().String());
666		return;
667	}
668
669	if (stability == "unspecified")
670		stability = "";
671
672	status_t status;
673	BMessage info;
674	if (ratingID.Length() > 0) {
675		HDINFO("will update the existing user rating [%s]", ratingID.String());
676		status = interface->UpdateUserRating(ratingID,
677			languageId, comment, stability, rating, active, info);
678	} else {
679		HDINFO("will create a new user rating for pkg [%s]", package.String());
680		status = interface->CreateUserRating(package, fPackage->Version(),
681			architecture, webAppRepositoryCode, webAppRepositorySourceCode,
682			languageId, comment, stability, rating, info);
683	}
684
685	if (status == B_OK) {
686			// could be an error or could be a valid response envelope
687			// containing data.
688		switch (WebAppInterface::ErrorCodeFromResponse(info)) {
689			case ERROR_CODE_NONE:
690			{
691				if (ratingID.Length() > 0)
692					messenger.SendMessage(MSG_DID_UPDATE_USER_RATING);
693				else
694					messenger.SendMessage(MSG_DID_ADD_USER_RATING);
695				break;
696			}
697			default:
698				ServerHelper::NotifyServerJsonRpcError(info);
699				break;
700		}
701	} else {
702		HDERROR("an error has arisen communicating with the"
703			" server to obtain data for an existing rating [%s]",
704			strerror(status));
705		ServerHelper::NotifyTransportError(status);
706	}
707
708	messenger.SendMessage(B_QUIT_REQUESTED);
709	_SetWorkerThread(-1);
710}
711