diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 24b2643..230505b 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -29,7 +29,7 @@ jobs: - name: Build Web game image run: | - docker build -f web/Dockerfile -t "step3-web:${BUILD_TAG}" . + docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f web/Dockerfile -t "step3-web:${BUILD_TAG}" . - name: Save image run: | diff --git a/api/QuestionsApi.Tests/CcsCaseTests.cs b/api/QuestionsApi.Tests/CcsCaseTests.cs index 444858c..42856db 100644 --- a/api/QuestionsApi.Tests/CcsCaseTests.cs +++ b/api/QuestionsApi.Tests/CcsCaseTests.cs @@ -7,6 +7,21 @@ namespace QuestionsApi.Tests; public sealed class CcsCaseTests { + [Fact] + public void AppFeedbackValidationAcceptsSafeDiagnostics() + { + var request = new CreateAppFeedbackRequest { + Category = "bug", Details = "The next button stopped responding.", + Context = new AppFeedbackContext { Screen = "player", Platform = "Web", ViewportWidth = 1280, ViewportHeight = 720 } + }; + Assert.Null(AppFeedbackValidator.Validate(request)); + request.Category = "content_accuracy"; + Assert.Equal("category is invalid", AppFeedbackValidator.Validate(request)); + request.Category = "bug"; + request.Details = "short"; + Assert.Contains("details", AppFeedbackValidator.Validate(request)); + } + [Fact] public void CcsReportCategoriesAreSpecificAndStable() { diff --git a/api/QuestionsApi/Controllers/AppFeedbackController.cs b/api/QuestionsApi/Controllers/AppFeedbackController.cs new file mode 100644 index 0000000..657da97 --- /dev/null +++ b/api/QuestionsApi/Controllers/AppFeedbackController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController, Route("api/app-feedback")] +public sealed class AppFeedbackController(AppFeedbackStore feedback) : ControllerBase +{ + private const int MaxReviewerNoteLength = 2000; + + [HttpPost, Authorize(Roles = "USER,SUPER")] + public async Task Create(CreateAppFeedbackRequest request) + { + if (AppFeedbackValidator.Validate(request) is { } error) return BadRequest(error); + var report = await feedback.CreateAsync(UserId(), request); + return StatusCode(StatusCodes.Status201Created, new { id = report.Id, report.Category, report.Status, report.CreatedUtc }); + } + + [HttpGet, Authorize(Roles = "SUPER")] + public async Task List([FromQuery] string status = "open", [FromQuery] string category = "", [FromQuery] string userId = "", [FromQuery] int skip = 0, [FromQuery] int limit = 50) + { + status = status.Trim().ToLowerInvariant(); category = category.Trim().ToLowerInvariant(); userId = userId.Trim(); + if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status is invalid"); + if (category.Length > 0 && !AppFeedbackCategory.All.Contains(category)) return BadRequest("category is invalid"); + if (skip < 0 || limit is < 1 or > 100) return BadRequest("pagination is invalid"); + return Ok(await feedback.SearchAsync(status, category, userId, skip, limit)); + } + + [HttpGet("{id}"), Authorize(Roles = "SUPER")] + public async Task Get(string id) => await feedback.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound(); + + [HttpPatch("{id}/status"), Authorize(Roles = "SUPER")] + public async Task Close(string id, UpdateQuestionReportStatusRequest request) + { + var status = (request.Status ?? "").Trim().ToLowerInvariant(); + var note = (request.ReviewerNote ?? "").Trim(); + if (!QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status must be resolved or dismissed"); + if (note.Length > MaxReviewerNoteLength) return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer"); + var updated = await feedback.CloseAsync(id.Trim(), status, UserId(), note); + return updated is null ? Conflict("feedback is missing or already closed") : Ok(updated); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; +} diff --git a/api/QuestionsApi/Models/AppFeedback.cs b/api/QuestionsApi/Models/AppFeedback.cs new file mode 100644 index 0000000..3f3fbb3 --- /dev/null +++ b/api/QuestionsApi/Models/AppFeedback.cs @@ -0,0 +1,78 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class AppFeedbackCategory +{ + public const string Bug = "bug"; + public const string Usability = "usability"; + public const string FeatureRequest = "feature_request"; + public const string Account = "account"; + public const string Other = "other"; + public static readonly HashSet All = [Bug, Usability, FeatureRequest, Account, Other]; +} + +public sealed class AppFeedbackContext +{ + public string Screen { get; set; } = ""; + public string SessionType { get; set; } = ""; + public string Platform { get; set; } = ""; + public int ViewportWidth { get; set; } + public int ViewportHeight { get; set; } + public string AppVersion { get; set; } = ""; + public string BuildRevision { get; set; } = ""; + public DateTime? ClientUtc { get; set; } +} + +public sealed class AppFeedback +{ + [BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string Category { get; set; } = ""; + public string Details { get; set; } = ""; + public AppFeedbackContext Context { get; set; } = new(); + public string Status { get; set; } = QuestionReportStatus.Open; + public string ReviewedByUserId { get; set; } = ""; + public string ReviewerNote { get; set; } = ""; + public DateTime? ReviewedUtc { get; set; } + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; +} + +public sealed class CreateAppFeedbackRequest +{ + public string Category { get; set; } = ""; + public string Details { get; set; } = ""; + public AppFeedbackContext Context { get; set; } = new(); +} + +public sealed class AppFeedbackListResponse +{ + public List Items { get; set; } = []; + public long Total { get; set; } + public int Skip { get; set; } + public int Limit { get; set; } +} + +public static class AppFeedbackValidator +{ + public const int MinDetailsLength = 10; + public const int MaxDetailsLength = 4000; + public const int MaxContextLength = 120; + + public static string? Validate(CreateAppFeedbackRequest request) + { + var category = (request.Category ?? "").Trim().ToLowerInvariant(); + var details = (request.Details ?? "").Trim(); + if (!AppFeedbackCategory.All.Contains(category)) return "category is invalid"; + if (details.Length is < MinDetailsLength or > MaxDetailsLength) + return $"details must be between {MinDetailsLength} and {MaxDetailsLength} characters"; + var context = request.Context ?? new AppFeedbackContext(); + if (context.ViewportWidth < 0 || context.ViewportHeight < 0) return "viewport dimensions cannot be negative"; + if (new[] { context.Screen, context.SessionType, context.Platform, context.AppVersion, context.BuildRevision } + .Any(value => (value ?? "").Length > MaxContextLength)) + return $"context values must be {MaxContextLength} characters or fewer"; + return null; + } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index a649060..d579b7c 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -20,6 +20,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/README.md b/api/QuestionsApi/README.md index 94762eb..c222f96 100644 --- a/api/QuestionsApi/README.md +++ b/api/QuestionsApi/README.md @@ -18,6 +18,10 @@ STEP-style question bank service for study games and clients. - `GET /api/question-reports` List and filter reports, newest first (SUPER only). - `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only). - `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only). +- `POST /api/app-feedback` Submit general feedback with safe client context (USER or SUPER). +- `GET /api/app-feedback` List and filter app feedback, newest first (SUPER only). +- `GET /api/app-feedback/{id}` Get one app feedback report (SUPER only). +- `PATCH /api/app-feedback/{id}/status` Resolve or dismiss app feedback (SUPER only). Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`. diff --git a/api/QuestionsApi/Services/AppFeedbackStore.cs b/api/QuestionsApi/Services/AppFeedbackStore.cs new file mode 100644 index 0000000..20d6e86 --- /dev/null +++ b/api/QuestionsApi/Services/AppFeedbackStore.cs @@ -0,0 +1,56 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class AppFeedbackStore +{ + private readonly IMongoCollection _feedback; + + public AppFeedbackStore(MongoDatabaseProvider mongo) + { + _feedback = mongo.Database.GetCollection("AppFeedback"); + _feedback.Indexes.CreateMany([ + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.Category).Descending(value => value.CreatedUtc)), + new CreateIndexModel(Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)) + ]); + } + + public async Task CreateAsync(string userId, CreateAppFeedbackRequest request) + { + var now = DateTime.UtcNow; + var report = new AppFeedback { + UserId = userId, Category = request.Category.Trim().ToLowerInvariant(), Details = request.Details.Trim(), + Context = request.Context ?? new AppFeedbackContext(), CreatedUtc = now, UpdatedUtc = now + }; + await _feedback.InsertOneAsync(report); + return report; + } + + public async Task GetAsync(string id) => ObjectId.TryParse(id, out _) + ? await _feedback.Find(value => value.Id == id).FirstOrDefaultAsync() : null; + + public async Task SearchAsync(string status, string category, string userId, int skip, int limit) + { + var filter = Builders.Filter.Empty; + if (status != "all") filter &= Builders.Filter.Eq(value => value.Status, status); + if (category.Length > 0) filter &= Builders.Filter.Eq(value => value.Category, category); + if (userId.Length > 0) filter &= Builders.Filter.Eq(value => value.UserId, userId); + var total = await _feedback.CountDocumentsAsync(filter); + var items = await _feedback.Find(filter).SortByDescending(value => value.CreatedUtc).Skip(skip).Limit(limit).ToListAsync(); + return new AppFeedbackListResponse { Items = items, Total = total, Skip = skip, Limit = limit }; + } + + public async Task CloseAsync(string id, string status, string reviewer, string note) + { + if (!ObjectId.TryParse(id, out _)) return null; + var now = DateTime.UtcNow; + return await _feedback.FindOneAndUpdateAsync( + value => value.Id == id && value.Status == QuestionReportStatus.Open, + Builders.Update.Set(value => value.Status, status).Set(value => value.ReviewerNote, note) + .Set(value => value.ReviewedByUserId, reviewer).Set(value => value.ReviewedUtc, now).Set(value => value.UpdatedUtc, now), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); + } +} diff --git a/project.godot b/project.godot index ec67628..77bd935 100644 --- a/project.godot +++ b/project.godot @@ -11,6 +11,7 @@ config_version=5 [application] config/name="step3" +config/version="pre-alpha" run/main_scene="res://scenes/main.tscn" config/features=PackedStringArray("4.6", "GL Compatibility") config/icon="res://icon.svg" @@ -26,6 +27,7 @@ ProfileService="*res://scripts/autoload/profile_service.gd" BlockSessionService="*res://scripts/autoload/block_session_service.gd" ExamSimulationService="*res://scripts/autoload/exam_simulation_service.gd" CCSService="*res://scripts/autoload/ccs_service.gd" +FeedbackService="*res://scripts/autoload/feedback_service.gd" [display] @@ -49,3 +51,5 @@ textures/vram_compression/import_etc2_astc=true questions_api_url="https://s3q.ranaze.com/api/questions/search" question_reports_api_url="https://s3q.ranaze.com/api/question-reports" +app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" +build_revision="development" diff --git a/scripts/autoload/feedback_service.gd b/scripts/autoload/feedback_service.gd new file mode 100644 index 0000000..d9eec74 --- /dev/null +++ b/scripts/autoload/feedback_service.gd @@ -0,0 +1,74 @@ +extends Node + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_API_URL := "https://s3q.ranaze.com/api/app-feedback" +const REQUEST_TIMEOUT_SECONDS := 30.0 + +signal feedback_submitted(receipt_id: String) +signal feedback_failed(message: String) + +var api_url := DEFAULT_API_URL +var request: HTTPRequest +var is_submitting := false + + +func _ready() -> void: + api_url = EndpointConfig.resolve( + str(ProjectSettings.get_setting("step3/app_feedback_api_url", DEFAULT_API_URL)), + "/api/app-feedback") + request = HTTPRequest.new() + request.timeout = REQUEST_TIMEOUT_SECONDS + request.request_completed.connect(_on_request_completed) + add_child(request) + + +func submit(category: String, details: String, context: Dictionary) -> void: + if is_submitting: + return + if AuthState.access_token.is_empty(): + feedback_failed.emit("Log in before sending feedback.") + return + is_submitting = true + var headers := PackedStringArray([ + "Content-Type: application/json", + "Authorization: Bearer %s" % AuthState.access_token + ]) + var payload := {"category": category, "details": details, "context": context} + var error := request.request(api_url, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if error != OK: + is_submitting = false + feedback_failed.emit("The feedback request could not be started.") + + +func build_context(screen_name: String, viewport_size: Vector2) -> Dictionary: + var session_type := "" + if BlockSessionService.block_started: + session_type = BlockSessionService.session_kind + return { + "screen": screen_name.left(120), + "sessionType": session_type.left(120), + "platform": OS.get_name().left(120), + "viewportWidth": maxi(0, int(round(viewport_size.x))), + "viewportHeight": maxi(0, int(round(viewport_size.y))), + "appVersion": str(ProjectSettings.get_setting("application/config/version", "pre-alpha")).left(120), + "buildRevision": str(ProjectSettings.get_setting("step3/build_revision", "development")).left(120), + "clientUtc": "%sZ" % Time.get_datetime_string_from_system(true) + } + + +func _on_request_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + is_submitting = false + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + var detail := body.get_string_from_utf8().strip_edges().left(240) + var message := "Feedback submission failed with status %d." % response_code + if result != HTTPRequest.RESULT_SUCCESS and response_code == 0: + message = "The connection was interrupted. Your feedback is still in the form." + elif not detail.is_empty(): + message += " %s" % detail + feedback_failed.emit(message) + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY or str(parsed.get("id", "")).is_empty(): + feedback_failed.emit("The feedback service returned an invalid receipt.") + return + feedback_submitted.emit(str(parsed.get("id", ""))) diff --git a/scripts/autoload/feedback_service.gd.uid b/scripts/autoload/feedback_service.gd.uid new file mode 100644 index 0000000..ac0295c --- /dev/null +++ b/scripts/autoload/feedback_service.gd.uid @@ -0,0 +1 @@ +uid://dcrkkx7mvpju3 diff --git a/scripts/ui/app_feedback_dialog.gd b/scripts/ui/app_feedback_dialog.gd new file mode 100644 index 0000000..9c74c06 --- /dev/null +++ b/scripts/ui/app_feedback_dialog.gd @@ -0,0 +1,207 @@ +extends Control + +const Style := preload("res://scripts/ui/ui_style.gd") +const MIN_DETAILS := 10 +const MAX_DETAILS := 4000 +const CATEGORIES := [ + {"id": "bug", "label": "Bug / technical issue"}, + {"id": "usability", "label": "Usability"}, + {"id": "feature_request", "label": "Feature request"}, + {"id": "account", "label": "Account"}, + {"id": "other", "label": "Other feedback"} +] + +var panel: PanelContainer +var form: VBoxContainer +var success: VBoxContainer +var group: ButtonGroup +var details: TextEdit +var count: Label +var error_label: Label +var submit_button: Button +var cancel_button: Button +var receipt_label: Label +var context_label: Label +var current_context := {} + + +func _ready() -> void: + visible = false + top_level = true + mouse_filter = Control.MOUSE_FILTER_STOP + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var dim := ColorRect.new() + dim.color = Color(0, 0, 0, 0.72) + dim.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(dim) + var center := CenterContainer.new() + center.set_anchors_preset(Control.PRESET_FULL_RECT) + add_child(center) + panel = PanelContainer.new() + panel.custom_minimum_size = Vector2(700, 590) + panel.add_theme_stylebox_override("panel", Style.panel_box(Style.SURFACE_DARK, Style.CYAN)) + center.add_child(panel) + var root := VBoxContainer.new() + root.add_theme_constant_override("separation", 12) + panel.add_child(root) + var title := Label.new() + title.text = "Send feedback" + Style.set_label(title, 26, Style.TEXT) + root.add_child(title) + form = VBoxContainer.new() + form.add_theme_constant_override("separation", 10) + root.add_child(form) + var prompt := Label.new() + prompt.text = "What would you like to report?" + Style.set_label(prompt, 17, Style.CYAN) + form.add_child(prompt) + group = ButtonGroup.new() + group.allow_unpress = false + var category_grid := GridContainer.new() + category_grid.columns = 2 + form.add_child(category_grid) + for category in CATEGORIES: + var option := CheckBox.new() + option.text = str(category["label"]) + option.set_meta("category", str(category["id"])) + option.button_group = group + option.toggled.connect(func(_pressed: bool): _refresh()) + category_grid.add_child(option) + var detail_header := HBoxContainer.new() + form.add_child(detail_header) + var detail_title := Label.new() + detail_title.text = "Details" + detail_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + detail_header.add_child(detail_title) + count = Label.new() + detail_header.add_child(count) + details = TextEdit.new() + details.placeholder_text = "Describe what happened, what you expected, and how to reproduce it when applicable." + details.custom_minimum_size = Vector2(0, 210) + details.size_flags_vertical = Control.SIZE_EXPAND_FILL + details.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY + details.text_changed.connect(_refresh) + form.add_child(details) + context_label = Label.new() + context_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(context_label, 13, Style.MUTED) + form.add_child(context_label) + error_label = Label.new() + error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(error_label, 14, Style.RED) + form.add_child(error_label) + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 10) + form.add_child(actions) + cancel_button = Button.new() + cancel_button.text = "Cancel" + cancel_button.pressed.connect(hide_dialog) + actions.add_child(cancel_button) + submit_button = Button.new() + submit_button.text = "Send feedback" + submit_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + Style.style_primary_button(submit_button) + submit_button.pressed.connect(_submit) + actions.add_child(submit_button) + success = VBoxContainer.new() + success.visible = false + success.alignment = BoxContainer.ALIGNMENT_CENTER + success.add_theme_constant_override("separation", 16) + root.add_child(success) + receipt_label = Label.new() + receipt_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + receipt_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + Style.set_label(receipt_label, 21, Style.LIME) + success.add_child(receipt_label) + var close := Button.new() + close.text = "Close" + Style.style_primary_button(close) + close.pressed.connect(_close_success) + success.add_child(close) + FeedbackService.feedback_submitted.connect(_submitted) + FeedbackService.feedback_failed.connect(_failed) + resized.connect(_refresh_size) + _refresh_size() + _refresh() + + +func show_dialog(context: Dictionary) -> void: + current_context = context.duplicate(true) + form.visible = true + success.visible = false + error_label.text = "" + context_label.text = "Included context: %s, %s, %dx%d, version %s (%s)." % [ + context.get("screen", "unknown"), context.get("platform", "unknown"), + context.get("viewportWidth", 0), context.get("viewportHeight", 0), + context.get("appVersion", "unknown"), context.get("buildRevision", "unknown")] + visible = true + move_to_front() + BlockSessionService.pause_current_question_timer() + _refresh() + + +func hide_dialog() -> void: + if FeedbackService.is_submitting: + return + visible = false + BlockSessionService.resume_current_question_timer() + + +func _close_success() -> void: + details.text = "" + for button in group.get_buttons(): + button.button_pressed = false + hide_dialog() + + +func _unhandled_input(event: InputEvent) -> void: + if visible and event.is_action_pressed("ui_cancel"): + hide_dialog() + get_viewport().set_input_as_handled() + + +func _refresh_size() -> void: + if panel != null: + panel.custom_minimum_size = Vector2(minf(700.0, maxf(300.0, size.x - 32.0)), minf(590.0, maxf(430.0, size.y - 32.0))) + + +func _refresh() -> void: + if details == null: + return + var length := details.text.strip_edges().length() + count.text = "%d / %d" % [length, MAX_DETAILS] + var selected := group.get_pressed_button() + var category := "" if selected == null else str(selected.get_meta("category", "")) + submit_button.disabled = FeedbackService.is_submitting or not is_valid_submission(category, details.text) + cancel_button.disabled = FeedbackService.is_submitting + + +static func is_valid_submission(category: String, value: String) -> bool: + var valid_categories := ["bug", "usability", "feature_request", "account", "other"] + var length := value.strip_edges().length() + return valid_categories.has(category) and length >= MIN_DETAILS and length <= MAX_DETAILS + + +func _submit() -> void: + var selected := group.get_pressed_button() + if selected == null: + return + error_label.text = "" + submit_button.text = "Sending..." + _refresh() + FeedbackService.submit(str(selected.get_meta("category", "")), details.text.strip_edges(), current_context) + + +func _submitted(receipt_id: String) -> void: + submit_button.text = "Send feedback" + form.visible = false + success.visible = true + receipt_label.text = "Feedback sent\nReceipt: %s" % receipt_id + UISoundService.play_sound("confirmation") + + +func _failed(message: String) -> void: + submit_button.text = "Send feedback" + error_label.text = message + _refresh() + UISoundService.play_sound("error") diff --git a/scripts/ui/app_feedback_dialog.gd.uid b/scripts/ui/app_feedback_dialog.gd.uid new file mode 100644 index 0000000..74681f2 --- /dev/null +++ b/scripts/ui/app_feedback_dialog.gd.uid @@ -0,0 +1 @@ +uid://b0bipxy8ymuj6 diff --git a/scripts/ui/ccs_player.gd b/scripts/ui/ccs_player.gd index 2e27d8a..fea3238 100644 --- a/scripts/ui/ccs_player.gd +++ b/scripts/ui/ccs_player.gd @@ -16,6 +16,7 @@ var end_button: Button var time_row: HBoxContainer var report_dialog: Control var report_button: Button +var app_feedback_button: Button var workspace: HSplitContainer var leave_button: Button var leaving := false @@ -32,6 +33,7 @@ func _ready() -> void: var larger := Button.new(); larger.text = "A+"; larger.tooltip_text = "Increase text size"; larger.pressed.connect(_zoom.bind(CCSService.TEXT_SCALE_STEP)); top.add_child(larger) var clear_highlights := Button.new(); clear_highlights.text = "Clear"; clear_highlights.tooltip_text = "Clear highlights"; clear_highlights.pressed.connect(_clear_highlights); top.add_child(clear_highlights) report_button = Button.new(); report_button.text = "Report"; report_button.tooltip_text = "Report CCS case"; report_button.pressed.connect(_report); top.add_child(report_button) + app_feedback_button = Button.new(); app_feedback_button.text = "Feedback"; app_feedback_button.tooltip_text = "Send general feedback or report a bug"; app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()); top.add_child(app_feedback_button) theme_button = Button.new(); theme_button.custom_minimum_size = Vector2(44, 44); theme_button.add_theme_font_size_override("font_size", 22); UISoundService.set_button_sound(theme_button, "toggle"); theme_button.pressed.connect(theme_service.toggle_theme); top.add_child(theme_button); _refresh_theme_button() workspace = HSplitContainer.new(); workspace.size_flags_vertical = Control.SIZE_EXPAND_FILL; workspace.split_offset = 850; add_child(workspace) var chart_scroll := ScrollContainer.new(); chart_scroll.custom_minimum_size.x = 500; chart_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL; workspace.add_child(chart_scroll) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 3b9c003..784f678 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -10,6 +10,8 @@ var app_menu: PanelContainer var menu_exit_button: Button var sound_toggle: CheckButton var theme_button: Button +var feedback_button: Button +var feedback_dialog: Control var theme_service var current_screen_name := "" @@ -96,6 +98,14 @@ func _build_ui() -> void: user_label.gui_input.connect(_on_user_label_gui_input) header.add_child(user_label) + feedback_button = Button.new() + feedback_button.text = "Feedback" + feedback_button.tooltip_text = "Send general feedback or report a bug" + feedback_button.custom_minimum_size = Vector2(100, 44) + Style.style_chip(feedback_button, Style.CYAN) + feedback_button.pressed.connect(open_feedback_dialog) + header.add_child(feedback_button) + theme_button = Button.new() theme_button.custom_minimum_size = Vector2(44, 44) theme_button.add_theme_font_size_override("font_size", 22) @@ -194,6 +204,10 @@ func _build_ui() -> void: screens_container.add_child(profile) screens["profile"] = profile + feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() + feedback_dialog.name = "AppFeedbackDialog" + add_child(feedback_dialog) + ProfileService.profile_changed.connect(_refresh_header_stats) QuestionBankService.ratings_changed.connect(_refresh_header_stats) AuthState.auth_changed.connect(_refresh_header_stats) @@ -219,6 +233,14 @@ func _refresh_header_stats() -> void: ] if user_label != null: user_label.text = AuthState.username if AuthState.is_logged_in else "" + if feedback_button != null: + feedback_button.visible = AuthState.is_logged_in + + +func open_feedback_dialog() -> void: + if not AuthState.is_logged_in or feedback_dialog == null: + return + feedback_dialog.show_dialog(FeedbackService.build_context(current_screen_name, get_viewport_rect().size)) func _on_theme_changed(is_dark_mode: bool) -> void: diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index d425d3d..4acbe28 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -65,6 +65,7 @@ var text_larger_button: Button var labs_button: Button var flag_button: Button var report_button: Button +var app_feedback_button: Button var theme_button: Button var question_toolbar: HBoxContainer var nav_palette: HBoxContainer @@ -233,6 +234,14 @@ func _ready() -> void: report_button.pressed.connect(_toggle_report_current_question) question_toolbar.add_child(report_button) + app_feedback_button = Button.new() + app_feedback_button.text = "Feedback" + app_feedback_button.tooltip_text = "Send general feedback or report a bug" + app_feedback_button.custom_minimum_size = Vector2(92, 44) + Style.style_chip(app_feedback_button, Style.CYAN) + app_feedback_button.pressed.connect(func(): main.open_feedback_dialog()) + question_toolbar.add_child(app_feedback_button) + top.add_child(question_toolbar) add_child(top) diff --git a/tests/post_answer_review_test.gd b/tests/post_answer_review_test.gd index 10a2c8e..419bf31 100644 --- a/tests/post_answer_review_test.gd +++ b/tests/post_answer_review_test.gd @@ -13,6 +13,8 @@ func _initialize() -> void: _test_collapsed_followup_review_expands() _test_question_failure_retry_classification() _test_transient_question_failure_keeps_player_waiting() + _test_feedback_context_excludes_study_content() + _test_feedback_form_validation() quit(1 if failures > 0 else 0) @@ -169,6 +171,24 @@ func _test_transient_question_failure_keeps_player_waiting() -> void: player.free() +func _test_feedback_context_excludes_study_content() -> void: + var feedback = root.get_node("FeedbackService") + var context: Dictionary = feedback.build_context("player", Vector2(1280, 720)) + _assert(context.get("screen", "") == "player", "feedback context should identify the current screen") + _assert(context.get("viewportWidth", 0) == 1280, "feedback context should include viewport width") + _assert(context.get("viewportHeight", 0) == 720, "feedback context should include viewport height") + _assert(not context.has("question"), "feedback context must not include question content") + _assert(not context.has("answer"), "feedback context must not include answer content") + _assert(not context.has("accessToken"), "feedback context must not include authentication tokens") + + +func _test_feedback_form_validation() -> void: + var dialog_script = load("res://scripts/ui/app_feedback_dialog.gd") + _assert(dialog_script.is_valid_submission("bug", "The next button does not respond."), "valid feedback should be accepted") + _assert(not dialog_script.is_valid_submission("unknown", "The next button does not respond."), "unknown categories should be rejected") + _assert(not dialog_script.is_valid_submission("bug", "Too short"), "short feedback should be rejected") + + func _assert(condition: bool, message: String) -> void: if condition: return diff --git a/web/Dockerfile b/web/Dockerfile index e5631a4..9fda59e 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -3,6 +3,7 @@ FROM ubuntu:24.04 AS exporter ARG GODOT_VERSION=4.6.2 ARG GODOT_RELEASE=${GODOT_VERSION}-stable ARG GODOT_TEMPLATE_DIR=${GODOT_VERSION}.stable +ARG BUILD_REVISION=development RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -28,6 +29,7 @@ RUN curl -fsSLO "https://github.com/godotengine/godot-builds/releases/download/$ WORKDIR /src COPY . . RUN mkdir -p /build \ + && sed -i "s|build_revision=\"development\"|build_revision=\"${BUILD_REVISION}\"|" project.godot \ && sed -i "s|custom_template/release=\"\"|custom_template/release=\"/root/.local/share/godot/export_templates/${GODOT_TEMPLATE_DIR}/web_nothreads_release.zip\"|" export_presets.cfg \ && godot --headless --editor --path /src --quit \ && godot --verbose --headless --path /src --export-release Web /build/index.html \