OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / group_bar.gd

508 lines · gd ref:
extends PanelContainer
## Vertical icon bar for group (registry room) navigation.
## Discord-like: Home button, group icons, Add button.

signal group_selected(group_id: String)

@onready var group_icons: VBoxContainer = %GroupIcons
@onready var home_btn: Button = %HomeButton
@onready var add_btn: Button = %AddButton
@onready var view_select: OptionButton = %ViewSelect

var _registries: Array = []
var _registry_meta: Dictionary = {}  # reg_key -> meta dict
var _clusters: Array = []  # MI-computed clusters from graph
var _active_group: String = ""  # "" = home
var _init_retries: int = 0
const MAX_INIT_RETRIES := 5
var _popup: PopupMenu = null
var _popup_reg_key: String = ""
var _settings_window: Window = null
var _avatar_picker: AvatarPicker = null
var _current_view: int = 0  # index into VIEW_MODES

## View modes: what drives the cluster icons
const VIEW_MODES: Array[Dictionary] = [
	{"label": "Groups", "algo": {}},
	{"label": "People", "algo": {"name": "people", "features": [{"type": "size", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
	{"label": "Topics", "algo": {"name": "topics", "features": [{"type": "topics", "weight": 1.0}], "threshold": 0.3, "minSize": 2}},
	{"label": "Category", "algo": {"name": "category", "features": [{"type": "category", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
	{"label": "Activity", "algo": {"name": "activity", "features": [{"type": "activity", "weight": 1.0}], "threshold": 0.1, "minSize": 2}},
]

const ICON_SIZE := 48
const PILL_WIDTH := 4
const PILL_HEIGHT := 32
const COLOR_BG := Color(0.11, 0.11, 0.13)
const COLOR_PILL := Color(1.0, 1.0, 1.0)
const COLOR_PILL_DIM := Color(0.5, 0.5, 0.55)
const COLOR_HOME_BG := Color(0.3, 0.6, 0.4)


func _ready() -> void:
	KeetService.discovery.registries_listed.connect(_on_registries_listed)
	KeetService.discovery.registry_added.connect(func(_key, _label): KeetService.discovery.list_registries())
	KeetService.discovery.registry_removed.connect(func(_key): KeetService.discovery.list_registries())
	KeetService.graph_mgr.clusters_loaded.connect(_on_clusters_loaded)
	KeetService.client.connected.connect(_on_connected)

	home_btn.pressed.connect(_on_home_pressed)
	add_btn.pressed.connect(_on_add_pressed)

	# Populate view mode selector
	for i: int in VIEW_MODES.size():
		view_select.add_item(VIEW_MODES[i]["label"], i)
	view_select.selected = 0
	view_select.item_selected.connect(_on_view_mode_changed)

	if KeetService.client.is_connected:
		_on_connected()


func _on_connected() -> void:
	KeetService.discovery.list_registries()
	_recompute_clusters()


func _on_view_mode_changed(idx: int) -> void:
	_current_view = idx
	_clusters.clear()
	# Reset to home — previous group selection doesn't apply to new view
	_active_group = ""
	KeetService.group_view_changed.emit("")
	_rebuild_icons()
	_recompute_clusters()


func _recompute_clusters() -> void:
	var algo: Dictionary = VIEW_MODES[_current_view]["algo"]
	if algo.is_empty():
		# "Groups" mode — no clusters, just registries
		_clusters.clear()
		_rebuild_icons()
	else:
		KeetService.graph_mgr.compute(true, algo)


func _on_home_pressed() -> void:
	_active_group = ""
	_rebuild_icons()
	group_selected.emit("")
	KeetService.group_view_changed.emit("")


func _on_add_pressed() -> void:
	var dialog := AcceptDialog.new()
	dialog.title = "Add Group"
	dialog.min_size = Vector2i(400, 220)

	var vbox := VBoxContainer.new()
	vbox.add_theme_constant_override("separation", 6)

	# Tab-like buttons: Create / Join
	var mode_hbox := HBoxContainer.new()
	var create_tab := Button.new()
	create_tab.text = "Create New"
	create_tab.toggle_mode = true
	create_tab.button_pressed = true
	create_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	mode_hbox.add_child(create_tab)
	var join_tab := Button.new()
	join_tab.text = "Join Existing"
	join_tab.toggle_mode = true
	join_tab.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	mode_hbox.add_child(join_tab)
	vbox.add_child(mode_hbox)

	var sep := HSeparator.new()
	vbox.add_child(sep)

	# Create fields
	var create_box := VBoxContainer.new()
	var create_label := Label.new()
	create_label.text = "Create a new group you control:"
	create_label.add_theme_font_size_override("font_size", 12)
	create_label.add_theme_color_override("font_color", Color(0.6, 0.65, 0.7))
	create_box.add_child(create_label)
	var create_name := LineEdit.new()
	create_name.placeholder_text = "Group name..."
	create_box.add_child(create_name)
	vbox.add_child(create_box)

	# Join fields (hidden by default)
	var join_box := VBoxContainer.new()
	join_box.visible = false
	var join_label := Label.new()
	join_label.text = "Paste a group invite link to join:"
	join_label.add_theme_font_size_override("font_size", 12)
	join_label.add_theme_color_override("font_color", Color(0.6, 0.65, 0.7))
	join_box.add_child(join_label)
	var invite_input := LineEdit.new()
	invite_input.placeholder_text = "Invite link or token..."
	join_box.add_child(invite_input)
	vbox.add_child(join_box)

	# Status label
	var status := Label.new()
	status.text = ""
	status.add_theme_font_size_override("font_size", 12)
	vbox.add_child(status)

	# Tab switching
	create_tab.pressed.connect(func():
		create_tab.button_pressed = true
		join_tab.button_pressed = false
		create_box.visible = true
		join_box.visible = false
		status.text = ""
	)
	join_tab.pressed.connect(func():
		join_tab.button_pressed = true
		create_tab.button_pressed = false
		create_box.visible = false
		join_box.visible = true
		status.text = ""
	)

	dialog.add_child(vbox)
	dialog.confirmed.connect(func():
		if create_tab.button_pressed:
			var gname: String = create_name.text.strip_edges()
			if gname == "":
				status.text = "Enter a group name"
				status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
				return
			status.text = "Creating..."
			KeetService.discovery.create_registry(gname, func(result):
				if result is Dictionary:
					var new_id: String = result.get("roomId", "")
					if new_id != "":
						_active_group = new_id
						group_selected.emit(new_id)
						KeetService.group_view_changed.emit(new_id)
				dialog.queue_free()
			)
		else:
			var invite: String = invite_input.text.strip_edges()
			if invite == "":
				status.text = "Enter an invite link"
				status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
				return
			status.text = "Joining..."
			KeetService.discovery.join_registry(invite, func(result):
				if result == null:
					status.text = "Failed to join group"
					status.add_theme_color_override("font_color", Color(0.9, 0.4, 0.3))
					return
				var joined_id: String = ""
				if result is Dictionary:
					joined_id = result.get("roomId", result.get("id", ""))
				if joined_id != "":
					_active_group = joined_id
					_rebuild_icons()
					group_selected.emit(joined_id)
					KeetService.group_view_changed.emit(joined_id)
				dialog.queue_free()
			)
	)
	dialog.canceled.connect(func(): dialog.queue_free())
	add_child(dialog)
	dialog.popup_centered()
	create_name.grab_focus()


func _on_registries_listed(registries: Array) -> void:
	_registries = registries
	print("[groups] registries listed: %d (retry=%d)" % [registries.size(), _init_retries])
	# Retry when registries empty during startup (registry rooms may not be loaded yet)
	if registries.is_empty() and _init_retries < MAX_INIT_RETRIES:
		_init_retries += 1
		get_tree().create_timer(2.0).timeout.connect(
			func(): KeetService.discovery.list_registries()
		)
		_rebuild_icons()
		return
	_init_retries = 0  # Reset on success

	# Show icons immediately with letter fallbacks (don't wait for config)
	_rebuild_icons()

	if registries.is_empty():
		return

	# Fetch room config in background for avatars/descriptions
	var counter := {"pending": registries.size()}
	for reg: Variant in registries:
		if not reg is Dictionary:
			counter["pending"] -= 1
			continue
		var room_id: String = reg.get("roomId", "")
		if room_id == "":
			counter["pending"] -= 1
			continue
		KeetService.discovery.get_room_config(room_id, func(result, _rid = room_id):
			if not is_instance_valid(self):
				return
			_registry_meta[_rid] = result if result is Dictionary else {}
			counter["pending"] -= 1
			if counter["pending"] <= 0:
				_rebuild_icons()
		)


func _on_clusters_loaded(clusters: Array) -> void:
	_clusters = clusters
	_rebuild_icons()


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

	# Update home button selected state
	_style_home_button()

	# Registry groups — only shown in Groups mode (view 0)
	if _current_view == 0:
		for reg: Variant in _registries:
			if not reg is Dictionary:
				continue
			var room_id: String = reg.get("roomId", "")
			if room_id == "":
				continue

			var config: Dictionary = _registry_meta.get(room_id, {})
			var label: String = str(config.get("title", ""))
			if label == "":
				label = reg.get("title", "")
			if label == "":
				label = room_id.left(8)

			var desc: String = str(config.get("description", ""))
			var avatar_dict: Variant = config.get("avatar", null)
			var avatar_b64: String = ""
			if avatar_dict is Dictionary:
				avatar_b64 = str(avatar_dict.get("small", ""))
			var is_selected: bool = room_id == _active_group

			var icon := _create_group_icon(room_id, label, desc, avatar_b64, is_selected)
			group_icons.add_child(icon)

	# Cluster icons — only shown when a clustering algo is selected
	if _current_view > 0:
		for cluster: Variant in _clusters:
			if not cluster is Dictionary:
				continue
			var rooms: Array = cluster.get("rooms", [])
			if rooms.size() < 1:
				continue
			var cluster_id: String = "cluster:%d" % cluster.get("id", 0)
			var label: String = cluster.get("label", "")
			if label == "":
				label = "%d rooms" % rooms.size()
			var is_selected: bool = cluster_id == _active_group
			var c_icon := _create_group_icon(cluster_id, label, VIEW_MODES[_current_view]["label"], "", is_selected)
			group_icons.add_child(c_icon)


func _style_home_button() -> void:
	var is_home: bool = _active_group == ""
	var style := StyleBoxFlat.new()
	style.bg_color = COLOR_HOME_BG
	# Selected: rounded rect; default: circle
	var radius: int = 12 if is_home else 24
	style.corner_radius_top_left = radius
	style.corner_radius_top_right = radius
	style.corner_radius_bottom_left = radius
	style.corner_radius_bottom_right = radius
	home_btn.add_theme_stylebox_override("normal", style)
	var hover := style.duplicate()
	hover.corner_radius_top_left = 12
	hover.corner_radius_top_right = 12
	hover.corner_radius_bottom_left = 12
	hover.corner_radius_bottom_right = 12
	home_btn.add_theme_stylebox_override("hover", hover)


func _create_group_icon(reg_key: String, label: String, desc: String, avatar_b64: String, is_selected: bool) -> HBoxContainer:
	var row := HBoxContainer.new()
	row.custom_minimum_size = Vector2(60, ICON_SIZE + 4)
	row.add_theme_constant_override("separation", 0)

	# Pill indicator
	var pill := ColorRect.new()
	pill.custom_minimum_size = Vector2(PILL_WIDTH, PILL_HEIGHT)
	pill.color = COLOR_PILL if is_selected else Color.TRANSPARENT
	pill.size_flags_vertical = Control.SIZE_SHRINK_CENTER
	row.add_child(pill)

	# Spacer
	var spacer := Control.new()
	spacer.custom_minimum_size.x = 4
	row.add_child(spacer)

	# Icon button
	var btn := Button.new()
	btn.custom_minimum_size = Vector2(ICON_SIZE, ICON_SIZE)
	btn.tooltip_text = label if desc == "" else "%s\n%s" % [label, desc]

	# Shape: selected = rounded rect, default = circle
	var radius: int = 12 if is_selected else 24

	if avatar_b64 != "":
		# Decode avatar
		var img_data: PackedByteArray = Marshalls.base64_to_raw(avatar_b64)
		var img := Image.new()
		if img.load_png_from_buffer(img_data) == OK:
			var tex := ImageTexture.create_from_image(img)
			btn.icon = tex
			btn.expand_icon = true
	else:
		# First letter with color from name hash
		btn.text = label.left(1).to_upper() if label != "" else "?"
		btn.add_theme_font_size_override("font_size", 18)

	var style := StyleBoxFlat.new()
	style.bg_color = color_from_name(label) if avatar_b64 == "" else Color(0.15, 0.15, 0.2)
	style.corner_radius_top_left = radius
	style.corner_radius_top_right = radius
	style.corner_radius_bottom_left = radius
	style.corner_radius_bottom_right = radius
	btn.add_theme_stylebox_override("normal", style)

	var hover_style := style.duplicate()
	hover_style.corner_radius_top_left = 12
	hover_style.corner_radius_top_right = 12
	hover_style.corner_radius_bottom_left = 12
	hover_style.corner_radius_bottom_right = 12
	hover_style.bg_color = style.bg_color.lightened(0.15)
	btn.add_theme_stylebox_override("hover", hover_style)

	var pressed_style := style.duplicate()
	pressed_style.corner_radius_top_left = 12
	pressed_style.corner_radius_top_right = 12
	pressed_style.corner_radius_bottom_left = 12
	pressed_style.corner_radius_bottom_right = 12
	btn.add_theme_stylebox_override("pressed", pressed_style)

	btn.pressed.connect(func():
		_active_group = reg_key
		_rebuild_icons()
		group_selected.emit(reg_key)
		KeetService.group_view_changed.emit(reg_key)
	)

	# Right-click context menu
	btn.gui_input.connect(func(event: InputEvent):
		if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
			_show_context_menu(reg_key, label)
	)

	row.add_child(btn)

	# Hover pill effect
	btn.mouse_entered.connect(func():
		if not is_selected:
			pill.color = COLOR_PILL_DIM
			pill.custom_minimum_size.y = 16
	)
	btn.mouse_exited.connect(func():
		if not is_selected:
			pill.color = Color.TRANSPARENT
			pill.custom_minimum_size.y = PILL_HEIGHT
	)

	return row


func _show_context_menu(reg_key: String, current_name: String) -> void:
	if _popup != null:
		_popup.queue_free()
	_popup_reg_key = reg_key
	_popup = PopupMenu.new()
	_popup.add_item("Settings", 0)
	_popup.add_item("Rename", 1)
	_popup.add_separator()
	_popup.add_item("Remove", 2)
	_popup.id_pressed.connect(func(id: int):
		match id:
			0: _open_group_settings(reg_key)
			1: _show_rename_dialog(reg_key, current_name)
			2: _confirm_remove(reg_key, current_name)
		_popup.queue_free()
		_popup = null
	)
	get_tree().root.add_child(_popup)
	_popup.popup(Rect2i(Vector2i(get_viewport().get_mouse_position()), Vector2i.ZERO))


func _open_group_settings(reg_key: String) -> void:
	if _settings_window == null:
		var scene: PackedScene = preload("res://scenes/group_settings.tscn")
		_settings_window = scene.instantiate()
		_settings_window.group_deleted.connect(_on_group_deleted)
		get_tree().root.add_child(_settings_window)
	_settings_window.open_for_group(reg_key)


func _on_group_deleted(reg_key: String) -> void:
	if _active_group == reg_key:
		_active_group = ""
		group_selected.emit("")
		KeetService.group_view_changed.emit("")
	KeetService.discovery.list_registries()


func _show_rename_dialog(room_id: String, current_name: String) -> void:
	var dialog := AcceptDialog.new()
	dialog.title = "Rename Group"
	dialog.min_size = Vector2i(350, 100)

	var name_input := LineEdit.new()
	name_input.text = current_name
	name_input.placeholder_text = "Enter group name..."
	dialog.add_child(name_input)

	dialog.confirmed.connect(func():
		var new_name: String = name_input.text.strip_edges()
		if new_name != "":
			KeetService.client.call_rpc("room.config.set", {
				"roomId": room_id, "key": "title", "value": new_name
			}, func(_result, _err):
				KeetService.discovery.list_registries()
			)
		dialog.queue_free()
	)
	dialog.canceled.connect(func(): dialog.queue_free())
	add_child(dialog)
	dialog.popup_centered()
	name_input.grab_focus()


func _confirm_remove(reg_key: String, name: String) -> void:
	var dialog := ConfirmationDialog.new()
	dialog.title = "Remove Group"
	dialog.dialog_text = "Remove '%s' from your groups?\n\nYou can rejoin later with an invite." % name
	dialog.min_size = Vector2i(320, 100)
	dialog.confirmed.connect(func():
		KeetService.discovery.remove_registry(reg_key)
		if _active_group == reg_key:
			_active_group = ""
			group_selected.emit("")
			KeetService.group_view_changed.emit("")
		dialog.queue_free()
	)
	dialog.canceled.connect(func(): dialog.queue_free())
	add_child(dialog)
	dialog.popup_centered()


static func color_from_name(name: String) -> Color:
	var h: int = name.hash()
	var hue: float = fmod(absf(float(h)) * 0.618033988749895, 1.0)
	var sat: float = 0.45 + fmod(absf(float(h >> 8)) * 0.1, 0.2)
	var val: float = 0.55 + fmod(absf(float(h >> 16)) * 0.1, 0.15)
	return Color.from_hsv(hue, sat, val)