Add general app feedback reporting

This commit is contained in:
hz
2026-07-15 20:55:13 -05:00
parent 98fa98b5b1
commit aec56f168e
17 changed files with 544 additions and 1 deletions
+74
View File
@@ -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", "")))
+1
View File
@@ -0,0 +1 @@
uid://dcrkkx7mvpju3
+207
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://b0bipxy8ymuj6
+2
View File
@@ -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)
+22
View File
@@ -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:
+9
View File
@@ -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)