diff --git a/scenes/friends_presence_ui.tscn b/scenes/friends_presence_ui.tscn new file mode 100644 index 0000000..049e256 --- /dev/null +++ b/scenes/friends_presence_ui.tscn @@ -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 diff --git a/scripts/friends_presence_ui.gd b/scripts/friends_presence_ui.gd new file mode 100644 index 0000000..8fe3dca --- /dev/null +++ b/scripts/friends_presence_ui.gd @@ -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() diff --git a/scripts/smoke_test_friends_presence_ui.gd b/scripts/smoke_test_friends_presence_ui.gd new file mode 100644 index 0000000..c392912 --- /dev/null +++ b/scripts/smoke_test_friends_presence_ui.gd @@ -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)