Merge branch 'agent/af249a67f693-godot-friends-presence-ui' into main
checkpoint / checkpoint (push) Successful in 5m9s

This commit is contained in:
2026-09-15 19:32:46 +00:00
3 changed files with 695 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/friends_presence_ui.gd" id="1_fp"]
[node name="FriendsPresenceUI" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_fp")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32.0
offset_top = 32.0
offset_right = -32.0
offset_bottom = -32.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 12
[node name="HeaderHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="BackButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "< Back"
[node name="TitleLabel" type="Label" parent="VBoxContainer/HeaderHBox"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 26
text = "Friends & Social"
horizontal_alignment = 1
[node name="RefreshButton" type="Button" parent="VBoxContainer/HeaderHBox"]
custom_minimum_size = Vector2(100, 36)
layout_mode = 2
text = "Refresh"
[node name="MyStatusHBox" type="HBoxContainer" parent="VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="StatusPrefix" type="Label" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
text = "My Status:"
[node name="StatusLineEdit" type="LineEdit" parent="VBoxContainer/MyStatusHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "What are you doing? (e.g. Exploring worlds...)"
[node name="SetStatusButton" type="Button" parent="VBoxContainer/MyStatusHBox"]
custom_minimum_size = Vector2(110, 32)
layout_mode = 2
text = "Set Status"
[node name="StatusMessageLabel" type="Label" parent="VBoxContainer"]
layout_mode = 2
horizontal_alignment = 1
[node name="TabContainer" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="Friends" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 0
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Friends"]
layout_mode = 2
size_flags_vertical = 3
[node name="FriendsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Friends/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Requests" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 1
[node name="SendHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="TargetUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to add..."
[node name="SendButton" type="Button" parent="VBoxContainer/TabContainer/Requests/SendHBox"]
custom_minimum_size = Vector2(120, 32)
layout_mode = 2
text = "Send Request"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Requests"]
layout_mode = 2
size_flags_vertical = 3
[node name="RequestsList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Requests/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Following" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 2
[node name="FollowHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="FollowUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to follow..."
[node name="FollowButton" type="Button" parent="VBoxContainer/TabContainer/Following/FollowHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Follow"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Following"]
layout_mode = 2
size_flags_vertical = 3
[node name="FollowingList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Following/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
[node name="Blocked" type="VBoxContainer" parent="VBoxContainer/TabContainer"]
visible = false
layout_mode = 2
theme_override_constants/separation = 8
metadata/_tab_index = 3
[node name="BlockHBox" type="HBoxContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="BlockUserIdEdit" type="LineEdit" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Enter User ID to block..."
[node name="BlockButton" type="Button" parent="VBoxContainer/TabContainer/Blocked/BlockHBox"]
custom_minimum_size = Vector2(100, 32)
layout_mode = 2
text = "Block"
[node name="Scroll" type="ScrollContainer" parent="VBoxContainer/TabContainer/Blocked"]
layout_mode = 2
size_flags_vertical = 3
[node name="BlockedList" type="VBoxContainer" parent="VBoxContainer/TabContainer/Blocked/Scroll"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
+444
View File
@@ -0,0 +1,444 @@
extends Control
class_name FriendsPresenceUI
## Friends & Presence UI panel.
##
## Displays:
## - Friends list with real-time online/offline presence & custom status
## - Friend requests inbox (with Accept / Decline / Send actions)
## - Following list (with Unfollow / Follow actions)
## - Blocked users list (with Unblock / Block actions)
## - Custom status updater
signal back_requested
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var status_label: Label = $VBoxContainer/StatusMessageLabel
@onready var tab_container: TabContainer = $VBoxContainer/TabContainer
@onready var friends_container: VBoxContainer = $VBoxContainer/TabContainer/Friends/Scroll/FriendsList
@onready var requests_container: VBoxContainer = $VBoxContainer/TabContainer/Requests/Scroll/RequestsList
@onready var following_container: VBoxContainer = $VBoxContainer/TabContainer/Following/Scroll/FollowingList
@onready var blocked_container: VBoxContainer = $VBoxContainer/TabContainer/Blocked/Scroll/BlockedList
@onready var custom_status_input: LineEdit = $VBoxContainer/MyStatusHBox/StatusLineEdit
@onready var set_status_button: Button = $VBoxContainer/MyStatusHBox/SetStatusButton
# Friend request input
@onready var send_req_input: LineEdit = $VBoxContainer/TabContainer/Requests/SendHBox/TargetUserIdEdit
@onready var send_req_button: Button = $VBoxContainer/TabContainer/Requests/SendHBox/SendButton
# Follow input
@onready var follow_input: LineEdit = $VBoxContainer/TabContainer/Following/FollowHBox/FollowUserIdEdit
@onready var follow_button: Button = $VBoxContainer/TabContainer/Following/FollowHBox/FollowButton
# Block input
@onready var block_input: LineEdit = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockUserIdEdit
@onready var block_button: Button = $VBoxContainer/TabContainer/Blocked/BlockHBox/BlockButton
var _api_client: Node = null
var _friend_presence_map: Dictionary = {} # user_id (int) -> {"status": String, "custom_status": String, "last_seen": String}
func _ready() -> void:
_api_client = _get_api()
_wire_signals()
if _api_client != null and _api_client.has_token():
refresh_all()
func _get_api() -> Node:
if has_node("/root/ApiClient"):
return get_node("/root/ApiClient")
return null
func _wire_signals() -> void:
if back_button:
back_button.pressed.connect(_on_back_pressed)
if refresh_button:
refresh_button.pressed.connect(refresh_all)
if set_status_button:
set_status_button.pressed.connect(_on_set_status_pressed)
if send_req_button:
send_req_button.pressed.connect(_on_send_req_pressed)
if follow_button:
follow_button.pressed.connect(_on_follow_pressed)
if block_button:
block_button.pressed.connect(_on_block_pressed)
if _api_client != null and _api_client.ws != null:
if not _api_client.ws.presence_changed.is_connected(_on_presence_changed):
_api_client.ws.presence_changed.connect(_on_presence_changed)
func _on_back_pressed() -> void:
back_requested.emit()
if get_tree().current_scene == self:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
func show_message(text: String, is_error: bool = false) -> void:
if status_label:
status_label.text = text
status_label.modulate = Color(1.0, 0.4, 0.4) if is_error else Color(0.4, 1.0, 0.4)
func refresh_all() -> void:
if _api_client == null or not _api_client.has_token():
show_message("Please log in to view social features.", true)
return
await refresh_friends()
await refresh_requests()
await refresh_following()
await refresh_blocked()
# ── Friends & Presence ──
func refresh_friends() -> void:
if _api_client == null:
return
# Fetch mutual presence first
var pres_res: Dictionary = await _api_client.get_friends_presence()
_friend_presence_map.clear()
if pres_res.ok and pres_res.data is Dictionary and pres_res.data.has("friends"):
for p in pres_res.data["friends"]:
var uid: int = int(p.get("user_id", 0))
_friend_presence_map[uid] = p
# Fetch friends list
var res: Dictionary = await _api_client.get_friends()
_clear_children(friends_container)
if not res.ok:
show_message("Failed to load friends: " + res.detail, true)
return
var friends_list: Array = []
if res.data is Dictionary and res.data.has("friends"):
friends_list = res.data["friends"]
if friends_list.is_empty():
var empty_label := Label.new()
empty_label.text = "No friends yet. Send a friend request to get started!"
empty_label.modulate = Color(0.7, 0.7, 0.7)
friends_container.add_child(empty_label)
return
for friend in friends_list:
var uid: int = int(friend.get("id", 0))
var uname: String = str(friend.get("username", "Unknown"))
_build_friend_row(uid, uname)
func _build_friend_row(uid: int, uname: String) -> void:
var hbox := HBoxContainer.new()
hbox.name = "FriendRow_%d" % uid
hbox.add_theme_constant_override("separation", 12)
var pres: Dictionary = _friend_presence_map.get(uid, {})
var is_online: bool = pres.get("status", "offline") == "online"
var custom_status: String = str(pres.get("custom_status", ""))
var last_seen: String = str(pres.get("last_seen", ""))
# Status indicator dot
var status_dot := Label.new()
status_dot.text = "●"
status_dot.modulate = Color(0.2, 0.9, 0.2) if is_online else Color(0.5, 0.5, 0.5)
hbox.add_child(status_dot)
# Username label
var name_label := Label.new()
name_label.text = uname
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(name_label)
# Presence info
var pres_label := Label.new()
if is_online:
pres_label.text = "Online" + ((" - " + custom_status) if custom_status != "" else "")
pres_label.modulate = Color(0.3, 0.9, 0.3)
else:
pres_label.text = "Offline" + ((" (" + last_seen.left(10) + ")") if last_seen != "" else "")
pres_label.modulate = Color(0.6, 0.6, 0.6)
pres_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(pres_label)
# Action: Unfriend
var unfriend_btn := Button.new()
unfriend_btn.text = "Unfriend"
unfriend_btn.pressed.connect(func(): _on_unfriend(uid))
hbox.add_child(unfriend_btn)
# Action: Block
var block_btn := Button.new()
block_btn.text = "Block"
block_btn.pressed.connect(func(): _on_block_friend(uid))
hbox.add_child(block_btn)
friends_container.add_child(hbox)
func _on_unfriend(uid: int) -> void:
var res: Dictionary = await _api_client.unfriend(uid)
if res.ok:
show_message("Unfriended user.")
refresh_friends()
else:
show_message("Failed to unfriend: " + res.detail, true)
func _on_block_friend(uid: int) -> void:
var res: Dictionary = await _api_client.block_user(uid)
if res.ok:
show_message("Blocked user.")
refresh_friends()
refresh_blocked()
else:
show_message("Failed to block user: " + res.detail, true)
func _on_presence_changed(uid: int, p_status: String) -> void:
if not _friend_presence_map.has(uid):
_friend_presence_map[uid] = {}
_friend_presence_map[uid]["status"] = p_status
# Refresh UI if row exists
var row_name := "FriendRow_%d" % uid
if friends_container.has_node(row_name):
refresh_friends()
func _on_set_status_pressed() -> void:
if _api_client == null:
return
var new_status := custom_status_input.text.strip_edges()
var res: Dictionary = await _api_client.send_presence_heartbeat(new_status)
if res.ok:
show_message("Status updated.")
else:
show_message("Failed to update status: " + res.detail, true)
# ── Requests ──
func refresh_requests() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_friend_requests()
_clear_children(requests_container)
if not res.ok:
show_message("Failed to load requests: " + res.detail, true)
return
var req_list: Array = []
if res.data is Dictionary and res.data.has("requests"):
req_list = res.data["requests"]
if req_list.is_empty():
var empty_label := Label.new()
empty_label.text = "No pending friend requests."
empty_label.modulate = Color(0.7, 0.7, 0.7)
requests_container.add_child(empty_label)
return
for req in req_list:
var req_id: int = int(req.get("id", 0))
var from_id: int = int(req.get("requester_id", 0))
var created_at: String = str(req.get("created_at", "")).left(10)
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "Request from User #%d (%s)" % [from_id, created_at]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var accept_btn := Button.new()
accept_btn.text = "Accept"
accept_btn.pressed.connect(func(): _on_accept_request(req_id))
hbox.add_child(accept_btn)
var decline_btn := Button.new()
decline_btn.text = "Decline"
decline_btn.pressed.connect(func(): _on_decline_request(req_id))
hbox.add_child(decline_btn)
requests_container.add_child(hbox)
func _on_accept_request(req_id: int) -> void:
var res: Dictionary = await _api_client.accept_friend_request(req_id)
if res.ok:
show_message("Friend request accepted!")
refresh_requests()
refresh_friends()
else:
show_message("Failed to accept: " + res.detail, true)
func _on_decline_request(req_id: int) -> void:
var res: Dictionary = await _api_client.decline_friend_request(req_id)
if res.ok:
show_message("Friend request declined.")
refresh_requests()
else:
show_message("Failed to decline: " + res.detail, true)
func _on_send_req_pressed() -> void:
var target_text := send_req_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.send_friend_request(target_id)
if res.ok:
show_message("Friend request sent to User #%d!" % target_id)
send_req_input.text = ""
else:
show_message("Failed to send request: " + res.detail, true)
# ── Following ──
func refresh_following() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_following()
_clear_children(following_container)
if not res.ok:
return
var list: Array = []
if res.data is Dictionary and res.data.has("following"):
list = res.data["following"]
if list.is_empty():
var empty_label := Label.new()
empty_label.text = "You are not following anyone."
empty_label.modulate = Color(0.7, 0.7, 0.7)
following_container.add_child(empty_label)
return
for u in list:
var uid: int = int(u.get("id", 0))
var uname: String = str(u.get("username", "Unknown"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "%s (ID: %d)" % [uname, uid]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var unfollow_btn := Button.new()
unfollow_btn.text = "Unfollow"
unfollow_btn.pressed.connect(func(): _on_unfollow(uid))
hbox.add_child(unfollow_btn)
following_container.add_child(hbox)
func _on_unfollow(uid: int) -> void:
var res: Dictionary = await _api_client.unfollow_user(uid)
if res.ok:
show_message("Unfollowed User #%d." % uid)
refresh_following()
else:
show_message("Failed to unfollow: " + res.detail, true)
func _on_follow_pressed() -> void:
var target_text := follow_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.follow_user(target_id)
if res.ok:
show_message("Following User #%d." % target_id)
follow_input.text = ""
refresh_following()
else:
show_message("Failed to follow: " + res.detail, true)
# ── Blocked ──
func refresh_blocked() -> void:
if _api_client == null:
return
var res: Dictionary = await _api_client.get_blocked()
_clear_children(blocked_container)
if not res.ok:
return
var list: Array = []
if res.data is Dictionary and res.data.has("blocked"):
list = res.data["blocked"]
if list.is_empty():
var empty_label := Label.new()
empty_label.text = "No blocked users."
empty_label.modulate = Color(0.7, 0.7, 0.7)
blocked_container.add_child(empty_label)
return
for u in list:
var uid: int = int(u.get("id", 0))
var uname: String = str(u.get("username", "Unknown"))
var hbox := HBoxContainer.new()
hbox.add_theme_constant_override("separation", 12)
var lbl := Label.new()
lbl.text = "%s (ID: %d)" % [uname, uid]
lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
hbox.add_child(lbl)
var unblock_btn := Button.new()
unblock_btn.text = "Unblock"
unblock_btn.pressed.connect(func(): _on_unblock(uid))
hbox.add_child(unblock_btn)
blocked_container.add_child(hbox)
func _on_unblock(uid: int) -> void:
var res: Dictionary = await _api_client.unblock_user(uid)
if res.ok:
show_message("Unblocked User #%d." % uid)
refresh_blocked()
else:
show_message("Failed to unblock: " + res.detail, true)
func _on_block_pressed() -> void:
var target_text := block_input.text.strip_edges()
if not target_text.is_valid_int():
show_message("Enter a valid integer user ID.", true)
return
var target_id := int(target_text)
var res: Dictionary = await _api_client.block_user(target_id)
if res.ok:
show_message("Blocked User #%d." % target_id)
block_input.text = ""
refresh_blocked()
refresh_friends()
else:
show_message("Failed to block: " + res.detail, true)
func _clear_children(node: Node) -> void:
if node == null:
return
for c in node.get_children():
c.queue_free()
+77
View File
@@ -0,0 +1,77 @@
extends SceneTree
## Headless smoke test for FriendsPresenceUI scene and script.
func _initialize() -> void:
_run()
func _run() -> void:
await process_frame
print("SMOKE: Starting Friends & Presence UI verification...")
var scene_res := load("res://scenes/friends_presence_ui.tscn")
if scene_res == null:
printerr("FAIL: Could not load res://scenes/friends_presence_ui.tscn")
quit(1)
return
var ui: Control = scene_res.instantiate()
if ui == null:
printerr("FAIL: Could not instantiate FriendsPresenceUI")
quit(1)
return
root.add_child(ui)
await process_frame
# Verify essential nodes
if ui.friends_container == null:
printerr("FAIL: friends_container is null")
quit(1)
return
if ui.requests_container == null:
printerr("FAIL: requests_container is null")
quit(1)
return
if ui.following_container == null:
printerr("FAIL: following_container is null")
quit(1)
return
if ui.blocked_container == null:
printerr("FAIL: blocked_container is null")
quit(1)
return
# Verify signal declaration
if not ui.has_signal("back_requested"):
printerr("FAIL: back_requested signal missing")
quit(1)
return
# Test mock row building
ui._friend_presence_map[42] = {
"status": "online",
"custom_status": "Building cool stuff",
"last_seen": "2026-09-15T12:00:00Z"
}
ui._build_friend_row(42, "TesterFriend")
if not ui.friends_container.has_node("FriendRow_42"):
printerr("FAIL: FriendRow_42 was not added to friends_container")
quit(1)
return
print("PASS: Friend row built successfully with presence indicator.")
# Test message display
ui.show_message("Test message", false)
if ui.status_label.text != "Test message":
printerr("FAIL: status_label text not updated")
quit(1)
return
ui.queue_free()
print("SMOKE RESULT: PASS")
quit(0)