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