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