OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / room_list_panel.gd

984 lines · gd ref:
extends PanelContainer
## Left panel: room list with search, create, join.
## 3-mode state machine:
##   HOME — all joined rooms (no discovery)
##   GROUP_JOINED — joined rooms in selected group
##   GROUP_DISCOVER — all rooms in group with Join/Joined badges

enum ViewMode { HOME, GROUP_JOINED, GROUP_DISCOVER }

@onready var title_label: Label = %Title
@onready var sort_btn: OptionButton = %SortBtn
@onready var group_settings_btn: Button = %GroupSettingsBtn
@onready var search_field: LineEdit = %SearchField
@onready var my_rooms_list: VBoxContainer = %MyRoomsList
@onready var discovery_sections: VBoxContainer = %DiscoverySections
@onready var group_rooms_list: VBoxContainer = %GroupRoomsList
@onready var create_field: LineEdit = %CreateField
@onready var create_btn: Button = %CreateBtn
@onready var public_check: CheckButton = %PublicCheck
@onready var create_status: Label = %CreateStatus
@onready var join_field: LineEdit = %JoinField
@onready var join_btn: Button = %JoinBtn
@onready var leave_btn: Button = %LeaveBtn

var _rooms: Array = []
var _selected_room_id: String = ""
var _pending_title: String = ""
var _pending_public: bool = false
var _group_settings_window: Window = null

## View state
var _view_mode: ViewMode = ViewMode.HOME
var _active_group: String = ""  # "" = home, else registry key

## DM state
var _dm_rooms: Array = []

## Discovery state
var _registries: Array = []
var _registry_data: Dictionary = {}  # reg_key -> { label, meta, rooms }
var _collapsed: Dictionary = {}  # roomId -> bool (true = collapsed)

## Group view state
var _group_rooms: Array = []
var _group_meta: Dictionary = {}
var _group_rooms_key: String = ""  # which registry _group_rooms belongs to
var _fetching_group: bool = false  # guard against overlapping fetches
var _joining_rooms: Dictionary = {}  # roomId -> true, tracks rooms being joined

## Topic search state
var _topic_results: Array = []
var _topic_searching: bool = false

## Cluster metadata from graph (cluster_id_str -> {label, topics})
var _cluster_meta_cache: Dictionary = {}

## Sort mode
enum SortMode { DEFAULT, NAME_AZ, NAME_ZA }
var _sort_mode: SortMode = SortMode.DEFAULT

## Style constants
const COLOR_SELECTED := Color(0.25, 0.35, 0.55)
const COLOR_HOVER := Color(0.2, 0.25, 0.35)
const COLOR_NORMAL := Color(0.12, 0.12, 0.16)
const COLOR_HEADER := Color(0.18, 0.2, 0.28)
const COLOR_HEADER_TEXT := Color(0.8, 0.85, 1.0)
const COLOR_DIM := Color(0.5, 0.55, 0.65)
const COLOR_PUBLIC_ROOM := Color(0.15, 0.15, 0.2)
const COLOR_TITLE_CLICKABLE := Color(0.6, 0.75, 1.0)
const COLOR_DM_BG := Color(0.14, 0.12, 0.2)
const COLOR_DM_HOVER := Color(0.22, 0.18, 0.32)
const COLOR_DM_SELECTED := Color(0.3, 0.22, 0.45)
const COLOR_DM_ACCENT := Color(0.7, 0.6, 1.0)


func _ready() -> void:
	KeetService.room_mgr.rooms_loaded.connect(_on_rooms_loaded)
	KeetService.room_mgr.room_created.connect(_on_room_created)
	KeetService.room_mgr.room_joined.connect(_on_room_joined)
	KeetService.room_mgr.room_left.connect(_on_room_left)
	KeetService.client.connected.connect(_on_connected)
	KeetService.discovery.registries_listed.connect(_on_registries_listed)
	KeetService.group_view_changed.connect(_on_group_changed)
	KeetService.dm.dms_loaded.connect(_on_dms_loaded)

	search_field.text_changed.connect(_on_search_changed)
	search_field.text_submitted.connect(_on_search_submitted)
	create_btn.pressed.connect(_on_create_pressed)
	join_btn.pressed.connect(_on_join_pressed)
	leave_btn.pressed.connect(_on_leave_pressed)
	group_settings_btn.pressed.connect(_on_group_settings_pressed)
	KeetService.discovery.topic_results.connect(_on_topic_results)

	# Sort button
	sort_btn.add_item("Default", 0)
	sort_btn.add_item("Name A-Z", 1)
	sort_btn.add_item("Name Z-A", 2)
	sort_btn.selected = 0
	sort_btn.item_selected.connect(_on_sort_changed)

	# Make title clickable for discover mode toggle
	title_label.mouse_filter = Control.MOUSE_FILTER_STOP
	title_label.gui_input.connect(_on_title_gui_input)

	create_status.text = ""
	group_rooms_list.visible = false

	if KeetService.client.is_connected:
		_on_connected()


func _on_connected() -> void:
	KeetService.room_mgr.list_rooms()
	KeetService.discovery.list_registries()
	KeetService.dm.list_dms()
	# Auto-announce discoverable rooms on the DHT
	KeetService.discovery.announce_discoverable_rooms()


# --- View Mode State Machine ---

func _on_group_changed(group_id: String) -> void:
	var prev_group: String = _active_group
	_active_group = group_id
	create_status.text = ""
	print("[rooms] group changed: '%s' -> '%s'" % [prev_group.left(12), group_id.left(12)])
	if group_id == "":
		_group_rooms_key = ""  # reset so next group click fetches fresh
		_set_view_mode(ViewMode.HOME)
	elif group_id.begins_with("cluster:"):
		# MI-computed cluster — load rooms from graph cache
		if group_id != prev_group:
			_group_rooms_key = ""
			_fetching_group = false
		_set_view_mode(ViewMode.GROUP_JOINED)
		if _group_rooms_key != group_id:
			_group_rooms.clear()
			_group_rooms_key = group_id
			# Read cached clusters directly — do NOT call get_clusters() which
			# triggers a fresh compute with default algo and wipes group_bar icons
			var clusters: Array = KeetService.graph_mgr.last_clusters
			for c: Variant in clusters:
				if not c is Dictionary:
					continue
				if "cluster:%d" % c.get("id", -1) == group_id:
					_group_rooms.clear()
					for rid: Variant in c.get("rooms", []):
						_group_rooms.append({"roomId": str(rid)})
					# Cache cluster label/topics for display
					var label: String = str(c.get("label", ""))
					var topics: Variant = c.get("topics", [])
					if label != "":
						_cluster_meta_cache[group_id] = {"label": label, "topics": topics}
					title_label.text = _get_group_display_name(group_id)
					_build_joined_group_rooms()
					break
	else:
		# Discovery registry — existing logic
		if group_id != prev_group:
			_group_rooms_key = ""
			_fetching_group = false
		_set_view_mode(ViewMode.GROUP_JOINED)


func _set_view_mode(mode: ViewMode) -> void:
	_view_mode = mode
	match mode:
		ViewMode.HOME:
			_render_home_view()
		ViewMode.GROUP_JOINED:
			_render_group_joined_view()
		ViewMode.GROUP_DISCOVER:
			_render_group_discover_view()


func _render_home_view() -> void:
	title_label.text = "Rooms"
	title_label.mouse_default_cursor_shape = Control.CURSOR_ARROW
	title_label.remove_theme_color_override("font_color")
	my_rooms_list.visible = true
	discovery_sections.visible = false
	group_rooms_list.visible = false
	public_check.visible = true
	leave_btn.visible = true
	group_settings_btn.visible = false
	_refresh_my_rooms()


func _render_group_joined_view() -> void:
	var is_cluster: bool = _active_group.begins_with("cluster:")
	var display_name: String = _get_group_display_name(_active_group)
	title_label.text = display_name
	# Clusters have no discover mode, so no clickable title
	if is_cluster:
		title_label.mouse_default_cursor_shape = Control.CURSOR_ARROW
		title_label.remove_theme_color_override("font_color")
		title_label.tooltip_text = ""
	else:
		title_label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
		title_label.add_theme_color_override("font_color", COLOR_TITLE_CLICKABLE)
		title_label.tooltip_text = "Click to browse all rooms in %s" % display_name

	my_rooms_list.visible = false
	discovery_sections.visible = false
	group_rooms_list.visible = true
	public_check.visible = false
	leave_btn.visible = false
	group_settings_btn.visible = not is_cluster

	# Clear stale content immediately — remove_child() before queue_free() to avoid deferred deletion bug
	var old_group := group_rooms_list.get_children()
	for child in old_group:
		group_rooms_list.remove_child(child)
		child.queue_free()

	# Only fetch if switching to a different group — reuse data when toggling JOINED ↔ DISCOVER
	# Cluster groups are populated by _on_group_changed callback, not _fetch_group_data
	if _active_group.begins_with("cluster:"):
		if _group_rooms_key == _active_group:
			_build_joined_group_rooms()
		# else: waiting for graph callback to populate _group_rooms
	elif _group_rooms_key != _active_group:
		_group_rooms.clear()
		_group_meta.clear()
		_group_rooms_key = _active_group
		_fetch_group_data(_active_group)
	else:
		_build_joined_group_rooms()


func _render_group_discover_view() -> void:
	var display_name: String = _get_group_display_name(_active_group)
	title_label.text = "< %s (discover)" % display_name
	title_label.mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
	title_label.add_theme_color_override("font_color", COLOR_TITLE_CLICKABLE)
	title_label.tooltip_text = "Click to go back to your rooms"

	my_rooms_list.visible = false
	discovery_sections.visible = false
	group_rooms_list.visible = true
	public_check.visible = false
	leave_btn.visible = false
	group_settings_btn.visible = true

	# Rebuild with discover view using existing data
	_rebuild_current_group_view()


func _on_sort_changed(idx: int) -> void:
	match idx:
		0: _sort_mode = SortMode.DEFAULT
		1: _sort_mode = SortMode.NAME_AZ
		2: _sort_mode = SortMode.NAME_ZA
	match _view_mode:
		ViewMode.HOME:
			_refresh_my_rooms()
		ViewMode.GROUP_JOINED:
			_build_joined_group_rooms()
		ViewMode.GROUP_DISCOVER:
			_build_discover_group_rooms()


func _sorted_rooms(rooms: Array) -> Array:
	if _sort_mode == SortMode.DEFAULT:
		return rooms
	var sorted: Array = rooms.duplicate()
	sorted.sort_custom(func(a: Variant, b: Variant) -> bool:
		var ta: String = a.get("title", "") if a is Dictionary else ""
		var tb: String = b.get("title", "") if b is Dictionary else ""
		if _sort_mode == SortMode.NAME_AZ:
			return ta.to_lower() < tb.to_lower()
		else:
			return ta.to_lower() > tb.to_lower()
	)
	return sorted


func _on_title_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		if _view_mode == ViewMode.GROUP_JOINED:
			_set_view_mode(ViewMode.GROUP_DISCOVER)
		elif _view_mode == ViewMode.GROUP_DISCOVER:
			_set_view_mode(ViewMode.GROUP_JOINED)


func _get_group_display_name(group_id: String) -> String:
	# Cluster groups — prefer MetadataBase label, fall back to room count
	if group_id.begins_with("cluster:"):
		if _cluster_meta_cache.has(group_id):
			var meta: Dictionary = _cluster_meta_cache[group_id]
			var label: String = str(meta.get("label", ""))
			if label != "":
				var n: int = _group_rooms.size()
				return "%s (%d)" % [label, n] if n > 0 else label
		var n: int = _group_rooms.size()
		return "Cluster (%d rooms)" % n if n > 0 else "Cluster"
	# Registry room — check cached data, then registries array
	if _registry_data.has(group_id):
		var data: Dictionary = _registry_data[group_id]
		var config: Dictionary = data.get("config", {})
		var t: String = str(config.get("title", ""))
		if t != "":
			return t
		var title: String = data.get("title", "")
		if title != "":
			return title
	for reg: Variant in _registries:
		if reg is Dictionary and reg.get("roomId", "") == group_id:
			var title: String = reg.get("title", "")
			if title != "":
				return title
	return group_id.left(12) + "..."


# --- Group Data Fetching ---

func _fetch_group_data(registry_room_id: String) -> void:
	if _fetching_group:
		print("[rooms] _fetch_group_data skipped (already fetching)")
		return  # already fetching, skip duplicate
	_fetching_group = true
	print("[rooms] _fetch_group_data: registry=%s" % registry_room_id.left(12))
	var fetch_key: String = registry_room_id  # capture for stale-check in callbacks
	var counter := {"pending": 2}  # Dictionary for lambda reference capture
	KeetService.discovery.list_rooms(registry_room_id, "", func(rooms):
		if not is_instance_valid(self):
			return
		if _active_group != fetch_key:
			_fetching_group = false
			return
		_group_rooms = rooms if rooms is Array else []
		print("[rooms] group rooms loaded: %d for registry=%s" % [_group_rooms.size(), fetch_key.left(12)])
		counter["pending"] -= 1
		if counter["pending"] <= 0:
			_fetching_group = false
			_rebuild_current_group_view()
	)
	KeetService.discovery.get_room_config(registry_room_id, func(config):
		if not is_instance_valid(self):
			return
		if _active_group != fetch_key:
			_fetching_group = false
			return
		_group_meta = config if config is Dictionary else {}
		counter["pending"] -= 1
		if counter["pending"] <= 0:
			_fetching_group = false
			_rebuild_current_group_view()
	)


func _rebuild_current_group_view() -> void:
	if _view_mode == ViewMode.GROUP_JOINED:
		_build_joined_group_rooms()
	elif _view_mode == ViewMode.GROUP_DISCOVER:
		_build_discover_group_rooms()


func _build_joined_group_rooms() -> void:
	var old := group_rooms_list.get_children()
	for child in old:
		group_rooms_list.remove_child(child)
		child.queue_free()

	var filter: String = search_field.text.strip_edges().to_lower()

	# Build set of discovery room IDs for this group
	var disc_ids: Dictionary = {}
	for dr: Variant in _group_rooms:
		if dr is Dictionary:
			var rid: String = dr.get("roomId", "")
			if rid != "":
				disc_ids[rid] = true

	# Show only MY rooms that are also in the group's discovery
	var shown: int = 0
	for room: Variant in _sorted_rooms(_rooms):
		if not room is Dictionary:
			continue
		var rid: String = room.get("roomId", "")
		if not disc_ids.has(rid):
			continue
		var title: String = room.get("title", "(untitled)")
		if title == "" or title == "<null>" or title == "null":
			title = "(untitled)"
		if filter != "" and title.to_lower().find(filter) == -1:
			continue
		var btn := _create_room_button(rid, title)
		group_rooms_list.add_child(btn)
		shown += 1

	if shown == 0:
		var empty_label := Label.new()
		empty_label.text = "No joined rooms in this group"
		empty_label.add_theme_color_override("font_color", COLOR_DIM)
		empty_label.add_theme_font_size_override("font_size", 12)
		empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		group_rooms_list.add_child(empty_label)

		# Hint to discover rooms
		var hint_btn := Button.new()
		hint_btn.text = "Browse rooms..."
		hint_btn.alignment = HORIZONTAL_ALIGNMENT_CENTER
		hint_btn.add_theme_color_override("font_color", COLOR_TITLE_CLICKABLE)
		hint_btn.add_theme_font_size_override("font_size", 12)
		var hint_style := StyleBoxFlat.new()
		hint_style.bg_color = Color.TRANSPARENT
		hint_btn.add_theme_stylebox_override("normal", hint_style)
		var hint_hover := StyleBoxFlat.new()
		hint_hover.bg_color = COLOR_HOVER
		hint_hover.corner_radius_top_left = 3
		hint_hover.corner_radius_top_right = 3
		hint_hover.corner_radius_bottom_left = 3
		hint_hover.corner_radius_bottom_right = 3
		hint_btn.add_theme_stylebox_override("hover", hint_hover)
		hint_btn.pressed.connect(func():
			_set_view_mode(ViewMode.GROUP_DISCOVER)
		)
		group_rooms_list.add_child(hint_btn)


func _build_discover_group_rooms() -> void:
	var old := group_rooms_list.get_children()
	for child in old:
		group_rooms_list.remove_child(child)
		child.queue_free()

	var filter: String = search_field.text.strip_edges().to_lower()

	if _group_rooms.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No public rooms in this group"
		empty_label.add_theme_color_override("font_color", COLOR_DIM)
		empty_label.add_theme_font_size_override("font_size", 12)
		empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		group_rooms_list.add_child(empty_label)
		return

	for room: Variant in _sorted_rooms(_group_rooms):
		if not room is Dictionary:
			continue
		var title: String = room.get("title", "(untitled)")
		if filter != "":
			var t_lower: String = title.to_lower()
			var d_lower: String = str(room.get("description", "")).to_lower()
			if t_lower.find(filter) == -1 and d_lower.find(filter) == -1:
				continue
		var card := _create_discovery_room_card(room)
		group_rooms_list.add_child(card)


# --- My Rooms ---

func _on_rooms_loaded(rooms: Array) -> void:
	# Split rooms by roomType: type 2 = DM, everything else = regular room
	_rooms = []
	var dm_from_rooms: Array = []
	for r: Variant in rooms:
		if r is Dictionary:
			var rt: int = int(r.get("roomType", 0))
			if rt == 2:
				dm_from_rooms.append(r)
			else:
				_rooms.append(r)
	# Merge DMs detected by roomType with DMs from dm.list
	if not dm_from_rooms.is_empty():
		var existing_ids: Dictionary = {}
		for dm: Variant in _dm_rooms:
			if dm is Dictionary:
				existing_ids[dm.get("roomId", dm.get("id", ""))] = true
		for dm: Variant in dm_from_rooms:
			var rid: String = dm.get("roomId", "")
			if rid != "" and not existing_ids.has(rid):
				_dm_rooms.append(dm)

	# Clear joining state for rooms that have appeared in the room list
	if not _joining_rooms.is_empty():
		var room_ids: Dictionary = {}
		for r2: Variant in rooms:
			if r2 is Dictionary:
				room_ids[r2.get("roomId", "")] = true
		for jid: String in _joining_rooms.keys():
			if room_ids.has(jid):
				_joining_rooms.erase(jid)
	match _view_mode:
		ViewMode.HOME:
			_refresh_my_rooms()
		ViewMode.GROUP_JOINED:
			# Don't rebuild while group data is still being fetched —
			# _group_rooms may be empty, causing 0 cross-reference matches.
			# The fetch callback will rebuild when data arrives.
			if not _fetching_group:
				_build_joined_group_rooms()
		ViewMode.GROUP_DISCOVER:
			_build_discover_group_rooms()


func _on_dms_loaded(dms: Array) -> void:
	_dm_rooms = dms
	if _view_mode == ViewMode.HOME:
		_refresh_my_rooms()


func _refresh_my_rooms() -> void:
	var old := my_rooms_list.get_children()
	for child in old:
		my_rooms_list.remove_child(child)
		child.queue_free()

	var filter: String = search_field.text.strip_edges().to_lower()

	# --- Messages section (DMs) — visually distinct from rooms ---
	if not _dm_rooms.is_empty():
		var dm_header := Label.new()
		dm_header.text = "Direct Messages"
		dm_header.add_theme_color_override("font_color", COLOR_DM_ACCENT)
		dm_header.add_theme_font_size_override("font_size", 11)
		my_rooms_list.add_child(dm_header)

		for dm_room: Variant in _dm_rooms:
			if not dm_room is Dictionary:
				continue
			var dm_title: String = dm_room.get("title", dm_room.get("name", "(DM)"))
			if dm_title == "" or dm_title == "<null>" or dm_title == "null":
				dm_title = "(DM)"
			var dm_rid: String = dm_room.get("roomId", dm_room.get("id", ""))
			if filter != "" and dm_title.to_lower().find(filter) == -1:
				continue
			var btn := _create_dm_button(dm_rid, dm_title)
			my_rooms_list.add_child(btn)

		# Line separator between DMs and rooms
		var sep := HSeparator.new()
		sep.add_theme_constant_override("separation", 8)
		my_rooms_list.add_child(sep)

	# --- Rooms section ---
	var rooms_header := Label.new()
	rooms_header.text = "Rooms"
	rooms_header.add_theme_color_override("font_color", COLOR_HEADER_TEXT)
	rooms_header.add_theme_font_size_override("font_size", 11)
	my_rooms_list.add_child(rooms_header)

	if _rooms.is_empty():
		var empty_label := Label.new()
		empty_label.text = "No rooms yet"
		empty_label.add_theme_color_override("font_color", COLOR_DIM)
		empty_label.add_theme_font_size_override("font_size", 12)
		empty_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		my_rooms_list.add_child(empty_label)
		return

	for room: Variant in _sorted_rooms(_rooms):
		if not room is Dictionary:
			continue
		var title: String = room.get("title", "(untitled)")
		if title == "" or title == "<null>" or title == "null":
			title = "(untitled)"
		var rid: String = room.get("roomId", "")
		if filter != "" and title.to_lower().find(filter) == -1:
			continue
		var btn := _create_room_button(rid, title)
		my_rooms_list.add_child(btn)

	# --- Topic search results (shown after Enter in search field) ---
	if _topic_searching:
		var spacer2 := Control.new()
		spacer2.custom_minimum_size = Vector2(0, 8)
		my_rooms_list.add_child(spacer2)
		var searching_label := Label.new()
		searching_label.text = "Searching DHT..."
		searching_label.add_theme_color_override("font_color", COLOR_DIM)
		searching_label.add_theme_font_size_override("font_size", 12)
		searching_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
		my_rooms_list.add_child(searching_label)
	elif not _topic_results.is_empty():
		var spacer2 := Control.new()
		spacer2.custom_minimum_size = Vector2(0, 8)
		my_rooms_list.add_child(spacer2)
		var topic_header := Label.new()
		topic_header.text = "Topic Results (%d)" % _topic_results.size()
		topic_header.add_theme_color_override("font_color", COLOR_HEADER_TEXT)
		topic_header.add_theme_font_size_override("font_size", 11)
		my_rooms_list.add_child(topic_header)
		for tr: Variant in _topic_results:
			if not tr is Dictionary:
				continue
			var card := _create_discovery_room_card(tr)
			my_rooms_list.add_child(card)


func _create_room_button(room_id: String, title: String) -> Button:
	var btn := Button.new()
	btn.text = title
	btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
	btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	# Flat style
	var style := StyleBoxFlat.new()
	style.bg_color = COLOR_SELECTED if room_id == _selected_room_id else COLOR_NORMAL
	style.corner_radius_top_left = 3
	style.corner_radius_top_right = 3
	style.corner_radius_bottom_left = 3
	style.corner_radius_bottom_right = 3
	style.content_margin_left = 8
	style.content_margin_right = 8
	style.content_margin_top = 4
	style.content_margin_bottom = 4
	btn.add_theme_stylebox_override("normal", style)

	var hover_style := style.duplicate()
	hover_style.bg_color = COLOR_HOVER if room_id != _selected_room_id else COLOR_SELECTED
	btn.add_theme_stylebox_override("hover", hover_style)

	var pressed_style := style.duplicate()
	pressed_style.bg_color = COLOR_SELECTED
	btn.add_theme_stylebox_override("pressed", pressed_style)

	btn.pressed.connect(func():
		_selected_room_id = room_id
		leave_btn.disabled = room_id == ""
		if room_id != "":
			KeetService.select_room(room_id)
		# Refresh whichever view is active
		if _view_mode == ViewMode.HOME:
			_refresh_my_rooms()
		else:
			_build_joined_group_rooms()
	)
	return btn


func _create_dm_button(room_id: String, title: String) -> Button:
	var btn := Button.new()
	btn.text = "DM  %s" % title
	btn.alignment = HORIZONTAL_ALIGNMENT_LEFT
	btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	btn.add_theme_color_override("font_color", COLOR_DM_ACCENT)

	var is_selected: bool = room_id == _selected_room_id
	var style := StyleBoxFlat.new()
	style.bg_color = COLOR_DM_SELECTED if is_selected else COLOR_DM_BG
	style.corner_radius_top_left = 3
	style.corner_radius_top_right = 3
	style.corner_radius_bottom_left = 3
	style.corner_radius_bottom_right = 3
	style.content_margin_left = 8
	style.content_margin_right = 8
	style.content_margin_top = 4
	style.content_margin_bottom = 4
	style.border_width_left = 2
	style.border_color = COLOR_DM_ACCENT
	btn.add_theme_stylebox_override("normal", style)

	var hover_style := style.duplicate()
	hover_style.bg_color = COLOR_DM_HOVER if not is_selected else COLOR_DM_SELECTED
	btn.add_theme_stylebox_override("hover", hover_style)

	var pressed_style := style.duplicate()
	pressed_style.bg_color = COLOR_DM_SELECTED
	btn.add_theme_stylebox_override("pressed", pressed_style)

	btn.pressed.connect(func():
		_selected_room_id = room_id
		leave_btn.disabled = room_id == ""
		if room_id != "":
			KeetService.select_room(room_id)
		if _view_mode == ViewMode.HOME:
			_refresh_my_rooms()
		else:
			_build_joined_group_rooms()
	)
	return btn


func _on_search_changed(_text: String) -> void:
	_topic_results.clear()
	_topic_searching = false
	match _view_mode:
		ViewMode.HOME:
			_refresh_my_rooms()
		ViewMode.GROUP_JOINED:
			_build_joined_group_rooms()
		ViewMode.GROUP_DISCOVER:
			_build_discover_group_rooms()


func _on_search_submitted(text: String) -> void:
	var query: String = text.strip_edges()
	if query == "" or _view_mode != ViewMode.HOME:
		return
	_topic_searching = true
	_topic_results.clear()
	_refresh_my_rooms()  # show "Searching..." state
	KeetService.discovery.search_topic(query)


func _on_topic_results(rooms: Array) -> void:
	_topic_searching = false
	_topic_results = rooms
	if _view_mode == ViewMode.HOME:
		_refresh_my_rooms()


func _on_group_settings_pressed() -> void:
	if _active_group == "":
		return
	if _group_settings_window == null:
		var scene: PackedScene = preload("res://scenes/group_settings.tscn")
		_group_settings_window = scene.instantiate()
		get_tree().root.add_child(_group_settings_window)
	_group_settings_window.open_for_group(_active_group)


# --- Discovery Registries (data fetching — no longer shown inline in home) ---

func _on_registries_listed(registries: Array) -> void:
	_registries = registries
	print("[rooms] registries received: %d" % registries.size())

	# Prune stale entries from _registry_data
	var valid_ids: Array = []
	for reg: Variant in registries:
		if reg is Dictionary:
			var rid: String = reg.get("roomId", "")
			if rid != "":
				valid_ids.append(rid)
	for old_id: String in _registry_data.keys():
		if old_id not in valid_ids:
			_registry_data.erase(old_id)

	if _registries.is_empty():
		_registry_data.clear()
		return

	for reg: Variant in registries:
		if not reg is Dictionary:
			continue
		var room_id: String = reg.get("roomId", "")
		if room_id == "":
			continue

		if not _registry_data.has(room_id):
			_registry_data[room_id] = {
				"title": reg.get("title", ""),
				"config": {},
				"rooms": []
			}
		else:
			_registry_data[room_id]["title"] = reg.get("title", "")

		if not _collapsed.has(room_id):
			_collapsed[room_id] = false

		# Fetch listed rooms
		KeetService.discovery.list_rooms(room_id, "", func(rooms, _rid = room_id):
			if _registry_data.has(_rid):
				_registry_data[_rid]["rooms"] = rooms if rooms is Array else []
		)

		# Fetch room config
		KeetService.discovery.get_room_config(room_id, func(config, _rid = room_id):
			if _registry_data.has(_rid):
				_registry_data[_rid]["config"] = config if config is Dictionary else {}
		)


func _create_discovery_room_card(room: Dictionary) -> PanelContainer:
	var card := PanelContainer.new()
	var style := StyleBoxFlat.new()
	style.bg_color = COLOR_PUBLIC_ROOM
	style.corner_radius_top_left = 0
	style.corner_radius_top_right = 0
	style.corner_radius_bottom_left = 0
	style.corner_radius_bottom_right = 0
	style.content_margin_left = 12
	style.content_margin_right = 8
	style.content_margin_top = 4
	style.content_margin_bottom = 4
	card.add_theme_stylebox_override("panel", style)

	var hbox := HBoxContainer.new()
	card.add_child(hbox)

	# Room info
	var info := VBoxContainer.new()
	info.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	hbox.add_child(info)

	var title_lbl := Label.new()
	title_lbl.text = room.get("title", "(untitled)")
	title_lbl.add_theme_font_size_override("font_size", 13)
	info.add_child(title_lbl)

	var desc: String = room.get("description", "")
	if desc != "":
		var desc_label := Label.new()
		desc_label.text = desc
		desc_label.add_theme_color_override("font_color", COLOR_DIM)
		desc_label.add_theme_font_size_override("font_size", 11)
		desc_label.clip_text = true
		desc_label.custom_minimum_size.x = 80
		info.add_child(desc_label)

	# Check if we already have this room
	var room_id: String = room.get("roomId", "")
	var already_joined := false
	for r: Variant in _rooms:
		if r is Dictionary and r.get("roomId", "") == room_id:
			already_joined = true
			break

	if already_joined:
		var joined_label := Label.new()
		joined_label.text = "Joined"
		joined_label.add_theme_color_override("font_color", Color(0.4, 0.7, 0.4))
		joined_label.add_theme_font_size_override("font_size", 11)
		hbox.add_child(joined_label)
	elif _joining_rooms.has(room_id):
		# Join RPC is in-flight — show status that survives card rebuilds
		var status_label := Label.new()
		status_label.text = "Joining..."
		status_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.4))
		status_label.add_theme_font_size_override("font_size", 11)
		hbox.add_child(status_label)
	else:
		var join_btn_card := Button.new()
		join_btn_card.custom_minimum_size.x = 48
		var invite_token: String = room.get("inviteToken", "")
		join_btn_card.text = "Join" if invite_token != "" else "No token"
		join_btn_card.pressed.connect(func():
			if invite_token == "" or _joining_rooms.has(room_id):
				return
			# Track join state by roomId — survives card rebuilds
			_joining_rooms[room_id] = true
			_build_discover_group_rooms()  # Rebuild to show "Joining..."
			# Fire RPC — NO button capture, only use _joining_rooms state
			KeetService.client.call_rpc("room.join", {"invite": invite_token}, func(result, err, _rid = room_id):
				if not is_instance_valid(self):
					return
				if err != null:
					print("[join] ERROR for '%s': %s" % [_rid, str(err)])
					_joining_rooms.erase(_rid)
					if _view_mode == ViewMode.GROUP_DISCOVER:
						_build_discover_group_rooms()
					return
				print("[join] OK for '%s': %s" % [_rid, str(result)])
				# Don't switch views — pairing is async, room not in list yet.
				# Keep "Joining..." visible. Delayed list_rooms() will update
				# _rooms, and the already_joined check will show "Joined".
				KeetService.room_mgr.room_joined.emit(_rid)
				# Clear joining state after 30s fallback
				get_tree().create_timer(30.0).timeout.connect(func():
					if _joining_rooms.has(_rid):
						_joining_rooms.erase(_rid)
						if _view_mode == ViewMode.GROUP_DISCOVER:
							_build_discover_group_rooms()
				)
			)
		)
		if invite_token == "":
			join_btn_card.disabled = true
			join_btn_card.tooltip_text = "No invite token"
		hbox.add_child(join_btn_card)

	return card


# --- Room Creation ---

func _on_create_pressed() -> void:
	var title: String = create_field.text.strip_edges()
	if title == "":
		return
	create_status.text = ""

	if _active_group != "" and not _active_group.begins_with("cluster:"):
		# Creating in a group context — auto-register in the registry room
		_do_create_room(title, true)
	elif public_check.button_pressed:
		_do_create_room(title, true)
	else:
		_do_create_room(title, false)


func _do_create_room(title: String, make_public: bool) -> void:
	create_field.text = ""
	_pending_title = title
	_pending_public = make_public
	print("[rooms] creating room: title='%s' public=%s group=%s" % [title, str(make_public), _active_group])
	KeetService.room_mgr.create_room(title)


func _on_room_created(room_id: String) -> void:
	print("[rooms] room_created signal: id=%s pending_title='%s' pending_public=%s" % [room_id, _pending_title, str(_pending_public)])

	if room_id != "" and _pending_title != "":
		var title_to_set: String = _pending_title
		var should_register: bool = _pending_public
		var group_key: String = _active_group
		_pending_title = ""
		_pending_public = false
		print("[rooms] setting title via room.config.set: room=%s title='%s'" % [room_id, title_to_set])
		KeetService.room_mgr.set_config(room_id, "title", title_to_set, func(result):
			print("[rooms] set_config result: %s" % str(result))
			if should_register and group_key != "" and not group_key.begins_with("cluster:"):
				print("[rooms] registering in registry: room=%s title='%s' registry=%s" % [room_id, title_to_set, group_key])
				KeetService.discovery.register_room(group_key, room_id, title_to_set, "", [], "", func(reg_result):
					if reg_result == null:
						create_status.text = "Room created, but failed to register"
						create_status.add_theme_color_override("font_color", Color(0.9, 0.6, 0.3))
					else:
						create_status.text = "Room listed in group!"
						create_status.add_theme_color_override("font_color", Color(0.3, 0.9, 0.4))
					get_tree().create_timer(3.0).timeout.connect(func():
						if create_status.text == "Room listed in group!" or create_status.text.begins_with("Room created"):
							create_status.text = ""
					)
					KeetService.discovery.list_registries()
					_fetch_group_data(group_key)
				)
			KeetService.room_mgr.list_rooms()
		)
	else:
		_pending_title = ""
		_pending_public = false

	KeetService.room_mgr.list_rooms()
	get_tree().create_timer(3.0).timeout.connect(func(): KeetService.room_mgr.list_rooms())
	get_tree().create_timer(8.0).timeout.connect(func(): KeetService.room_mgr.list_rooms())


# --- Join / Leave ---

func _on_join_pressed() -> void:
	var invite: String = join_field.text.strip_edges()
	if invite == "":
		return
	join_field.text = ""
	KeetService.room_mgr.join_room(invite)


func _on_room_joined(_room_id: String) -> void:
	KeetService.room_mgr.list_rooms()
	# Re-fetch group data so cross-reference picks up newly joined room
	if _active_group != "" and _view_mode == ViewMode.GROUP_JOINED:
		_group_rooms_key = ""  # Force re-fetch
		_fetching_group = false
		_fetch_group_data(_active_group)
	# Delayed refreshes to catch async pairing completion
	get_tree().create_timer(2.0).timeout.connect(func(): KeetService.room_mgr.list_rooms())
	get_tree().create_timer(5.0).timeout.connect(func(): KeetService.room_mgr.list_rooms())
	get_tree().create_timer(15.0).timeout.connect(func(): KeetService.room_mgr.list_rooms())


func _on_room_left(_room_id: String) -> void:
	_selected_room_id = ""
	leave_btn.disabled = true
	KeetService.room_mgr.list_rooms()


func _on_leave_pressed() -> void:
	if _selected_room_id == "":
		return
	var rid: String = _selected_room_id
	var title: String = ""
	for room: Variant in _rooms:
		if room is Dictionary and room.get("roomId", "") == rid:
			title = room.get("title", rid)
			break
	var dialog := ConfirmationDialog.new()
	dialog.title = "Leave Room"
	dialog.dialog_text = "Leave '%s' and purge local data?\n\nYou will need a new invite to rejoin." % title
	dialog.min_size = Vector2i(320, 100)
	dialog.confirmed.connect(func():
		leave_btn.disabled = true
		KeetService.room_mgr.purge_room(rid)
		dialog.queue_free()
	)
	dialog.canceled.connect(func():
		dialog.queue_free()
	)
	add_child(dialog)
	dialog.popup_centered()