From 54be7bbb2545e68016f3fba33b15fec368bd3f35 Mon Sep 17 00:00:00 2001 From: adtpdn Date: Thu, 30 Apr 2026 04:18:46 +0800 Subject: [PATCH] feat: bug fix social system --- CHANGELOG_DRAFT.md | 9 + scenes/lobby.gd | 67 +++++-- scenes/ui/boot_screen.tscn | 2 +- scenes/ui/social_panel.tscn | 201 ++++++++++++------- scripts/managers/friend_manager.gd | 137 ++++++++++--- scripts/ui/friend_row.gd | 2 +- scripts/ui/social_panel.gd | 312 ++++++++++++++++++----------- server/docker-compose.yaml | 3 + server/nakama/tekton_admin.js | 109 ++++++++++ 9 files changed, 607 insertions(+), 235 deletions(-) diff --git a/CHANGELOG_DRAFT.md b/CHANGELOG_DRAFT.md index e64a08c..e4bfc06 100644 --- a/CHANGELOG_DRAFT.md +++ b/CHANGELOG_DRAFT.md @@ -1,5 +1,14 @@ ## [NEXT] +## [2.2.0] — 2026-04-30 +- Redesigned Social Panel with a 3-tab layout (Search, Requests, Friends) to fix UI overlap issues. +- Fixed an issue where offline friend requests were not being delivered properly. +- Added persistent storage for Direct Messages, loading previous chat history when opening a DM. +- Improved real-time Nakama socket listening to allow incoming DMs to be received instantly even when the chat tab is closed. +- Fixed account state pollution where friend data leaked when switching users without restarting the client. +- Corrected server-side RPCs for Nakama `send_friend_request` ensuring persistent push notifications. +- Re-themed Social Panel DM UI to use dark brown fonts for high-contrast readability. + ## [2.1.9] — 2026-04-29 - Added friend system with friend list, direct messaging, and lobby invitations - Improved Steam login support diff --git a/scenes/lobby.gd b/scenes/lobby.gd index 5af709b..e8e34d5 100644 --- a/scenes/lobby.gd +++ b/scenes/lobby.gd @@ -125,7 +125,7 @@ var _bot_names: Dictionary = {} # ============================================================================= # Chat System # ============================================================================= -const GLOBAL_CHAT_ROOM := "global_lobby" +const GLOBAL_CHAT_ROOM := "social_global" var _chat_channel = null var _chat_messages: Array = [] @@ -1311,15 +1311,30 @@ func _join_global_chat() -> void: if not socket.received_channel_message.is_connected(_on_chat_message_received): socket.received_channel_message.connect(_on_chat_message_received) - # Load history and render (Nakama sends recent messages on join via received_channel_message) + # Load history + _chat_messages.clear() + var history_result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, _chat_channel.id, 50, false) + if not history_result.is_exception() and history_result.messages: + var msgs = history_result.messages.duplicate() + msgs.reverse() # Oldest to newest + for msg in msgs: + _add_chat_message(msg, false) + + _trim_old_messages() _refresh_chat_display() func _on_chat_message_received(message) -> void: """Nakama socket signal: a message arrived on any channel.""" - # message is ApiChannelMessage — use direct property access, NOT .get() if _chat_channel == null or message.channel_id != _chat_channel.id: return + # Ignore messages from ourselves (we inject them locally for instant feedback) + if NakamaManager.session and message.sender_id == NakamaManager.session.user_id: + return + + _add_chat_message(message, true) + +func _add_chat_message(message, refresh_display: bool) -> void: # content is a String (JSON) — parse to extract our "msg" field var text: String = "" var parsed = JSON.parse_string(message.content) @@ -1330,8 +1345,10 @@ func _on_chat_message_received(message) -> void: # Sender: use username property directly (falls back to first 8 chars of sender_id) var sender: String = message.username - if sender.is_empty(): + if sender.is_empty() and message.sender_id: sender = message.sender_id.substr(0, 8) + elif sender.is_empty(): + sender = "Unknown" # Timestamp → HH:MM var ts_str: String = _format_nakama_time(message.create_time) @@ -1340,11 +1357,12 @@ func _on_chat_message_received(message) -> void: "sender": sender, "content": text, "ts": ts_str, - "date": message.create_time.substr(0, 10) + "date": message.create_time.substr(0, 10) if message.create_time else Time.get_date_string_from_system() }) - _trim_old_messages() - _refresh_chat_display() + if refresh_display: + _trim_old_messages() + _refresh_chat_display() func _on_chat_send_pressed() -> void: """Send a message to the global chat channel.""" @@ -1355,10 +1373,11 @@ func _on_chat_send_pressed() -> void: chat_input.text = "" chat_input.grab_focus() + # Instantly show locally for best UX + _inject_local_message(text) + var socket = NakamaManager.socket if not socket or _chat_channel == null: - # Offline fallback: show locally only - _inject_local_message(text) return # Nakama GDScript SDK: write_chat_message_async takes a Dictionary, not a JSON string @@ -1366,8 +1385,6 @@ func _on_chat_send_pressed() -> void: var result = await socket.write_chat_message_async(_chat_channel.id, content) if result.is_exception(): push_warning("[Chat] Failed to send message: " + result.get_exception().message) - # Still show it locally - _inject_local_message(text) func _inject_local_message(text: String) -> void: """Display a message as the local player when offline/fallback.""" @@ -1382,9 +1399,9 @@ func _inject_local_message(text: String) -> void: _refresh_chat_display() func _trim_old_messages() -> void: - """Remove messages from previous calendar days (daily clear).""" - var today: String = Time.get_date_string_from_system() - _chat_messages = _chat_messages.filter(func(m): return m.get("date", today) == today) + """Keep only the most recent 100 messages to prevent memory/UI bloat.""" + if _chat_messages.size() > 100: + _chat_messages = _chat_messages.slice(-100) func _refresh_chat_display() -> void: """Re-render the RichTextLabel with all buffered messages.""" @@ -1402,20 +1419,28 @@ func _refresh_chat_display() -> void: # Scroll to bottom await get_tree().process_frame - chat_display.scroll_to_line(chat_display.get_line_count()) + if chat_display: + var scrollbar = chat_display.get_v_scroll_bar() + if scrollbar: + chat_display.scroll_to_line(chat_display.get_line_count()) -func _format_nakama_time(iso_str: String) -> String: - """Convert Nakama ISO timestamp '2026-04-14T10:30:00Z' → local 'HH:MM'.""" - # Parse the UTC time components - if iso_str.length() < 19: +func _format_nakama_time(time_str: String) -> String: + """Convert Nakama time to local 'HH:MM'.""" + # Nakama returns UNIX epoch as string (e.g. "1714418656") or ISO string. + if time_str.is_valid_int(): + var unix_time = time_str.to_int() + var dict = Time.get_time_dict_from_unix_time(unix_time) + return "%02d:%02d" % [dict.hour, dict.minute] + + # Fallback for ISO strings or empty + if time_str.length() < 19: return _get_local_time_hhmm() - var t_parts = iso_str.split("T") + var t_parts = time_str.split("T") if t_parts.size() < 2: return _get_local_time_hhmm() var time_part = t_parts[1].replace("Z", "").split(":") if time_part.size() < 2: return _get_local_time_hhmm() - # Use UTC hours/minutes directly (simple, avoids TZ complexity in Godot) return "%s:%s" % [time_part[0], time_part[1]] func _get_local_time_hhmm() -> String: diff --git a/scenes/ui/boot_screen.tscn b/scenes/ui/boot_screen.tscn index 3f886d1..3bce672 100644 --- a/scenes/ui/boot_screen.tscn +++ b/scenes/ui/boot_screen.tscn @@ -1,4 +1,4 @@ -[gd_scene format=3 uid="uid://bdfogx1k2q1fl"] +[gd_scene format=3 uid="uid://c330qhn1hqr3b"] [ext_resource type="Script" uid="uid://vgyrq5y5p7jw" path="res://scripts/ui/boot_screen.gd" id="1_boot"] [ext_resource type="Texture2D" uid="uid://2d1ks5pmblc7" path="res://assets/graphics/main_menu/bg_back.png" id="2_17ab1"] diff --git a/scenes/ui/social_panel.tscn b/scenes/ui/social_panel.tscn index c5a64e4..f7423e2 100644 --- a/scenes/ui/social_panel.tscn +++ b/scenes/ui/social_panel.tscn @@ -24,10 +24,10 @@ anchor_left = 0.5 anchor_top = 0.5 anchor_right = 0.5 anchor_bottom = 0.5 -offset_left = -240.0 -offset_top = -280.0 -offset_right = 240.0 -offset_bottom = 280.0 +offset_left = -250.0 +offset_top = -310.0 +offset_right = 250.0 +offset_bottom = 310.0 [node name="VBox" type="VBoxContainer" parent="Panel"] layout_mode = 2 @@ -39,141 +39,208 @@ layout_mode = 2 unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "Social" [node name="CloseBtn" type="Button" parent="Panel/VBox/Header"] unique_name_in_owner = true layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "X" -[node name="HSep" type="HSeparator" parent="Panel/VBox"] +[node name="HSep0" type="HSeparator" parent="Panel/VBox"] layout_mode = 2 [node name="TabBar" type="HBoxContainer" parent="Panel/VBox"] layout_mode = 2 +[node name="SearchTabBtn" type="Button" parent="Panel/VBox/TabBar"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Search" + +[node name="RequestsTabBtn" type="Button" parent="Panel/VBox/TabBar"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Requests" + [node name="FriendsTabBtn" type="Button" parent="Panel/VBox/TabBar"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "Friends" -[node name="GlobalTabBtn" type="Button" parent="Panel/VBox/TabBar"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -text = "Global Chat" - [node name="DMTabBtn" type="Button" parent="Panel/VBox/TabBar"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "DM" visible = false -[node name="ContentStack" type="Control" parent="Panel/VBox"] +[node name="HSep1" type="HSeparator" parent="Panel/VBox"] layout_mode = 2 -size_flags_vertical = 3 -custom_minimum_size = Vector2(0, 380) -[node name="FriendsView" type="VBoxContainer" parent="Panel/VBox/ContentStack"] +[node name="SearchView" type="VBoxContainer" parent="Panel/VBox"] unique_name_in_owner = true -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 - -[node name="AddFriendRow" type="HBoxContainer" parent="Panel/VBox/ContentStack/FriendsView"] -layout_mode = 2 - -[node name="AddFriendInput" type="LineEdit" parent="Panel/VBox/ContentStack/FriendsView/AddFriendRow"] -unique_name_in_owner = true -layout_mode = 2 -size_flags_horizontal = 3 -placeholder_text = "Username or ID..." - -[node name="AddFriendBtn" type="Button" parent="Panel/VBox/ContentStack/FriendsView/AddFriendRow"] -unique_name_in_owner = true -layout_mode = 2 -text = "Add" - -[node name="FriendScroll" type="ScrollContainer" parent="Panel/VBox/ContentStack/FriendsView"] layout_mode = 2 size_flags_vertical = 3 -[node name="FriendList" type="VBoxContainer" parent="Panel/VBox/ContentStack/FriendsView/FriendScroll"] +[node name="SearchRow" type="HBoxContainer" parent="Panel/VBox/SearchView"] +layout_mode = 2 + +[node name="SearchInput" type="LineEdit" parent="Panel/VBox/SearchView/SearchRow"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 +placeholder_text = "Search by username..." -[node name="GlobalView" type="VBoxContainer" parent="Panel/VBox/ContentStack"] +[node name="SearchBtn" type="Button" parent="Panel/VBox/SearchView/SearchRow"] unique_name_in_owner = true -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Search" + +[node name="NoSearchResultsLabel" type="Label" parent="Panel/VBox/SearchView"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "No users found." visible = false -[node name="GlobalLog" type="RichTextLabel" parent="Panel/VBox/ContentStack/GlobalView"] -unique_name_in_owner = true +[node name="SearchScroll" type="ScrollContainer" parent="Panel/VBox/SearchView"] layout_mode = 2 size_flags_vertical = 3 -bbcode_enabled = true -scroll_following = true -[node name="GlobalInputRow" type="HBoxContainer" parent="Panel/VBox/ContentStack/GlobalView"] -layout_mode = 2 - -[node name="GlobalInput" type="LineEdit" parent="Panel/VBox/ContentStack/GlobalView/GlobalInputRow"] +[node name="SearchResultsList" type="VBoxContainer" parent="Panel/VBox/SearchView/SearchScroll"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 -placeholder_text = "Type a message..." -[node name="GlobalSendBtn" type="Button" parent="Panel/VBox/ContentStack/GlobalView/GlobalInputRow"] +[node name="SearchResultTemplate" type="HBoxContainer" parent="Panel/VBox/SearchView"] unique_name_in_owner = true layout_mode = 2 -text = "Send" - -[node name="DMView" type="VBoxContainer" parent="Panel/VBox/ContentStack"] -unique_name_in_owner = true -layout_mode = 1 -anchors_preset = 15 -anchor_right = 1.0 -anchor_bottom = 1.0 visible = false -[node name="DMHeader" type="HBoxContainer" parent="Panel/VBox/ContentStack/DMView"] +[node name="SRNameLabel" type="Label" parent="Panel/VBox/SearchView/SearchResultTemplate"] layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) -[node name="DMBackBtn" type="Button" parent="Panel/VBox/ContentStack/DMView/DMHeader"] +[node name="SRAddBtn" type="Button" parent="Panel/VBox/SearchView/SearchResultTemplate"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Add Friend" + +[node name="RequestsView" type="VBoxContainer" parent="Panel/VBox"] unique_name_in_owner = true layout_mode = 2 +size_flags_vertical = 3 +visible = false + +[node name="NoRequestsLabel" type="Label" parent="Panel/VBox/RequestsView"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "No incoming friend requests." +visible = false + +[node name="RequestsScroll" type="ScrollContainer" parent="Panel/VBox/RequestsView"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="RequestsList" type="VBoxContainer" parent="Panel/VBox/RequestsView/RequestsScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="RequestRowTemplate" type="HBoxContainer" parent="Panel/VBox/RequestsView"] +unique_name_in_owner = true +layout_mode = 2 +visible = false + +[node name="RRNameLabel" type="Label" parent="Panel/VBox/RequestsView/RequestRowTemplate"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) + +[node name="RRAcceptBtn" type="Button" parent="Panel/VBox/RequestsView/RequestRowTemplate"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Accept" + +[node name="RRDeclineBtn" type="Button" parent="Panel/VBox/RequestsView/RequestRowTemplate"] +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "Decline" + +[node name="FriendsView" type="VBoxContainer" parent="Panel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +visible = false + +[node name="NoFriendsLabel" type="Label" parent="Panel/VBox/FriendsView"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) +text = "No friends yet!" +visible = false + +[node name="FriendScroll" type="ScrollContainer" parent="Panel/VBox/FriendsView"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="FriendList" type="VBoxContainer" parent="Panel/VBox/FriendsView/FriendScroll"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="DMView" type="VBoxContainer" parent="Panel/VBox"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_vertical = 3 +visible = false + +[node name="DMHeader" type="HBoxContainer" parent="Panel/VBox/DMView"] +layout_mode = 2 + +[node name="DMBackBtn" type="Button" parent="Panel/VBox/DMView/DMHeader"] +unique_name_in_owner = true +layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "<- Back" -[node name="DMUsernameLabel" type="Label" parent="Panel/VBox/ContentStack/DMView/DMHeader"] +[node name="DMUsernameLabel" type="Label" parent="Panel/VBox/DMView/DMHeader"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) -[node name="DMLog" type="RichTextLabel" parent="Panel/VBox/ContentStack/DMView"] +[node name="DMLog" type="RichTextLabel" parent="Panel/VBox/DMView"] unique_name_in_owner = true layout_mode = 2 size_flags_vertical = 3 +theme_override_colors/default_color = Color(0.3, 0.18, 0.1, 1) bbcode_enabled = true scroll_following = true -[node name="DMInputRow" type="HBoxContainer" parent="Panel/VBox/ContentStack/DMView"] +[node name="DMInputRow" type="HBoxContainer" parent="Panel/VBox/DMView"] layout_mode = 2 -[node name="DMInput" type="LineEdit" parent="Panel/VBox/ContentStack/DMView/DMInputRow"] +[node name="DMInput" type="LineEdit" parent="Panel/VBox/DMView/DMInputRow"] unique_name_in_owner = true layout_mode = 2 size_flags_horizontal = 3 placeholder_text = "Type a message..." -[node name="DMSendBtn" type="Button" parent="Panel/VBox/ContentStack/DMView/DMInputRow"] +[node name="DMSendBtn" type="Button" parent="Panel/VBox/DMView/DMInputRow"] unique_name_in_owner = true layout_mode = 2 +theme_override_colors/font_color = Color(0.3, 0.18, 0.1, 1) text = "Send" - diff --git a/scripts/managers/friend_manager.gd b/scripts/managers/friend_manager.gd index aa7a3ce..b494ba6 100644 --- a/scripts/managers/friend_manager.gd +++ b/scripts/managers/friend_manager.gd @@ -7,7 +7,8 @@ signal lobby_invite_received(from_user_id: String, from_name: String, match_id: signal dm_message_received(from_user_id: String, from_name: String, message: String) ## Notification codes (must match server-side RPC) -const NOTIF_LOBBY_INVITE := 1001 +const NOTIF_LOBBY_INVITE := 1001 +const NOTIF_FRIEND_REQUEST := 1002 ## Friend state codes from Nakama const STATE_FRIEND := 0 @@ -15,18 +16,59 @@ const STATE_INVITE_OUT := 1 const STATE_INVITE_IN := 2 const STATE_BLOCKED := 3 -var friends: Array = [] # [{user_id, username, state}] -var _friend_ids: Dictionary = {} # user_id -> true (mutual friends only) +var friends: Array = [] # [{user_id, username, state}] +var _friend_ids: Dictionary = {} # user_id -> true (mutual friends only) var _dm_channels: Dictionary = {} # user_id -> NakamaChannel +var _has_pending_requests: bool = false # set before UI is ready func _ready() -> void: NakamaManager.connected_to_nakama.connect(_on_nakama_connected) + AuthManager.logged_out.connect(_on_logged_out) if NakamaManager.socket and NakamaManager.socket.is_connected_to_host(): _connect_socket_signals() +func _on_logged_out() -> void: + print("[FriendManager] Clearing state on logout") + friends.clear() + _friend_ids.clear() + close_all_dm_channels() + _has_pending_requests = false + emit_signal("friends_updated", []) + func _on_nakama_connected() -> void: _connect_socket_signals() - load_friends() + await load_friends() + _fetch_pending_notifications() + +func _fetch_pending_notifications() -> void: + if not NakamaManager.session or not NakamaManager.client: + return + print("[FriendManager] _fetch_pending_notifications: fetching...") + # Retrieve all persistent notifications queued while offline (up to 100) + var result = await NakamaManager.client.list_notifications_async( + NakamaManager.session, 100, null) + if result.is_exception(): + push_warning("[FriendManager] Failed to fetch notifications: " + result.get_exception().message) + return + + print("[FriendManager] _fetch_pending_notifications: count=%d" % result.notifications.size()) + var has_friend_request := false + var ids_to_delete: PackedStringArray = PackedStringArray() + + for notif in result.notifications: + print("[FriendManager] notification: code=%d sender=%s" % [notif.code, notif.sender_id]) + ids_to_delete.append(notif.id) + if notif.code == NOTIF_FRIEND_REQUEST: + has_friend_request = true + + # Acknowledge all so they are not delivered again next login + if ids_to_delete.size() > 0: + NakamaManager.client.delete_notifications_async(NakamaManager.session, ids_to_delete) + + # If any were friend requests, reload the friends list to show them + if has_friend_request: + print("[FriendManager] _fetch_pending_notifications: reloading friends for pending request") + load_friends() func _connect_socket_signals() -> void: var socket = NakamaManager.socket @@ -43,13 +85,16 @@ func _connect_socket_signals() -> void: func load_friends() -> void: if not NakamaManager.session: + print("[FriendManager] load_friends: no session") return - var result = await NakamaManager.client.list_friends_async(NakamaManager.session, 100, null, null) + print("[FriendManager] load_friends: fetching...") + var result = await NakamaManager.client.list_friends_async(NakamaManager.session, null, 100, null) if result.is_exception(): push_warning("[FriendManager] Failed to load friends: " + result.get_exception().message) return friends.clear() _friend_ids.clear() + _has_pending_requests = false for f in result.friends: var u = f.user var entry := { @@ -58,8 +103,12 @@ func load_friends() -> void: "state": f.state, } friends.append(entry) + print("[FriendManager] friend: %s state=%d" % [u.username, f.state]) if f.state == STATE_FRIEND: _friend_ids[u.id] = true + if f.state == STATE_INVITE_IN: + _has_pending_requests = true + print("[FriendManager] load_friends done: %d entries" % friends.size()) emit_signal("friends_updated", friends) func get_mutual_friends() -> Array: @@ -71,17 +120,30 @@ func is_friend(user_id: String) -> bool: func add_friend_by_id(user_id: String) -> bool: if not NakamaManager.session: return false + print("[FriendManager] add_friend_by_id: adding %s" % user_id) + # Step 1: Add the friend relationship via native Nakama client API var result = await NakamaManager.client.add_friends_async( NakamaManager.session, PackedStringArray([user_id]), null) if result.is_exception(): push_warning("[FriendManager] add_friend failed: " + result.get_exception().message) return false + print("[FriendManager] add_friend_by_id: add_friends_async OK, sending notification RPC...") + # Step 2: Notify the target via RPC + var payload = JSON.stringify({"user_id": user_id}) + var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "send_friend_request", payload) + if rpc_result.is_exception(): + push_error("[FriendManager] rpcSendFriendRequest failed: " + rpc_result.get_exception().message) + else: + print("[FriendManager] rpcSendFriendRequest OK: " + str(rpc_result.payload)) + load_friends() return true func add_friend_by_username(username: String) -> bool: if not NakamaManager.session: return false + # Resolve username → user_id first via search, then use the RPC + # Fall back to direct Nakama API (no notification sent to target) var result = await NakamaManager.client.add_friends_async( NakamaManager.session, null, PackedStringArray([username])) if result.is_exception(): @@ -114,12 +176,18 @@ func send_lobby_invite(to_user_id: String, match_id: String) -> void: push_warning("[FriendManager] send_lobby_invite failed: " + result.get_exception().message) func _on_notification_received(notification) -> void: - if notification.code == NOTIF_LOBBY_INVITE: - var content = JSON.parse_string(notification.content) - if content: - var from_name: String = content.get("from_name", "Someone") - var match_id: String = content.get("match_id", "") - emit_signal("lobby_invite_received", notification.sender_id, from_name, match_id) + print("[FriendManager] _on_notification_received: code=%d sender=%s" % [notification.code, notification.sender_id]) + match notification.code: + NOTIF_LOBBY_INVITE: + var content = JSON.parse_string(notification.content) + if content: + var from_name: String = content.get("from_name", "Someone") + var match_id: String = content.get("match_id", "") + emit_signal("lobby_invite_received", notification.sender_id, from_name, match_id) + NOTIF_FRIEND_REQUEST: + # Refresh friends list so the incoming request appears immediately + print("[FriendManager] friend request notification received, reloading friends...") + load_friends() # ============================================================================= # Direct Messages @@ -139,6 +207,30 @@ func open_dm(user_id: String) -> Object: _dm_channels[user_id] = channel return channel +func get_dm_history(user_id: String, limit: int = 50) -> Array: + if not NakamaManager.session: + return [] + var channel = await open_dm(user_id) + if not channel: + return [] + var result = await NakamaManager.client.list_channel_messages_async(NakamaManager.session, channel.id, limit, true) + if result.is_exception(): + push_warning("[FriendManager] Failed to fetch DM history: " + result.get_exception().message) + return [] + + var history: Array = [] + for msg in result.messages: + var text: String = "" + var parsed = JSON.parse_string(msg.content) + if typeof(parsed) == TYPE_DICTIONARY: + text = parsed.get("msg", msg.content) + else: + text = msg.content + history.append({"from": msg.sender_id, "msg": text, "username": msg.username}) + + history.reverse() # Oldest to newest + return history + func send_dm(user_id: String, message: String) -> bool: var channel = await open_dm(user_id) if not channel: @@ -154,18 +246,17 @@ func get_dm_channel_id(user_id: String) -> String: return ch.id if ch else "" func _on_channel_message(message) -> void: - # Route to DM signal if this message is from a DM channel - for user_id in _dm_channels: - var ch = _dm_channels[user_id] - if ch.id == message.channel_id: - var text: String = "" - var parsed = JSON.parse_string(message.content) - if typeof(parsed) == TYPE_DICTIONARY: - text = parsed.get("msg", message.content) - else: - text = message.content - emit_signal("dm_message_received", message.sender_id, message.username, text) - return + # Ignore global chat + if "social_global" in message.channel_id: + return + + var text: String = "" + var parsed = JSON.parse_string(message.content) + if typeof(parsed) == TYPE_DICTIONARY: + text = parsed.get("msg", message.content) + else: + text = message.content + emit_signal("dm_message_received", message.sender_id, message.username, text) func close_all_dm_channels() -> void: var socket = NakamaManager.socket diff --git a/scripts/ui/friend_row.gd b/scripts/ui/friend_row.gd index 2d1beb8..87c8945 100644 --- a/scripts/ui/friend_row.gd +++ b/scripts/ui/friend_row.gd @@ -23,7 +23,7 @@ func setup(uid: String, uname: String, state: int, panel: Control) -> void: FriendManager.STATE_FRIEND: _dm_btn.visible = true _remove_btn.visible = true - _dm_btn.pressed.connect(func(): panel.call("_open_dm", uid, uname)) + _dm_btn.pressed.connect(func(): panel.call("open_dm", uid, uname)) _remove_btn.pressed.connect(func(): FriendManager.remove_friend(uid)) FriendManager.STATE_INVITE_OUT: _state_label.text = "(invite sent)" diff --git a/scripts/ui/social_panel.gd b/scripts/ui/social_panel.gd index bbc25ed..ac19d8c 100644 --- a/scripts/ui/social_panel.gd +++ b/scripts/ui/social_panel.gd @@ -1,140 +1,216 @@ extends Control -## SocialPanel — Friend list with DM and global chat tabs. -## Nodes defined in social_panel.tscn; this script handles all logic. +## SocialPanel — Search, Requests, Friends, DM tabs. +## All UI nodes are defined in social_panel.tscn. signal closed -# ─── Node references via %UniqueName ───────────────────────────────────── -@onready var _close_btn: Button = %CloseBtn -@onready var _friends_tab_btn: Button = %FriendsTabBtn -@onready var _global_tab_btn: Button = %GlobalTabBtn -@onready var _dm_tab_btn: Button = %DMTabBtn -@onready var _friends_view: VBoxContainer = %FriendsView -@onready var _global_view: VBoxContainer = %GlobalView -@onready var _dm_view: VBoxContainer = %DMView -@onready var _add_friend_input: LineEdit = %AddFriendInput -@onready var _add_friend_btn: Button = %AddFriendBtn -@onready var _friend_list: VBoxContainer = %FriendList -@onready var _global_log: RichTextLabel = %GlobalLog -@onready var _global_input: LineEdit = %GlobalInput -@onready var _global_send_btn: Button = %GlobalSendBtn -@onready var _dm_back_btn: Button = %DMBackBtn -@onready var _dm_username_label: Label = %DMUsernameLabel -@onready var _dm_log: RichTextLabel = %DMLog -@onready var _dm_input: LineEdit = %DMInput -@onready var _dm_send_btn: Button = %DMSendBtn +# ─── Node references ───────────────────────────────────────────────────────── +@onready var _close_btn: Button = %CloseBtn -# ─── State ──────────────────────────────────────────────────────────────── -var _active_dm_user_id: String = "" -var _active_dm_username: String = "" -var _dm_history: Dictionary = {} -var _global_chat_channel = null -var _current_tab: String = "friends" +# Tab buttons +@onready var _search_tab_btn: Button = %SearchTabBtn +@onready var _requests_tab_btn: Button = %RequestsTabBtn +@onready var _friends_tab_btn: Button = %FriendsTabBtn +@onready var _dm_tab_btn: Button = %DMTabBtn -# ─── Lifecycle ──────────────────────────────────────────────────────────── +# Views +@onready var _search_view: VBoxContainer = %SearchView +@onready var _requests_view: VBoxContainer = %RequestsView +@onready var _friends_view: VBoxContainer = %FriendsView +@onready var _dm_view: VBoxContainer = %DMView + +# Search tab nodes +@onready var _search_input: LineEdit = %SearchInput +@onready var _search_btn: Button = %SearchBtn +@onready var _no_search_results: Label = %NoSearchResultsLabel +@onready var _search_results_list: VBoxContainer = %SearchResultsList +@onready var _search_result_tmpl: HBoxContainer = %SearchResultTemplate + +# Requests tab nodes +@onready var _no_requests_label: Label = %NoRequestsLabel +@onready var _requests_list: VBoxContainer = %RequestsList +@onready var _request_row_tmpl: HBoxContainer = %RequestRowTemplate + +# Friends tab nodes +@onready var _no_friends_label: Label = %NoFriendsLabel +@onready var _friend_list: VBoxContainer = %FriendList + +# DM tab nodes +@onready var _dm_back_btn: Button = %DMBackBtn +@onready var _dm_username_label: Label = %DMUsernameLabel +@onready var _dm_log: RichTextLabel = %DMLog +@onready var _dm_input: LineEdit = %DMInput +@onready var _dm_send_btn: Button = %DMSendBtn + +# ─── State ─────────────────────────────────────────────────────────────────── +var _current_tab: String = "search" +var _active_dm_user_id: String = "" +var _dm_history: Dictionary = {} + +# ─── Lifecycle ─────────────────────────────────────────────────────────────── func _ready() -> void: _close_btn.pressed.connect(func(): emit_signal("closed"); hide()) + + # Tab buttons + _search_tab_btn.pressed.connect(func(): _show_tab("search")) + _requests_tab_btn.pressed.connect(func(): _show_tab("requests")) _friends_tab_btn.pressed.connect(func(): _show_tab("friends")) - _global_tab_btn.pressed.connect(func(): _show_tab("global")) - _dm_tab_btn.pressed.connect(func(): _show_tab("dm")) - _add_friend_btn.pressed.connect(_on_add_friend_pressed) - _add_friend_input.text_submitted.connect(func(_t): _on_add_friend_pressed()) - _global_send_btn.pressed.connect(_send_global_message) - _global_input.text_submitted.connect(func(_t): _send_global_message()) + _dm_back_btn.pressed.connect(func(): _show_tab("friends")) + + # Search + _search_btn.pressed.connect(_on_search_pressed) + _search_input.text_submitted.connect(func(_t): _on_search_pressed()) + + # DM _dm_send_btn.pressed.connect(_send_dm) _dm_input.text_submitted.connect(func(_t): _send_dm()) - _dm_back_btn.pressed.connect(func(): _show_tab("friends")) - - FriendManager.friends_updated.connect(_refresh_friend_list) - FriendManager.dm_message_received.connect(_on_dm_received) - NakamaManager.connected_to_nakama.connect(_join_global_chat) - if NakamaManager.socket and NakamaManager.socket.is_connected_to_host(): - _join_global_chat() - FriendManager.load_friends() - _show_tab("friends") -func _refresh_friend_list(friends: Array) -> void: - if not _friend_list: + # FriendManager signals + FriendManager.friends_updated.connect(_on_friends_updated) + FriendManager.dm_message_received.connect(_on_dm_received) + + # Replay already-loaded friends in case FriendManager loaded before this panel was ready + if FriendManager.friends.size() > 0: + _on_friends_updated(FriendManager.friends) + else: + FriendManager.load_friends() + + _show_tab("search") + +# ─── Tab Switching ─────────────────────────────────────────────────────────── +func _show_tab(tab: String) -> void: + _current_tab = tab + _search_view.visible = tab == "search" + _requests_view.visible = tab == "requests" + _friends_view.visible = tab == "friends" + _dm_view.visible = tab == "dm" + + # Auto-load search results on first open + if tab == "search" and _search_results_list.get_child_count() == 0: + _on_search_pressed() + +# ─── Search Tab ────────────────────────────────────────────────────────────── +func _on_search_pressed() -> void: + var query := _search_input.text.strip_edges() + + # UUID → add directly + if query.length() == 36 and query.count("-") == 4: + FriendManager.add_friend_by_id(query) + _search_input.text = "" return + + _search_btn.disabled = true + var payload = JSON.stringify({"query": query}) + var result = await NakamaManager.client.rpc_async(NakamaManager.session, "search_users", payload) + _search_btn.disabled = false + + if result.is_exception(): + push_warning("[Social] Search failed: " + result.get_exception().message) + return + + var response = JSON.parse_string(result.payload) + if not response or not response.has("users"): + return + + _populate_search_results(response.users) + +func _populate_search_results(users: Array) -> void: + for ch in _search_results_list.get_children(): + ch.queue_free() + + _no_search_results.visible = users.is_empty() + + var my_id = NakamaManager.session.user_id if NakamaManager.session else "" + + for u in users: + if u.user_id == my_id: + continue # skip self + var row: HBoxContainer = _search_result_tmpl.duplicate() + row.show() + row.get_node("SRNameLabel").text = u.display_name + " (@" + u.username + ")" + var add_btn: Button = row.get_node("SRAddBtn") + add_btn.pressed.connect(func(): + FriendManager.add_friend_by_id(u.user_id) + add_btn.text = "Sent ✓" + add_btn.disabled = true + ) + _search_results_list.add_child(row) + +# ─── Requests Tab ──────────────────────────────────────────────────────────── +func _populate_requests(incoming: Array) -> void: + for ch in _requests_list.get_children(): + ch.queue_free() + + _no_requests_label.visible = incoming.is_empty() + + for f in incoming: + var row: HBoxContainer = _request_row_tmpl.duplicate() + row.show() + row.get_node("RRNameLabel").text = f.username + var accept_btn: Button = row.get_node("RRAcceptBtn") + var decline_btn: Button = row.get_node("RRDeclineBtn") + var uid: String = f.user_id + accept_btn.pressed.connect(func(): + FriendManager.add_friend_by_id(uid) + row.queue_free() + ) + decline_btn.pressed.connect(func(): + FriendManager.remove_friend(uid) + row.queue_free() + ) + _requests_list.add_child(row) + + # Badge on tab button + if incoming.is_empty(): + _requests_tab_btn.text = "Requests" + else: + _requests_tab_btn.text = "Requests (%d)" % incoming.size() + +# ─── Friends Tab ───────────────────────────────────────────────────────────── +func _populate_friends(mutual: Array) -> void: for ch in _friend_list.get_children(): ch.queue_free() - - if friends.is_empty(): - var empty_lbl := Label.new() - empty_lbl.text = "No friends yet. Add someone above!" - _friend_list.add_child(empty_lbl) - return - + + _no_friends_label.visible = mutual.is_empty() + var friend_row_scene := preload("res://scenes/ui/friend_row.tscn") - for f in friends: - var uid: String = f.get("user_id", "") - var uname: String = f.get("username", "?") - var state: int = f.get("state", 0) - var row: Control = friend_row_scene.instantiate() + for f in mutual: + var row: Control = friend_row_scene.instantiate() _friend_list.add_child(row) - row.setup(uid, uname, state, self) + row.setup(f.user_id, f.username, f.state, self) -func _on_add_friend_pressed() -> void: - var val := _add_friend_input.text.strip_edges() - if val.is_empty(): - return - _add_friend_input.text = "" - if val.length() == 36 and val.count("-") == 4: - FriendManager.add_friend_by_id(val) - else: - FriendManager.add_friend_by_username(val) +# ─── FriendManager Callbacks ───────────────────────────────────────────────── +func _on_friends_updated(friends: Array) -> void: + print("[SocialPanel] _on_friends_updated: total=%d" % friends.size()) + var incoming := friends.filter(func(f): return f.state == FriendManager.STATE_INVITE_IN) + var mutual := friends.filter(func(f): return f.state == FriendManager.STATE_FRIEND) + print("[SocialPanel] incoming=%d mutual=%d" % [incoming.size(), mutual.size()]) + _populate_requests(incoming) + _populate_friends(mutual) -func _join_global_chat() -> void: - if _global_chat_channel: - return - var socket = NakamaManager.socket - if not socket: - return - var channel = await socket.join_chat_async( - "social_global", NakamaSocket.ChannelType.Room, true, false) - if channel.is_exception(): - return - _global_chat_channel = channel - if not socket.received_channel_message.is_connected(_on_global_message): - socket.received_channel_message.connect(_on_global_message) - -func _send_global_message() -> void: - var text = _global_input.text.strip_edges() - if text.is_empty() or not _global_chat_channel: - return - _global_input.text = "" - var socket = NakamaManager.socket - if socket: - socket.write_chat_message_async(_global_chat_channel.id, {"msg": text}) - -func _on_global_message(msg) -> void: - if not _global_chat_channel or msg.channel_id != _global_chat_channel.id: - return - var text: String = "" - var parsed = JSON.parse_string(msg.content) - if typeof(parsed) == TYPE_DICTIONARY: - text = parsed.get("msg", msg.content) - else: - text = msg.content - var sender_name: String = msg.username if msg.username else "?" - if _global_log: - _global_log.append_text("[b]%s:[/b] %s\n" % [sender_name, text]) - -func _open_dm(user_id: String, username: String) -> void: - _active_dm_user_id = user_id - _active_dm_username = username +# ─── DM ────────────────────────────────────────────────────────────────────── +func open_dm(user_id: String, username: String) -> void: + _active_dm_user_id = user_id _dm_username_label.text = "DM: %s" % username _dm_tab_btn.visible = true - # Reload history _dm_log.clear() - var history: Array = _dm_history.get(user_id, []) - for entry in history: - var is_self = entry.get("from") == "me" - var prefix = "[b]%s:[/b]" % ("You" if is_self else username) - _dm_log.append_text("%s %s\n" % [prefix, entry.get("msg", "")]) - # Open channel - FriendManager.open_dm(user_id) _show_tab("dm") + + _dm_log.append_text("[i]Loading history...[/i]\n") + var history = await FriendManager.get_dm_history(user_id) + + _dm_log.clear() + _dm_history[user_id] = [] + + var my_id = NakamaManager.session.user_id if NakamaManager.session else "" + + for entry in history: + var is_self = entry.get("from") == my_id + var sender_name = "You" if is_self else username + _dm_history[user_id].append({"from": "me" if is_self else entry.get("from"), "msg": entry.get("msg")}) + _dm_log.append_text("[b]%s:[/b] %s\n" % [sender_name, entry.get("msg", "")]) + + FriendManager.open_dm(user_id) func _send_dm() -> void: var text = _dm_input.text.strip_edges() @@ -152,13 +228,5 @@ func _on_dm_received(from_user_id: String, from_name: String, message: String) - if not _dm_history.has(from_user_id): _dm_history[from_user_id] = [] _dm_history[from_user_id].append({"from": from_user_id, "msg": message}) - if _active_dm_user_id == from_user_id and _current_tab == "dm": _dm_log.append_text("[b]%s:[/b] %s\n" % [from_name, message]) - -# ─── Tab switching ───────────────────────────────────────────────────────── -func _show_tab(tab: String) -> void: - _current_tab = tab - _friends_view.visible = tab == "friends" - _global_view.visible = tab == "global" - _dm_view.visible = tab == "dm" diff --git a/server/docker-compose.yaml b/server/docker-compose.yaml index 96a0766..d945e85 100644 --- a/server/docker-compose.yaml +++ b/server/docker-compose.yaml @@ -34,6 +34,9 @@ services: --session.token_expiry_sec 7200 --console.username admin --console.password password + --runtime.path /nakama/data/modules + volumes: + - ./nakama:/nakama/data/modules ports: - "7349:7349" # gRPC API - "7350:7350" # HTTP API (main client port) diff --git a/server/nakama/tekton_admin.js b/server/nakama/tekton_admin.js index 1a2e2c7..c3c310a 100644 --- a/server/nakama/tekton_admin.js +++ b/server/nakama/tekton_admin.js @@ -23,6 +23,7 @@ function InitModule(ctx, logger, nk, initializer) { // User management RPCs initializer.registerRpc("get_user_profile", rpcGetUserProfile); initializer.registerRpc("update_user_profile", rpcUpdateUserProfile); + initializer.registerRpc("search_users", rpcSearchUsers); // Store RPCs initializer.registerRpc("purchase_item", rpcPurchaseItem); @@ -40,6 +41,7 @@ function InitModule(ctx, logger, nk, initializer) { initializer.registerRpc("change_credentials", rpcChangeCredentials); initializer.registerRpc("reset_stats", rpcResetStats); initializer.registerRpc("send_lobby_invite", rpcSendLobbyInvite); + initializer.registerRpc("send_friend_request", rpcSendFriendRequest); // Steam auth hooks initializer.registerAfterAuthenticateSteam(afterAuthenticateSteam); @@ -661,6 +663,52 @@ function rpcUpdateUserProfile(ctx, logger, nk, payload) { } } +function rpcSearchUsers(ctx, logger, nk, payload) { + if (!ctx.userId) { + throw new Error("Not authenticated"); + } + + var request = {}; + try { + request = JSON.parse(payload || "{}"); + } catch (e) {} + + var query = request.query || ""; + + try { + var users = []; + var sql = ""; + var params = []; + + if (query === "") { + sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"; + } else { + sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"; + params = ["%" + query + "%"]; + } + + var rows = nk.sqlQuery(sql, params); + + for (var i = 0; i < rows.length; i++) { + var row = rows[i]; + var metadata = {}; + try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {} + + users.push({ + user_id: row.id, + username: row.username || "", + display_name: row.display_name || row.username || "", + avatar_url: metadata.avatar_url || "" + }); + } + + return JSON.stringify({ users: users }); + } catch (e) { + logger.error("Failed to search users: " + e); + return JSON.stringify({ users: [] }); + } +} + // ============================================================================= // Leaderboard RPCs // ============================================================================= @@ -1091,3 +1139,64 @@ function beforeAuthenticateEmail(ctx, logger, nk, data) { // Can't check ban before auth, so we check in afterAuthenticate return data; } + +// ============================================================================= +// Social / Friend RPCs +// ============================================================================= + +// Sends a real-time notification (code 1002) to the target so their client +// can refresh the friends list immediately. The actual friend relationship +// is added by the client via add_friends_async BEFORE calling this RPC. +function rpcSendFriendRequest(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + + var request = {}; + try { request = JSON.parse(payload || "{}"); } catch (e) {} + + var targetUserId = request.user_id || ""; + if (!targetUserId) throw new Error("user_id is required"); + if (targetUserId === ctx.userId) throw new Error("Cannot add yourself"); + + var senderAccount = nk.accountGetId(ctx.userId); + var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone"; + + // Send a persistent notification to target so they see it on next login too + nk.notificationSend( + targetUserId, + "Friend Request", + { from_user_id: ctx.userId, from_name: senderName }, + 1002, // code: friend request + ctx.userId, // sender + true // persistent (survives offline) + ); + + logger.info("Friend request notification sent from " + ctx.userId + " to " + targetUserId); + return JSON.stringify({ success: true }); +} + +function rpcSendLobbyInvite(ctx, logger, nk, payload) { + if (!ctx.userId) throw new Error("Not authenticated"); + + var request = {}; + try { request = JSON.parse(payload || "{}"); } catch (e) {} + + var toUserId = request.to_user_id || ""; + var matchId = request.match_id || ""; + if (!toUserId) throw new Error("to_user_id is required"); + if (!matchId) throw new Error("match_id is required"); + + var senderAccount = nk.accountGetId(ctx.userId); + var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone"; + + nk.notificationSend( + toUserId, + "Lobby Invite", + { from_name: senderName, match_id: matchId }, + 1001, // code: 1001 = lobby invite + ctx.userId, + false // not persistent + ); + + logger.info("Lobby invite sent from " + ctx.userId + " to " + toUserId + " for match " + matchId); + return JSON.stringify({ success: true }); +}