feat: bug fix social system
This commit is contained in:
@@ -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
|
||||
|
||||
+46
-21
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+134
-67
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
+188
-120
@@ -1,141 +1,217 @@
|
||||
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_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")
|
||||
# Search
|
||||
_search_btn.pressed.connect(_on_search_pressed)
|
||||
_search_input.text_submitted.connect(func(_t): _on_search_pressed())
|
||||
|
||||
func _refresh_friend_list(friends: Array) -> void:
|
||||
if not _friend_list:
|
||||
# DM
|
||||
_dm_send_btn.pressed.connect(_send_dm)
|
||||
_dm_input.text_submitted.connect(func(_t): _send_dm())
|
||||
|
||||
# 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()
|
||||
if text.is_empty() or _active_dm_user_id.is_empty():
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user