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