331 lines
11 KiB
GDScript
331 lines
11 KiB
GDScript
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()
|