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()