extends Window
## Room Settings window — configure room, manage members, notifications.
const SETTINGS_VERSION := "v14" # Bump on each deploy to verify build
signal room_left(room_id: String)
@onready var room_title_label: Label = %RoomTitle
@onready var avatar_preview: TextureRect = %AvatarPreview
@onready var change_avatar_btn: Button = %ChangeAvatarBtn
@onready var title_field: LineEdit = %TitleField
@onready var desc_field: TextEdit = %DescField
@onready var calls_check: CheckButton = %CallsCheck
@onready var lock_check: CheckButton = %LockCheck
@onready var member_events_check: CheckButton = %MemberEventsCheck
@onready var topics_field: LineEdit = %TopicsField
@onready var discoverable_check: CheckButton = %DiscoverableCheck
@onready var discovery_list: VBoxContainer = %DiscoveryList
@onready var discovery_status: Label = %DiscoveryStatus
@onready var save_btn: Button = %SaveBtn
@onready var save_status: Label = %SaveStatus
@onready var invite_btn: Button = %InviteBtn
@onready var invite_result: LineEdit = %InviteResult
@onready var member_list: VBoxContainer = %MemberList
@onready var mute_all_check: CheckButton = %MuteAllCheck
@onready var mentions_only_check: CheckButton = %MentionsOnlyCheck
@onready var bookmark_check: CheckButton = %BookmarkCheck
@onready var notif_save_btn: Button = %NotifSaveBtn
@onready var leave_btn: Button = %LeaveBtn
@onready var purge_btn: Button = %PurgeBtn
var _room_id: String = ""
var _room_title: String = ""
var _config: Dictionary = {}
var _meta: Dictionary = {} # Custom metadata from _hs:room-meta pinned message
# Per-registry discovery state: { registry_key: { label, is_default, registered } }
var _registry_state: Dictionary = {}
var _registries: Array = []
var _avatar_picker: AvatarPicker = null
var _my_member_id: String = ""
var _my_room_role: String = "" # admin, mod, peer, etc.
func _ready() -> void:
close_requested.connect(_on_close_requested)
save_btn.pressed.connect(_on_save)
invite_btn.pressed.connect(_on_invite)
change_avatar_btn.pressed.connect(_on_change_avatar)
notif_save_btn.pressed.connect(_on_save_notifications)
leave_btn.pressed.connect(_on_leave)
purge_btn.pressed.connect(_on_purge)
save_status.text = ""
# Placeholder avatar
var placeholder := Image.create(64, 64, false, Image.FORMAT_RGBA8)
placeholder.fill(Color(0.25, 0.25, 0.3))
avatar_preview.texture = ImageTexture.create_from_image(placeholder)
func open_for_room(room_id: String, room_title: String) -> void:
print("[room_settings %s] open_for_room: room=%s title='%s'" % [SETTINGS_VERSION, room_id.left(12), room_title])
_room_id = room_id
_room_title = room_title
title = "Settings: %s" % room_title
room_title_label.text = room_title
save_status.text = ""
invite_result.visible = false
# Load room config (includes avatar)
KeetService.room_mgr.get_config(room_id, func(config):
_config = config if config is Dictionary else {}
_populate_general()
_load_room_avatar()
)
# Load custom metadata (topics, discoverable)
_meta = {}
KeetService.client.call_rpc("meta.get", {"roomId": room_id}, func(result, _err):
_meta = result if result is Dictionary else {}
_populate_meta()
)
# Get our identity then load members
_my_room_role = ""
KeetService.identity.get_identity(func(ident):
_my_member_id = ident.get("memberId", "") if ident is Dictionary else ""
KeetService.room_mgr.list_members(room_id, func(members):
_populate_members(members)
)
)
# Load preferences
KeetService.room_mgr.get_preferences(room_id, func(prefs):
_populate_notifications(prefs)
)
# Load all registries and check which ones this room is in
_registry_state.clear()
_registries.clear()
discovery_status.text = "Loading groups..."
KeetService.discovery.list_registries(func(regs):
_registries = regs if regs is Array else []
if _registries.is_empty():
discovery_status.text = "No groups configured"
_build_discovery_list()
return
# Build registry state and show checkboxes immediately (default unchecked)
for reg: Variant in _registries:
if not reg is Dictionary:
continue
var reg_key: String = reg.get("roomId", "")
if reg_key == "":
continue
var reg_label: String = reg.get("title", reg_key.left(12) + "...")
var is_default: bool = false
var is_available: bool = true
print("[room_settings] registry: key=%s label=%s" % [reg_key.left(12), reg_label])
_registry_state[reg_key] = {
"label": reg_label,
"is_default": is_default,
"registered": false,
"available": is_available
}
_build_discovery_list()
discovery_status.text = "Checking registrations..."
# Now check which registries this room is listed in (skip unavailable)
var check_keys: Array = []
for reg_key: String in _registry_state:
if _registry_state[reg_key].get("available", true):
check_keys.append(reg_key)
# Use Dictionary so lambdas share the counter by reference (int would be captured by value)
var counter := {"pending": check_keys.size()}
if counter["pending"] == 0:
var avail: int = 0
for _k: String in _registry_state:
if _registry_state[_k].get("available", true):
avail += 1
discovery_status.text = "Listed in 0 of %d groups (%d unavailable)" % [avail, _registry_state.size() - avail]
_build_discovery_list()
return
for reg_key: String in check_keys:
KeetService.discovery.list_rooms(reg_key, "", func(rooms: Array, _rk = reg_key):
for rm: Variant in rooms:
if rm is Dictionary and rm.get("roomId", "") == _room_id:
if _registry_state.has(_rk):
_registry_state[_rk]["registered"] = true
break
counter["pending"] -= 1
_build_discovery_list()
if counter["pending"] <= 0:
var count: int = 0
var avail: int = 0
for _k: String in _registry_state:
if _registry_state[_k].get("registered", false):
count += 1
if _registry_state[_k].get("available", true):
avail += 1
if avail < _registry_state.size():
discovery_status.text = "Listed in %d of %d groups (%d unavailable)" % [count, avail, _registry_state.size() - avail]
else:
discovery_status.text = "Listed in %d of %d groups" % [count, _registry_state.size()]
)
# Timeout fallback — if some registries have fd-lock issues, don't wait forever
get_tree().create_timer(8.0).timeout.connect(func():
if discovery_status.text == "Checking registrations...":
var count: int = 0
var avail: int = 0
for _k: String in _registry_state:
if _registry_state[_k].get("registered", false):
count += 1
if _registry_state[_k].get("available", true):
avail += 1
discovery_status.text = "Listed in %d of %d groups (%d unavailable)" % [count, avail, _registry_state.size() - avail]
_build_discovery_list()
)
)
popup_centered()
# --- General Tab ---
func _build_discovery_list() -> void:
# Remove old children IMMEDIATELY (not deferred) so new ones are the only visible ones
var old_children: Array = discovery_list.get_children()
for child in old_children:
discovery_list.remove_child(child)
child.queue_free()
if _registry_state.is_empty():
discovery_status.text = "No groups configured"
print("[room_settings %s] _build_discovery_list: empty registry_state" % SETTINGS_VERSION)
return
var count: int = 0
for reg_key: String in _registry_state:
var state: Dictionary = _registry_state[reg_key]
var is_available: bool = state.get("available", true)
var is_registered: bool = state.get("registered", false)
var check := CheckButton.new()
var label: String = state.get("label", reg_key.left(12) + "...")
if state.get("is_default", false):
label += " (default)"
if not is_available:
label += " (unavailable)"
check.disabled = true
check.text = label
check.set_pressed_no_signal(is_registered)
check.set_meta("registry_key", reg_key)
check.add_theme_font_size_override("font_size", 12)
if not is_available:
check.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5))
discovery_list.add_child(check)
if is_registered:
count += 1
print("[room_settings %s] _build_discovery_list: key=%s registered=%s label=%s" % [SETTINGS_VERSION, reg_key.left(12), str(is_registered), label])
# Update discovery_status to reflect current registration state
var avail: int = 0
for _k: String in _registry_state:
if _registry_state[_k].get("available", true):
avail += 1
if avail < _registry_state.size():
discovery_status.text = "Listed in %d of %d groups (%d unavailable)" % [count, avail, _registry_state.size() - avail]
else:
discovery_status.text = "Listed in %d of %d groups" % [count, _registry_state.size()]
print("[room_settings %s] _build_discovery_list: status='%s'" % [SETTINGS_VERSION, discovery_status.text])
## Config values from keet-core may be String "true"/"false" instead of bool.
## Comparing String == bool crashes silently in Godot 4.6 exports.
static func _to_bool(val: Variant) -> bool:
if val is bool: return val
if val is String: return val.to_lower() == "true"
if val is int: return val != 0
return false
func _populate_general() -> void:
title_field.text = str(_config.get("title", _room_title))
desc_field.text = str(_config.get("description", ""))
calls_check.set_pressed_no_signal(_to_bool(_config.get("canCall", false)))
lock_check.set_pressed_no_signal(_to_bool(_config.get("locked", false)))
var events_disabled: bool = _to_bool(_config.get("isMembershipEventsDisabled", false))
member_events_check.set_pressed_no_signal(not events_disabled)
func _populate_meta() -> void:
# Topics: array of strings → comma-separated display
var topics: Variant = _meta.get("topics", [])
if topics is Array:
var parts: PackedStringArray = PackedStringArray()
for t: Variant in topics:
var s: String = str(t).strip_edges()
if s != "":
parts.append(s)
topics_field.text = ", ".join(parts)
else:
topics_field.text = ""
# Discoverable toggle
discoverable_check.set_pressed_no_signal(_to_bool(_meta.get("discoverable", false)))
func _parse_topics() -> Array:
## Parse comma-separated topics from the field into a clean array.
var raw: String = topics_field.text.strip_edges()
if raw == "":
return []
var result: Array = []
for part: String in raw.split(","):
var t: String = part.strip_edges().to_lower()
if t != "" and not result.has(t):
result.append(t)
return result
func _on_close_requested() -> void:
hide()
func _on_save() -> void:
print("[room_settings %s] === _on_save START ===" % SETTINGS_VERSION)
save_status.text = "Saving..."
save_status.remove_theme_color_override("font_color")
var counter := {"pending": 0}
# Title
var new_title: String = title_field.text.strip_edges()
var old_title: String = str(_config.get("title", ""))
if new_title != "" and new_title != old_title:
counter["pending"] += 1
KeetService.room_mgr.set_config(_room_id, "title", new_title, func(_r):
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Description
var new_desc: String = desc_field.text.strip_edges()
var old_desc: String = str(_config.get("description", ""))
if new_desc != old_desc:
counter["pending"] += 1
KeetService.room_mgr.set_config(_room_id, "description", new_desc, func(_r):
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Calls — use _to_bool() because keet-core returns String "true"/"false"
var new_calls: bool = calls_check.button_pressed
var old_calls: bool = _to_bool(_config.get("canCall", false))
print("[room_settings %s] calls: new=%s old=%s" % [SETTINGS_VERSION, str(new_calls), str(old_calls)])
if new_calls != old_calls:
counter["pending"] += 1
KeetService.room_mgr.set_config(_room_id, "canCall", new_calls, func(_r):
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Lock — same String/"true" issue
var new_lock: bool = lock_check.button_pressed
var old_lock: bool = _to_bool(_config.get("locked", false))
print("[room_settings %s] lock: new=%s old=%s" % [SETTINGS_VERSION, str(new_lock), str(old_lock)])
if new_lock != old_lock:
counter["pending"] += 1
KeetService.room_mgr.set_config(_room_id, "locked", new_lock, func(_r):
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Membership events — correct key is isMembershipEventsDisabled (not disableMembershipEvents)
var new_events: bool = member_events_check.button_pressed
var current_events_disabled: bool = _to_bool(_config.get("isMembershipEventsDisabled", false))
print("[room_settings %s] events: show=%s disabled=%s toggled=%s" % [SETTINGS_VERSION, str(new_events), str(current_events_disabled), str(new_events == current_events_disabled)])
if new_events == current_events_disabled: # user wants events but they're disabled, or vice versa
counter["pending"] += 1
KeetService.room_mgr.set_config(_room_id, "isMembershipEventsDisabled", not new_events, func(_r):
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Topics + discoverable metadata
var new_topics: Array = _parse_topics()
var old_topics: Array = _meta.get("topics", []) if _meta.get("topics", []) is Array else []
var new_discoverable: bool = discoverable_check.button_pressed
var old_discoverable: bool = _to_bool(_meta.get("discoverable", false))
if new_topics != old_topics or new_discoverable != old_discoverable:
counter["pending"] += 1
var meta_update := {"topics": new_topics, "discoverable": new_discoverable}
KeetService.client.call_rpc("meta.set", {"roomId": _room_id, "metadata": meta_update}, func(_r, _e):
_meta["topics"] = new_topics
_meta["discoverable"] = new_discoverable
# Auto-announce/unannounce on DHT based on discoverable toggle
if new_discoverable and new_topics.size() > 0:
KeetService.discovery.announce_room(_room_id)
elif not new_discoverable and old_discoverable:
KeetService.discovery.unannounce_room(_room_id)
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
# Discovery group opt-in/opt-out per registry
var reg_title: String = new_title if new_title != "" else _room_title
var disc_children: Array = discovery_list.get_children()
print("[room_settings %s] discovery: reg_title='%s' children=%d registry_state_keys=%s" % [SETTINGS_VERSION, reg_title, disc_children.size(), str(_registry_state.keys())])
for check_node: Variant in disc_children:
if not check_node is CheckButton:
continue
if not check_node.is_inside_tree():
print("[room_settings] skip: not in tree")
continue
var cb: CheckButton = check_node
var reg_key: String = cb.get_meta("registry_key", "")
if reg_key == "":
print("[room_settings] skip: empty reg_key")
continue
var was_registered: bool = _registry_state.get(reg_key, {}).get("registered", false)
var now_checked: bool = cb.button_pressed
var is_avail: bool = _registry_state.get(reg_key, {}).get("available", true)
print("[room_settings] reg_key=%s was=%s now=%s avail=%s label=%s" % [reg_key.left(12), str(was_registered), str(now_checked), str(is_avail), cb.text])
if now_checked and not was_registered:
if not is_avail:
continue
counter["pending"] += 1
KeetService.discovery.register_room(reg_key, _room_id, reg_title, new_desc, new_topics, "", func(_r, _rk = reg_key):
if _r == null:
save_status.text = "Failed to register in group"
save_status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
else:
_registry_state[_rk]["registered"] = true
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
elif not now_checked and was_registered:
counter["pending"] += 1
KeetService.discovery.unregister_room(reg_key, _room_id, func(_r, _rk = reg_key):
_registry_state[_rk]["registered"] = false
counter["pending"] -= 1
if counter["pending"] <= 0:
_on_save_done()
)
print("[room_settings %s] === _on_save END: pending=%d ===" % [SETTINGS_VERSION, counter["pending"]])
if counter["pending"] == 0:
save_status.text = "No changes"
func _on_save_done() -> void:
print("[room_settings %s] _on_save_done called! status='%s'" % [SETTINGS_VERSION, save_status.text])
# Only show "Saved!" if no error message was set (status still says "Saving...")
if save_status.text == "Saving...":
save_status.text = "Saved!"
save_status.add_theme_color_override("font_color", Color(0.3, 0.9, 0.4))
# Refresh room list + discovery state
KeetService.room_mgr.list_rooms()
_build_discovery_list()
get_tree().create_timer(2.0).timeout.connect(func():
if save_status.text == "Saved!":
save_status.text = ""
)
# --- Room Avatar ---
func _load_room_avatar() -> void:
var avatar: Variant = _config.get("avatar", null)
if avatar is Dictionary:
# Try medium first (288x288), then small (165x165)
var b64: String = str(avatar.get("medium", ""))
if b64 == "":
b64 = str(avatar.get("small", ""))
if b64 != "":
var img_data: PackedByteArray = Marshalls.base64_to_raw(b64)
var img := Image.new()
if img.load_png_from_buffer(img_data) == OK:
avatar_preview.texture = ImageTexture.create_from_image(img)
return
# Try WebP if PNG fails
if img.load_webp_from_buffer(img_data) == OK:
avatar_preview.texture = ImageTexture.create_from_image(img)
func _on_change_avatar() -> void:
if _avatar_picker != null:
_avatar_picker.queue_free()
_avatar_picker = AvatarPicker.new()
_avatar_picker.avatar_selected.connect(func(small_b64: String, large_b64: String):
# Keet room avatar sizes: 165x165 (small), 288x288 (medium)
var source_img := Image.new()
var raw: PackedByteArray = Marshalls.base64_to_raw(large_b64)
if source_img.load_png_from_buffer(raw) != OK:
return
# Scale to keet room sizes
var small_img: Image = source_img.duplicate()
small_img.resize(165, 165, Image.INTERPOLATE_LANCZOS)
var medium_img: Image = source_img.duplicate()
medium_img.resize(288, 288, Image.INTERPOLATE_LANCZOS)
var small_val: String = Marshalls.raw_to_base64(small_img.save_png_to_buffer())
var medium_val: String = Marshalls.raw_to_base64(medium_img.save_png_to_buffer())
var avatar_val := {"small": small_val, "medium": medium_val}
KeetService.room_mgr.set_config(_room_id, "avatar", avatar_val, func(_r):
_config["avatar"] = avatar_val
_load_room_avatar()
save_status.text = "Avatar updated!"
save_status.add_theme_color_override("font_color", Color(0.3, 0.9, 0.4))
get_tree().create_timer(2.0).timeout.connect(func():
if save_status.text == "Avatar updated!":
save_status.text = ""
)
)
)
add_child(_avatar_picker)
_avatar_picker.popup_centered()
# --- Members Tab ---
func _populate_members(members: Array) -> void:
for child in member_list.get_children():
child.queue_free()
# Find our role in this room
_my_room_role = ""
for m: Variant in members:
if m is Dictionary and m.get("memberId", "") == _my_member_id:
_my_room_role = m.get("role", "peer")
break
var is_admin: bool = _my_room_role == "admin"
for m: Variant in members:
if not m is Dictionary:
continue
var row := _create_member_row(m, is_admin)
member_list.add_child(row)
func _create_member_row(m: Dictionary, can_manage: bool = false) -> HBoxContainer:
var hbox := HBoxContainer.new()
var member_id: String = m.get("memberId", "?")
var name_text: String = m.get("name", member_id.left(8))
var role: String = m.get("role", "peer")
var online: bool = m.get("online", false)
var is_me: bool = member_id == _my_member_id
# Name + online indicator
var name_label := Label.new()
var suffix: String = ""
if online:
suffix += " [ON]"
if is_me:
suffix += " (you)"
name_label.text = "%s%s" % [name_text, suffix]
name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_label.add_theme_font_size_override("font_size", 13)
if online:
name_label.add_theme_color_override("font_color", Color(0.4, 0.9, 0.5))
hbox.add_child(name_label)
# Role label (always shown)
var role_label := Label.new()
role_label.text = role
role_label.add_theme_font_size_override("font_size", 11)
role_label.add_theme_color_override("font_color", Color(0.6, 0.65, 0.7))
role_label.custom_minimum_size.x = 50
if can_manage and not is_me:
# Admin can change other members' roles
var role_btn := OptionButton.new()
role_btn.add_item("Admin", 1)
role_btn.add_item("Mod", 2)
role_btn.add_item("Peer", 3)
role_btn.add_item("Read-only", 4)
var role_map := {"admin": 0, "mod": 1, "peer": 2, "read-only": 3}
role_btn.selected = role_map.get(role, 2)
role_btn.add_theme_font_size_override("font_size", 11)
role_btn.item_selected.connect(func(idx: int):
var new_role: int = role_btn.get_item_id(idx)
role_btn.disabled = true
KeetService.room_mgr.change_role(_room_id, member_id, new_role, func(_r):
role_btn.disabled = false
)
)
hbox.add_child(role_btn)
# Kick button
var kick_btn := Button.new()
kick_btn.text = "Kick"
kick_btn.add_theme_font_size_override("font_size", 11)
kick_btn.pressed.connect(func():
var dialog := ConfirmationDialog.new()
dialog.title = "Kick Member"
dialog.dialog_text = "Remove %s from this room?" % name_text
dialog.confirmed.connect(func():
KeetService.room_mgr.kick_member(_room_id, member_id, func(_r):
KeetService.room_mgr.list_members(_room_id, func(members):
_populate_members(members)
)
)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
)
hbox.add_child(kick_btn)
else:
hbox.add_child(role_label)
return hbox
func _on_invite() -> void:
invite_btn.disabled = true
invite_btn.text = "Creating..."
KeetService.room_mgr.create_invite(_room_id, func(token):
invite_btn.disabled = false
invite_btn.text = "Create Invite Link"
if token is String and token != "":
invite_result.text = token
invite_result.visible = true
else:
invite_result.text = "Failed to create invite"
invite_result.visible = true
)
# --- Notifications Tab ---
func _populate_notifications(prefs: Dictionary) -> void:
mute_all_check.set_pressed_no_signal(prefs.get("isFullyMuted", false) == true)
mentions_only_check.set_pressed_no_signal(prefs.get("isMentionsOnlyMuted", false) == true)
# Bookmark state not available from preferences — skip for now
func _on_save_notifications() -> void:
var prefs := {
"isFullyMuted": mute_all_check.button_pressed,
"isMentionsOnlyMuted": mentions_only_check.button_pressed
}
KeetService.room_mgr.set_preferences(_room_id, prefs)
if bookmark_check.button_pressed:
KeetService.room_mgr.bookmark(_room_id)
else:
KeetService.room_mgr.unbookmark(_room_id)
# --- Danger Zone ---
func _on_leave() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Leave Room"
dialog.dialog_text = "Leave '%s'?\n\nYou will need a new invite to rejoin." % _room_title
dialog.min_size = Vector2i(320, 100)
dialog.confirmed.connect(func():
KeetService.room_mgr.leave_room(_room_id, func(_r):
room_left.emit(_room_id)
hide()
)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()
func _on_purge() -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "Purge Room"
dialog.dialog_text = "Leave '%s' and DELETE all local data?\n\nThis cannot be undone." % _room_title
dialog.min_size = Vector2i(320, 120)
dialog.confirmed.connect(func():
KeetService.room_mgr.purge_room(_room_id, func(_r):
room_left.emit(_room_id)
hide()
)
dialog.queue_free()
)
dialog.canceled.connect(func(): dialog.queue_free())
add_child(dialog)
dialog.popup_centered()