diff --git a/scenes/chat_ui.tscn b/scenes/chat_ui.tscn new file mode 100644 index 0000000..2993592 --- /dev/null +++ b/scenes/chat_ui.tscn @@ -0,0 +1,202 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/chat_ui.gd" id="1_cu"] + +[node name="ChatUI" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_cu") + +[node name="VBoxContainer" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = 32.0 +offset_right = -32.0 +offset_bottom = -32.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "< Back" + +[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 26 +text = "Chat & Messaging" +horizontal_alignment = 1 + +[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"] +layout_mode = 2 +horizontal_alignment = 1 + +[node name="TabContainer" type="TabContainer" parent="VBoxContainer"] +layout_mode = 2 +size_flags_vertical = 3 +current_tab = 0 + +[node name="WorldChat" type="VBoxContainer" parent="VBoxContainer/TabContainer"] +layout_mode = 2 +theme_override_constants/separation = 8 +metadata/_tab_index = 0 + +[node name="ControlsHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="WorldPrefix" type="Label" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"] +layout_mode = 2 +text = "World ID:" + +[node name="WorldIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"] +custom_minimum_size = Vector2(120, 32) +layout_mode = 2 +text = "1" + +[node name="JoinChatButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/ControlsHBox"] +custom_minimum_size = Vector2(110, 32) +layout_mode = 2 +text = "Switch Channel" + +[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/WorldChat"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/WorldChat/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/WorldChat"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Type a message in world chat..." + +[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/WorldChat/SendHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Send" + +[node name="DirectMessages" type="VBoxContainer" parent="VBoxContainer/TabContainer"] +visible = false +layout_mode = 2 +theme_override_constants/separation = 8 +metadata/_tab_index = 1 + +[node name="StartHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="TargetUserEdit" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Enter User ID to start conversation..." + +[node name="StartDmButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/StartHBox"] +custom_minimum_size = Vector2(140, 32) +layout_mode = 2 +text = "Start Direct Chat" + +[node name="HSplit" type="HSplitContainer" parent="VBoxContainer/TabContainer/DirectMessages"] +layout_mode = 2 +size_flags_vertical = 3 +split_offset = 260 + +[node name="ConversationsVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_stretch_ratio = 0.35 +theme_override_constants/separation = 6 + +[node name="ConvHeader" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Conversations" + +[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ConvList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ConversationsVBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 4 + +[node name="ChatVBox" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 + +[node name="DmHeaderLabel" type="Label" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Select a conversation" + +[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="MessagesList" type="VBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 8 + +[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="MessageInput" type="LineEdit" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Type a direct message..." + +[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/DirectMessages/HSplit/ChatVBox/SendHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Send" + +[node name="ReportDialog" type="ConfirmationDialog" parent="."] +title = "Report Inappropriate Message" +size = Vector2i(420, 200) + +[node name="VBox" type="VBoxContainer" parent="ReportDialog"] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 412.0 +offset_bottom = 151.0 +theme_override_constants/separation = 8 + +[node name="DetailsLabel" type="Label" parent="ReportDialog/VBox"] +layout_mode = 2 +autowrap_mode = 2 + +[node name="ReasonPrompt" type="Label" parent="ReportDialog/VBox"] +layout_mode = 2 +text = "Reason for report:" + +[node name="ReasonEdit" type="LineEdit" parent="ReportDialog/VBox"] +layout_mode = 2 +text = "Inappropriate content" diff --git a/scripts/chat_ui.gd b/scripts/chat_ui.gd new file mode 100644 index 0000000..14ad8c6 --- /dev/null +++ b/scripts/chat_ui.gd @@ -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() diff --git a/scripts/smoke_test_chat_ui.gd b/scripts/smoke_test_chat_ui.gd new file mode 100644 index 0000000..859d895 --- /dev/null +++ b/scripts/smoke_test_chat_ui.gd @@ -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)