Implement Godot chat UI with DMs, world chat, real-time WebSocket receipt, and reporting (task 9b05df0c6869)

This commit is contained in:
2026-09-15 19:35:13 +00:00
parent 056e5c9f86
commit 484b455e44
3 changed files with 637 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
extends Control
class_name ChatUI
## Chat UI for Direct Messages, World/Room Chat, and Message Reporting.
##
## Features:
## - DM Conversations list + message thread
## - In-experience World/Room chat thread
## - Real-time WebSocket event dispatch for new messages
## - Inline "Report" button on each message with reporting popup
## - Rate limiting and error feedback display
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var title_label: Label = $VBoxContainer/HeaderHBox/TitleLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
# World chat controls
@onready var world_id_edit: LineEdit = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/WorldIdEdit
@onready var join_world_chat_btn: Button = $VBoxContainer/TabContainer/WorldChat/ControlsHBox/JoinChatButton
@onready var world_messages_container: VBoxContainer = $VBoxContainer/TabContainer/WorldChat/Scroll/MessagesList
@onready var world_msg_input: LineEdit = $VBoxContainer/TabContainer/WorldChat/SendHBox/MessageInput
@onready var world_send_btn: Button = $VBoxContainer/TabContainer/WorldChat/SendHBox/SendButton
# DM controls
@onready var target_user_edit: LineEdit = $VBoxContainer/TabContainer/DirectMessages/StartHBox/TargetUserEdit
@onready var start_dm_btn: Button = $VBoxContainer/TabContainer/DirectMessages/StartHBox/StartDmButton
@onready var conv_list_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll/ConvList
@onready var dm_header_label: Label = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/DmHeaderLabel
@onready var dm_messages_container: VBoxContainer = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll/MessagesList
@onready var dm_msg_input: LineEdit = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/MessageInput
@onready var dm_send_btn: Button = $VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox/SendButton
# Report Popup
@onready var report_dialog: ConfirmationDialog = $ReportDialog
@onready var report_reason_edit: LineEdit = $ReportDialog/VBox/ReasonEdit
@onready var report_details_label: Label = $ReportDialog/VBox/DetailsLabel
var _api_client: Node = null
var _current_world_id: int = 1
var _active_conversation_id: int = 0
var _reporting_message_id: int = 0
var _reporting_conv_type: String = ""
var _reporting_context: String = ""
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
refresh_world_chat()
refresh_dm_conversations()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_active_tab)
# World chat
if join_world_chat_btn:
join_world_chat_btn.pressed.connect(_on_join_world_chat)
if world_send_btn:
world_send_btn.pressed.connect(_on_send_world_message)
if world_msg_input:
world_msg_input.text_submitted.connect(func(_t): _on_send_world_message())
# DM chat
if start_dm_btn:
start_dm_btn.pressed.connect(_on_start_dm_pressed)
if dm_send_btn:
dm_send_btn.pressed.connect(_on_send_dm_message)
if dm_msg_input:
dm_msg_input.text_submitted.connect(func(_t): _on_send_dm_message())
# Report dialog
if report_dialog:
report_dialog.confirmed.connect(_on_report_confirmed)
# WebSocket real-time hook
if _api_client != null and _api_client.ws != null:
if not _api_client.ws.chat_message_received.is_connected(_on_ws_chat_message):
_api_client.ws.chat_message_received.connect(_on_ws_chat_message)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
func refresh_active_tab() -> void:
if tab_container == null:
return
if tab_container.current_tab == 0:
refresh_world_chat()
else:
refresh_dm_conversations()
if _active_conversation_id > 0:
refresh_dm_messages(_active_conversation_id)
# ── World Chat ──
func _on_join_world_chat() -> void:
var wid_str := world_id_edit.text.strip_edges()
if not wid_str.is_valid_int():
show_message("Enter a valid integer world ID.", true)
return
_current_world_id = int(wid_str)
if _api_client != null and _api_client.ws != null:
_api_client.ws.subscribe_world(_current_world_id)
show_message("Joined world %d chat." % _current_world_id)
refresh_world_chat()
func refresh_world_chat() -> void:
if _api_client == null or not _api_client.has_token():
return
var res: Dictionary = await _api_client.get_world_chat_messages(_current_world_id)
_clear_children(world_messages_container)
if not res.ok:
show_message("Failed to load world chat: " + res.detail, true)
return
var msgs: Array = []
if res.data is Dictionary and res.data.has("messages"):
msgs = res.data["messages"]
elif res.data is Array:
msgs = res.data
for m in msgs:
_add_message_row(world_messages_container, m, "world")
func _on_send_world_message() -> void:
var content := world_msg_input.text.strip_edges()
if content == "":
return
var res: Dictionary = await _api_client.send_world_chat_message(_current_world_id, content)
if res.ok:
world_msg_input.text = ""
refresh_world_chat()
else:
show_message("Send failed: " + res.detail, true)
# ── Direct Messages (DMs) ──
func refresh_dm_conversations() -> void:
if _api_client == null or not _api_client.has_token():
return
var res: Dictionary = await _api_client.get_dm_conversations()
_clear_children(conv_list_container)
if not res.ok:
show_message("Failed to load DMs: " + res.detail, true)
return
var convs: Array = []
if res.data is Dictionary and res.data.has("conversations"):
convs = res.data["conversations"]
elif res.data is Array:
convs = res.data
if convs.is_empty():
var empty_lbl := Label.new()
empty_lbl.text = "No conversations yet."
empty_lbl.modulate = Color(0.6, 0.6, 0.6)
conv_list_container.add_child(empty_lbl)
return
for c in convs:
var cid: int = int(c.get("id", 0))
var other_name: String = "Conversation #%d" % cid
if c.has("other_user") and c["other_user"] is Dictionary:
other_name = str(c["other_user"].get("username", other_name))
var btn := Button.new()
btn.text = other_name
btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
btn.pressed.connect(func(): _select_dm_conversation(cid, other_name))
conv_list_container.add_child(btn)
func _select_dm_conversation(cid: int, name_hint: String) -> void:
_active_conversation_id = cid
if dm_header_label:
dm_header_label.text = "Chat with " + name_hint
if _api_client != null and _api_client.ws != null:
_api_client.ws.subscribe_dm(cid)
refresh_dm_messages(cid)
func refresh_dm_messages(cid: int) -> void:
if _api_client == null or cid <= 0:
return
var res: Dictionary = await _api_client.get_dm_messages(cid)
_clear_children(dm_messages_container)
if not res.ok:
show_message("Failed to load messages: " + res.detail, true)
return
var msgs: Array = []
if res.data is Dictionary and res.data.has("messages"):
msgs = res.data["messages"]
elif res.data is Array:
msgs = res.data
for m in msgs:
_add_message_row(dm_messages_container, m, "dm")
func _on_start_dm_pressed() -> void:
var target_text := target_user_edit.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.start_dm_conversation(target_id)
if res.ok and res.data is Dictionary:
var cid: int = int(res.data.get("id", 0))
target_user_edit.text = ""
show_message("Conversation started!")
await refresh_dm_conversations()
_select_dm_conversation(cid, "User #%d" % target_id)
else:
show_message("Failed to start conversation: " + res.detail, true)
func _on_send_dm_message() -> void:
if _active_conversation_id <= 0:
show_message("Select or start a conversation first.", true)
return
var content := dm_msg_input.text.strip_edges()
if content == "":
return
var res: Dictionary = await _api_client.send_dm_message(_active_conversation_id, content)
if res.ok:
dm_msg_input.text = ""
refresh_dm_messages(_active_conversation_id)
else:
show_message("Send failed: " + res.detail, true)
# ── Message Row & Reporting ──
func _add_message_row(container: VBoxContainer, msg_data: Dictionary, conv_type: String) -> void:
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 8)
var mid: int = int(msg_data.get("id", 0))
var author: String = "User #%s" % str(msg_data.get("author_id", msg_data.get("sender_id", "?")))
if msg_data.has("author") and msg_data["author"] is Dictionary:
author = str(msg_data["author"].get("username", author))
elif msg_data.has("sender") and msg_data["sender"] is Dictionary:
author = str(msg_data["sender"].get("username", author))
var content: String = str(msg_data.get("content", ""))
var label := Label.new()
label.text = "[%s]: %s" % [author, content]
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
hbox.add_child(label)
var report_btn := Button.new()
report_btn.text = "Report"
report_btn.pressed.connect(func(): _open_report_dialog(mid, conv_type, content))
hbox.add_child(report_btn)
container.add_child(hbox)
func _open_report_dialog(msg_id: int, conv_type: String, context_text: String) -> void:
_reporting_message_id = msg_id
_reporting_conv_type = conv_type
_reporting_context = context_text
if report_details_label:
report_details_label.text = "Reporting message: \"%s\"" % context_text.left(60)
if report_reason_edit:
report_reason_edit.text = "Inappropriate content"
if report_dialog:
report_dialog.popup_centered()
func _on_report_confirmed() -> void:
if _reporting_message_id <= 0 or _api_client == null:
return
var reason := report_reason_edit.text.strip_edges() if report_reason_edit else "Inappropriate content"
var res: Dictionary = await _api_client.report_message(_reporting_message_id, _reporting_conv_type, reason, _reporting_context)
if res.ok:
show_message("Message reported to moderation queue. Thank you!")
else:
show_message("Failed to report message: " + res.detail, true)
# ── Real-Time WS Hook ──
func _on_ws_chat_message(channel: String, id: int, data: Dictionary) -> void:
if channel == "world" and id == _current_world_id:
_add_message_row(world_messages_container, data, "world")
elif channel == "dm" and id == _active_conversation_id:
_add_message_row(dm_messages_container, data, "dm")
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+105
View File
@@ -0,0 +1,105 @@
extends SceneTree
## Headless smoke test for ChatUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Chat UI verification...")
var scene_res := load("res://scenes/chat_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/chat_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate ChatUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.world_messages_container == null:
printerr("FAIL: world_messages_container is null")
quit(1)
return
if ui.dm_messages_container == null:
printerr("FAIL: dm_messages_container is null")
quit(1)
return
if ui.conv_list_container == null:
printerr("FAIL: conv_list_container is null")
quit(1)
return
if ui.report_dialog == null:
printerr("FAIL: report_dialog is null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test message row creation with Report button
var mock_msg := {
"id": 101,
"author": {"username": "ChattyUser"},
"content": "Hello world! This is a test message."
}
ui._add_message_row(ui.world_messages_container, mock_msg, "world")
if ui.world_messages_container.get_child_count() == 0:
printerr("FAIL: Message row was not added to world_messages_container")
quit(1)
return
var row: HBoxContainer = ui.world_messages_container.get_child(0)
var found_report := false
for child in row.get_children():
if child is Button and child.text == "Report":
found_report = true
break
if not found_report:
printerr("FAIL: Report button not found on message row")
quit(1)
return
print("PASS: Message row with Report button verified.")
# Test report dialog preparation
ui._open_report_dialog(101, "world", "Hello world! This is a test message.")
if ui._reporting_message_id != 101 or ui._reporting_conv_type != "world":
printerr("FAIL: Report dialog state not set correctly")
quit(1)
return
print("PASS: Report dialog setup verified.")
# Test real-time WS chat message hook
var mock_ws_event := {
"id": 102,
"author": {"username": "LiveUser"},
"content": "Real-time incoming message!"
}
ui._on_ws_chat_message("world", ui._current_world_id, mock_ws_event)
if ui.world_messages_container.get_child_count() < 2:
printerr("FAIL: Live WS message was not appended to world chat")
quit(1)
return
print("PASS: Real-time WS chat message append verified.")
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)