OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet / scripts / chat_panel.gd

874 lines · gd ref:
extends PanelContainer
## Center panel: chat messages + input for the selected room.
## Supports inline image previews for file attachments.

@onready var room_title: Label = %RoomTitle
@onready var room_title_edit: LineEdit = %RoomTitleEdit
@onready var toggle_members_btn: Button = %ToggleMembersBtn
@onready var graph_btn: Button = %GraphBtn
@onready var files_btn: Button = %FilesBtn
@onready var invite_btn: Button = %InviteBtn
@onready var settings_btn: Button = %SettingsBtn
@onready var search_btn: Button = %SearchBtn
@onready var search_bar: HBoxContainer = %SearchBar
@onready var search_input: LineEdit = %SearchInput
@onready var search_count: Label = %SearchCount
@onready var close_search_btn: Button = %CloseSearchBtn
@onready var chat_display: RichTextLabel = %ChatDisplay
@onready var attach_btn: Button = %AttachBtn
@onready var message_input: TextEdit = %MessageInput
@onready var send_btn: Button = %SendBtn

var _current_room_id: String = ""
var _file_dialog: FileDialog = null
var _current_title: String = ""
var _texture_cache: KeetTextureCache = KeetTextureCache.new()
var _pending_fetches: Dictionary = {}  # file_id -> true (in-flight dedup)
var _media_viewer: Window = null
var _image_lightbox: Window = null
var _message_files: Array = []  # Array of {file_id, file_name, http_link} for current message batch
var _msg_map: Dictionary = {}  # msg_key -> {id, memberId, text, name}
var _my_member_id: String = ""
var _editing_msg_id: Variant = null  # Set when editing a message
var _context_menu: PopupMenu = null
var _invite_dialog: Window = null
var _settings_window: Window = null
var _search_active: bool = false
var _search_debounce: Timer = null
var _cached_messages: Array = []  # Full message history for search highlighting
var _graph_panel: Control = null  # Lazy-instantiated graph panel
var _graph_active: bool = false

const IMAGE_TYPES: Array[String] = ["image/webp", "image/png", "image/jpeg", "image/jpg", "image/gif"]
const VIDEO_TYPES: Array[String] = ["video/mp4", "video/webm", "video/ogg", "video/quicktime", "video/x-matroska"]
const THUMB_HEIGHT: int = 180

# Bridge platform badge colors
const BRIDGE_COLORS: Dictionary = {
	"IRC": "#e67e22", "Discord": "#5865f2", "Matrix": "#0dbd8b",
	"Mastodon": "#6364ff", "Email": "#ea4335"
}


func _ready() -> void:
	KeetService.room_selected.connect(_on_room_selected)
	KeetService.chat.messages_loaded.connect(_on_history_loaded)
	KeetService.chat.older_messages_loaded.connect(_on_older_loaded)
	KeetService.chat.no_more_history.connect(_on_no_more_history)
	KeetService.chat.message_received.connect(_on_message_received)
	KeetService.chat.message_sent.connect(_on_message_sent)

	send_btn.pressed.connect(_on_send_pressed)
	attach_btn.pressed.connect(_on_attach_pressed)
	message_input.submit_pressed.connect(_send_current)
	message_input.text_changed.connect(_on_input_text_changed)
	toggle_members_btn.pressed.connect(_on_toggle_members)
	files_btn.pressed.connect(_on_files_pressed)
	invite_btn.pressed.connect(_on_invite_pressed)
	settings_btn.pressed.connect(_on_settings_pressed)
	chat_display.meta_clicked.connect(_on_meta_clicked)
	# Graph button — connected last so a missing node doesn't break critical signals above
	if graph_btn:
		graph_btn.toggled.connect(_on_graph_toggled)

	search_btn.toggled.connect(_on_search_toggled)
	close_search_btn.pressed.connect(_close_search)
	search_input.text_changed.connect(_on_search_input_changed)
	search_input.text_submitted.connect(func(_t): _do_search())

	_search_debounce = Timer.new()
	_search_debounce.wait_time = 0.3
	_search_debounce.one_shot = true
	_search_debounce.timeout.connect(_do_search)
	add_child(_search_debounce)

	chat_display.bbcode_enabled = true
	chat_display.text = ""
	chat_display.gui_input.connect(_on_chat_gui_input)
	room_title.text = "Select a room"
	room_title.gui_input.connect(_on_title_gui_input)
	room_title_edit.text_submitted.connect(_on_title_submitted)
	room_title_edit.focus_exited.connect(_on_title_edit_cancel)
	message_input.editable = false

	# Track our member ID for edit/delete ownership checks
	KeetService.identity.identity_loaded.connect(func(ident: Dictionary):
		_my_member_id = ident.get("memberId", "")
	)
	# Also fetch eagerly in case identity was already loaded before us
	if KeetService.client.is_connected:
		KeetService.identity.get_identity()


func _on_room_selected(room_id: String, title: String) -> void:
	# Skip reload if same room — prevents image refetching on room list refresh
	if room_id == _current_room_id:
		room_title.text = title  # Title may have updated
		_current_title = title
		return

	_current_room_id = room_id
	_current_title = title
	room_title.text = title
	chat_display.text = ""
	message_input.editable = true
	_texture_cache.clear()
	_pending_fetches.clear()
	_msg_map.clear()
	_editing_msg_id = null
	_cached_messages.clear()
	if _search_active:
		_close_search()

	# Switch chat subscription
	KeetService.chat.watch(room_id)
	KeetService.chat.load_history()
	# Subscribe to file events for this room
	KeetService.file_mgr.subscribe_files(room_id)


func _on_history_loaded(messages: Array) -> void:
	chat_display.text = ""
	_message_files.clear()
	_cached_messages = messages.duplicate()
	for msg: Variant in messages:
		if msg is Dictionary:
			_append_message(msg)


func _on_message_received(msg: Dictionary) -> void:
	_cached_messages.append(msg)
	_append_message(msg)
	# Auto-scroll to bottom
	chat_display.scroll_to_line(chat_display.get_line_count() - 1)


func _on_older_loaded(messages: Array) -> void:
	if messages.is_empty():
		return
	# Prepend older messages to cache and re-render
	var older: Array = messages.duplicate()
	older.append_array(_cached_messages)
	_cached_messages = older
	# Re-render all — preserve scroll position near where we were
	var old_line_count: int = chat_display.get_line_count()
	_rerender_messages()
	# Scroll to where old content started (approximate)
	var new_line_count: int = chat_display.get_line_count()
	var added_lines: int = new_line_count - old_line_count
	if added_lines > 0:
		chat_display.scroll_to_line(added_lines)


func _on_no_more_history() -> void:
	pass  # Could show "Beginning of conversation" indicator


func _on_send_pressed() -> void:
	_send_current()


func _on_attach_pressed() -> void:
	if _current_room_id == "":
		return
	if _file_dialog == null:
		_file_dialog = FileDialog.new()
		_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
		_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
		_file_dialog.title = "Attach File"
		_file_dialog.min_size = Vector2i(600, 400)
		_file_dialog.filters = PackedStringArray([
			"*.png, *.jpg, *.jpeg, *.webp, *.gif ; Images",
			"*.mp4, *.webm, *.ogg, *.mov, *.mkv ; Videos",
			"*.mp3, *.wav, *.flac ; Audio",
			"*.pdf, *.zip, *.txt, *.json ; Documents",
			"* ; All Files"
		])
		_file_dialog.file_selected.connect(_on_file_selected)
		add_child(_file_dialog)
	_file_dialog.popup_centered()


func _on_file_selected(file_path: String) -> void:
	if _current_room_id == "" or file_path == "":
		return
	var file_name: String = file_path.get_file()
	attach_btn.disabled = true
	attach_btn.text = "..."
	print("[chat] sending file: %s" % file_path)
	KeetService.file_mgr.send_file(_current_room_id, file_path, func(result):
		attach_btn.disabled = false
		attach_btn.text = "+"
		if result == null:
			print("[chat] file send failed")
		else:
			print("[chat] file sent: %s" % str(result).substr(0, 80))
			# Reload history to show the sent file
			KeetService.chat.load_history()
	)


func _on_input_text_changed() -> void:
	# Cap input height at 35% of chat display height
	var max_h: float = chat_display.size.y * 0.35
	if max_h < 32.0:
		max_h = 32.0
	message_input.custom_minimum_size.y = 32.0
	var content_h: float = message_input.get_content_height() + 8.0
	if content_h > max_h:
		message_input.custom_minimum_size.y = max_h
		message_input.scroll_fit_content_height = false
	else:
		message_input.custom_minimum_size.y = maxf(content_h, 32.0)
		message_input.scroll_fit_content_height = true


func _send_current() -> void:
	var text: String = message_input.text.strip_edges()
	if text == "" or _current_room_id == "":
		return
	message_input.text = ""
	message_input.custom_minimum_size.y = 32.0
	message_input.scroll_fit_content_height = true

	if _editing_msg_id != null:
		KeetService.chat.edit(_editing_msg_id, text, func(_result):
			KeetService.chat.load_history()
		)
		_editing_msg_id = null
		send_btn.text = "Send"
	else:
		KeetService.chat.send(text)


func _on_message_sent() -> void:
	pass  # Already displayed optimistically


func _on_toggle_members() -> void:
	# Find the member panel via the parent split container
	var split: HSplitContainer = get_parent() as HSplitContainer
	if split == null:
		return
	# If graph is active, deactivate it first
	if _graph_active:
		_on_graph_toggled(false)
		graph_btn.button_pressed = false
	var member_panel: Control = split.get_child(2) if split.get_child_count() > 2 else null
	if member_panel:
		member_panel.visible = not member_panel.visible


func _on_graph_toggled(toggled_on: bool) -> void:
	var split: HSplitContainer = get_parent() as HSplitContainer
	if split == null:
		return
	var member_panel: Control = split.get_child(2) if split.get_child_count() > 2 else null

	if toggled_on:
		_graph_active = true
		if member_panel:
			member_panel.visible = false
		# Lazy-instantiate graph panel
		if _graph_panel == null:
			var scene: PackedScene = preload("res://scenes/graph_panel.tscn")
			_graph_panel = scene.instantiate()
			split.add_child(_graph_panel)
		else:
			_graph_panel.visible = true
	else:
		_graph_active = false
		if _graph_panel != null:
			_graph_panel.visible = false
		if member_panel and toggle_members_btn.button_pressed:
			member_panel.visible = true


func _on_files_pressed() -> void:
	if _current_room_id == "":
		return
	_open_media_viewer("", true)


func _on_invite_pressed() -> void:
	if _current_room_id == "":
		return
	if _invite_dialog == null:
		var scene: PackedScene = preload("res://scenes/invite_dialog.tscn")
		_invite_dialog = scene.instantiate()
		get_tree().root.add_child(_invite_dialog)
	_invite_dialog.show_for_room(_current_room_id, _current_title)


func _on_settings_pressed() -> void:
	if _current_room_id == "":
		return
	if _settings_window == null:
		var scene: PackedScene = preload("res://scenes/room_settings.tscn")
		_settings_window = scene.instantiate()
		_settings_window.room_left.connect(func(_rid: String):
			_settings_window.hide()
			KeetService.room_mgr.list_rooms()
		)
		get_tree().root.add_child(_settings_window)
	_settings_window.open_for_room(_current_room_id, _current_title)


func _on_meta_clicked(meta: Variant) -> void:
	var meta_str: String = str(meta)
	print("[chat] meta_clicked: %s" % meta_str.substr(0, 80))
	if meta_str.begins_with("img:"):
		var file_id: String = meta_str.substr(4)
		_open_image_lightbox(file_id)
	elif meta_str.begins_with("file:"):
		var file_id: String = meta_str.substr(5)
		_open_media_viewer(file_id, false)
	elif meta_str.begins_with("msg:"):
		var msg_key: String = meta_str.substr(4)
		_show_message_context_menu(msg_key)


func _open_media_viewer(file_id: String, gallery_mode: bool) -> void:
	if _media_viewer == null:
		var scene: PackedScene = preload("res://scenes/media_viewer.tscn")
		_media_viewer = scene.instantiate()
		get_tree().root.add_child(_media_viewer)
	if gallery_mode:
		_media_viewer.show_gallery(_current_room_id)
	else:
		_media_viewer.show_file(_current_room_id, file_id)


func _open_image_lightbox(file_id: String) -> void:
	if _image_lightbox == null:
		var scene: PackedScene = preload("res://scenes/image_lightbox.tscn")
		_image_lightbox = scene.instantiate()
		get_tree().root.add_child(_image_lightbox)
	_image_lightbox.show_image(_current_room_id, file_id, _message_files.duplicate())


func _append_message(msg: Dictionary) -> void:
	var sender_name: String = msg.get("name", "?")
	var text: String = msg.get("text", "")
	var ts: Variant = msg.get("timestamp", 0)
	var system: Variant = msg.get("system", null)
	var deleted: bool = msg.get("deleted", false)
	var files: Variant = msg.get("files", null)
	var msg_id: Variant = msg.get("id", null)
	var member_id: String = msg.get("memberId", "")

	var time_str: String = _format_timestamp(ts)
	var msg_key: String = _make_msg_key(msg)

	# Store message metadata for context menu
	if msg_key != "" and system == null:
		_msg_map[msg_key] = {"id": msg_id, "memberId": member_id, "text": text, "name": sender_name}
		print("[chat] stored msg key=%s member=%s" % [msg_key, member_id.substr(0, 12)])

	if system != null:
		chat_display.append_text("[color=yellow]%s * %s[/color]\n" % [time_str, _format_system(system)])
		return

	if deleted:
		chat_display.append_text("[color=gray]%s %s: [i]deleted[/i][/color]\n" % [time_str, sender_name])
		return

	# Hide _hs structured messages (room-meta, room-listing, bridge-announce, etc.)
	if text.begins_with('{"_hs":'):
		return

	# Bridge message detection: [IRC] <nick> text → platform badge + bridged nick
	var bridge: Dictionary = _parse_bridge_prefix(text)
	if not bridge.is_empty():
		var badge: String = _bridge_badge(bridge["platform"])
		var display_nick: String = bridge.get("nick", sender_name)
		var body: String = bridge.get("text", "")
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] %s[b]%s[/b]: %s\n" % [msg_key, time_str, badge, _bb_escape(display_nick), _bb_escape(body)])
	elif text != "":
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] [b]%s[/b]: %s\n" % [msg_key, time_str, _bb_escape(sender_name), _bb_escape(text)])
	elif files != null:
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] [b]%s[/b]:\n" % [msg_key, time_str, _bb_escape(sender_name)])

	# Render file attachments
	if files is Array:
		for file_data: Variant in files:
			if file_data is Dictionary:
				_append_file_attachment(file_data)


func _append_file_attachment(file_data: Dictionary) -> void:
	var raw_name: Variant = file_data.get("name", "file")
	var file_name: String = "file" if raw_name == null else str(raw_name)
	# Use filename as canonical ID (matches daemon's file lookup)
	var file_id: String = file_name
	var raw_type: Variant = file_data.get("type", "")
	var file_type: String = "" if raw_type == null else str(raw_type)
	var file_size: int = int(file_data.get("size", 0))
	var dims: Variant = file_data.get("dimensions", null)

	var is_image: bool = _is_image_type(file_type, file_name)
	var is_video: bool = _is_video_type(file_type, file_name)

	# Get best available httpLink — full URL has both "token=" and "blob=" params
	var http_link: String = _get_file_link(file_data)
	# Also check for preview links from daemon (has full auth URL for thumbnails)
	# For videos, prefer medium preview (small is often <500 bytes and very low res)
	var preview_link: String = _get_preview_link(file_data, is_video)

	print("[chat] file: name=%s type=%s link=%s preview=%s" % [file_name, file_type, http_link.substr(0, 60), preview_link.substr(0, 60)])

	var dim_str: String = ""
	if dims is Dictionary:
		dim_str = " %dx%d" % [int(dims.get("width", 0)), int(dims.get("height", 0))]

	if is_image or is_video:
		# Create placeholder texture for inline preview
		var placeholder: Image = Image.create(int(THUMB_HEIGHT * 1.33), THUMB_HEIGHT, false, Image.FORMAT_RGBA8)
		placeholder.fill(Color(0.2, 0.2, 0.25, 1.0))
		var tex: ImageTexture = ImageTexture.create_from_image(placeholder)

		# Track media files for lightbox navigation (images + videos)
		var preview_for_cache: String = preview_link if is_video else http_link
		_message_files.append({"file_id": file_id, "file_name": file_name, "http_link": preview_for_cache, "is_video": is_video})

		# All media uses img: prefix to open in lightbox
		var url_prefix: String = "img:"
		chat_display.append_text("  ")
		chat_display.add_image(tex, 0, THUMB_HEIGHT)
		var label_color: String = "orange" if is_video else "cyan"
		var label_prefix: String = "▶ " if is_video else ""
		chat_display.append_text("\n  [url=%s%s][color=%s]%s%s[/color][/url]" % [url_prefix, file_id, label_color, label_prefix, _bb_escape(file_name)])
		chat_display.append_text(" [color=gray](%s%s)[/color]\n" % [_format_file_size(file_size), dim_str])

		# Fetch preview: for videos, prefer preview_link (WebP thumbnail) over http_link
		# (which is the full video blob and can't be decoded as an image).
		# For images, prefer full httpLink (has token= AND blob=).
		if is_video:
			if preview_link != "" and "token=" in preview_link:
				_fetch_inline_image(preview_link, file_id, tex)
			elif file_id != "":
				_fetch_preview_via_rpc(file_id, tex, "medium")
		elif http_link != "" and "token=" in http_link and "blob=" in http_link:
			_fetch_inline_image(http_link, file_id, tex)
		elif preview_link != "" and "token=" in preview_link:
			_fetch_inline_image(preview_link, file_id, tex)
		elif file_id != "":
			_fetch_preview_via_rpc(file_id, tex)
	else:
		# Non-image file
		chat_display.append_text("  [url=file:%s][color=cyan]%s[/color][/url]" % [file_id, _bb_escape(file_name)])
		chat_display.append_text(" [color=gray](%s)[/color]\n" % _format_file_size(file_size))


func _is_image_type(file_type: String, file_name: String) -> bool:
	for img_type: String in IMAGE_TYPES:
		if file_type.begins_with(img_type):
			return true
	if file_name.get_extension().to_lower() in ["png", "jpg", "jpeg", "webp", "gif"]:
		return true
	return false


func _is_video_type(file_type: String, file_name: String) -> bool:
	for vid_type: String in VIDEO_TYPES:
		if file_type.begins_with(vid_type):
			return true
	if file_name.get_extension().to_lower() in ["mp4", "webm", "ogg", "mov", "mkv"]:
		return true
	return false


func _get_file_link(file_data: Dictionary) -> String:
	# Try httpLink first (full file URL from entry.httpLink in daemon)
	var raw: Variant = file_data.get("httpLink", null)
	if raw != null and str(raw) != "":
		return str(raw)
	return ""


func _get_preview_link(file_data: Dictionary, prefer_medium: bool = false) -> String:
	# Extract preview thumbnail links (daemon includes preview.small/medium httpLinks)
	var preview: Variant = file_data.get("preview", null)
	if preview is Dictionary:
		var order: Array[String] = ["medium", "large", "small"] if prefer_medium else ["small", "medium"]
		for size_key: String in order:
			var link: Variant = preview.get(size_key, null)
			if link != null and str(link) != "":
				return str(link)
	return ""


func _fetch_inline_image(http_link: String, file_id: String, tex: ImageTexture) -> void:
	# Check cache first
	var cached: ImageTexture = _texture_cache.get_cached(file_id)
	if cached != null:
		tex.set_image(cached.get_image())
		return

	# Skip if already fetching this file (in-flight dedup)
	if _pending_fetches.has(file_id):
		return
	_pending_fetches[file_id] = true

	# Fetch directly from keet-core's blob server (httpLink includes token auth)
	var http_req := HTTPRequest.new()
	add_child(http_req)
	http_req.request_completed.connect(func(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray):
		http_req.queue_free()
		_pending_fetches.erase(file_id)
		if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
			print("[chat] inline fetch failed: code=%d size=%d url=%s" % [response_code, body.size(), http_link.substr(0, 80)])
			return
		# Extract content type for format-aware decoding
		var content_type: String = ""
		for h: String in headers:
			if h.to_lower().begins_with("content-type:"):
				content_type = h.substr(h.find(":") + 1).strip_edges().to_lower()
				print("[chat] fetch OK: %s size=%d type=%s" % [file_id, body.size(), content_type])
				break
		var decoded_tex: ImageTexture = _texture_cache.decode_bytes_and_cache(file_id, body, content_type)
		if decoded_tex != null:
			tex.set_image(decoded_tex.get_image())
		else:
			print("[chat] decode failed for %s (%d bytes, type=%s)" % [file_id, body.size(), content_type])
	)
	print("[chat] fetching: %s" % http_link.substr(0, 100))
	http_req.request(http_link)


func _fetch_preview_via_rpc(file_id: String, tex: ImageTexture, size: String = "small") -> void:
	# Get httpLink from daemon, then fetch directly from blob server (localhost)
	KeetService.file_mgr.get_preview(_current_room_id, file_id, size, func(data: Dictionary) -> void:
		if not is_instance_valid(self):
			return
		var preview_link: String = str(data.get("httpLink", ""))
		if preview_link == "":
			print("[chat] no preview link for %s" % file_id)
			return
		print("[chat] got httpLink for %s: %s" % [file_id, preview_link.substr(0, 80)])
		# Fetch directly from blob server (runs on localhost, no proxy needed)
		_fetch_inline_image(preview_link, file_id, tex)
	)


func _format_file_size(bytes: int) -> String:
	if bytes <= 0:
		return "unknown size"
	if bytes < 1024:
		return "%d B" % bytes
	if bytes < 1024 * 1024:
		return "%.1f KB" % (float(bytes) / 1024.0)
	if bytes < 1024 * 1024 * 1024:
		return "%.1f MB" % (float(bytes) / (1024.0 * 1024.0))
	return "%.1f GB" % (float(bytes) / (1024.0 * 1024.0 * 1024.0))


func _format_timestamp(ts: Variant) -> String:
	if ts is float or ts is int:
		if ts == 0:
			return "??:??"
		# Convert epoch ms to HH:MM
		var epoch_sec: int = int(ts) / 1000
		var dict: Dictionary = Time.get_datetime_dict_from_unix_time(epoch_sec)
		return "%02d:%02d" % [dict.get("hour", 0), dict.get("minute", 0)]
	return "??:??"


func _format_system(generic: Variant) -> String:
	if generic is Dictionary:
		for key: String in generic.keys():
			var val: Variant = generic[key]
			if val != null and val != false:
				return key.to_upper()
	elif generic is int or generic is float:
		var names: Dictionary = {1: "MEMBER_JOINED", 2: "CALL_STARTED", 5: "MEMBER_LEFT", 6: "TITLE_CHANGED"}
		return names.get(int(generic), "EVENT_%d" % int(generic))
	return "SYSTEM_EVENT"


func _on_title_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.double_click:
		if _current_room_id == "":
			return
		room_title.visible = false
		room_title_edit.visible = true
		room_title_edit.text = _current_title
		room_title_edit.grab_focus()
		room_title_edit.select_all()


func _on_title_submitted(new_title: String) -> void:
	var title: String = new_title.strip_edges()
	room_title_edit.visible = false
	room_title.visible = true
	if title == "" or title == _current_title:
		return
	var old_title: String = _current_title
	_current_title = title
	room_title.text = title
	print("[chat] setting room title: room=%s title='%s'" % [_current_room_id, title])
	KeetService.room_mgr.set_config(_current_room_id, "title", title, func(result):
		print("[chat] set_config result: %s" % str(result))
		if result == null:
			# Revert on error
			_current_title = old_title
			room_title.text = old_title
		else:
			# Refresh room list to pick up new title
			KeetService.room_mgr.list_rooms()
	)


func _on_title_edit_cancel() -> void:
	room_title_edit.visible = false
	room_title.visible = true


func _make_msg_key(msg: Dictionary) -> String:
	var id: Variant = msg.get("id", null)
	var key: String = ""
	if id is Dictionary:
		key = "%s_%d" % [str(id.get("deviceId", "")).substr(0, 16), int(id.get("seq", 0))]
	elif id is String:
		key = str(id)
	else:
		key = "%s_%d" % [str(msg.get("memberId", "")).substr(0, 16), int(msg.get("seq", 0))]
	# Sanitize: remove BBCode-breaking chars
	return key.replace("[", "").replace("]", "").replace(" ", "")


func _on_chat_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_RIGHT:
		# Right-click on chat display — copies selected text
		DisplayServer.clipboard_set(chat_display.get_selected_text())
	elif event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_WHEEL_UP:
		# Load older messages when scrolled to the top
		var scrollbar: VScrollBar = chat_display.get_v_scroll_bar()
		if scrollbar and scrollbar.value <= 0.0:
			KeetService.chat.load_older()


func _show_message_context_menu(msg_key: String) -> void:
	print("[chat] context menu for key=%s map_size=%d my_id=%s" % [msg_key, _msg_map.size(), _my_member_id.substr(0, 12)])
	var msg_data: Dictionary = _msg_map.get(msg_key, {})
	if msg_data.is_empty():
		print("[chat] msg_key not found in map. Available keys: %s" % str(_msg_map.keys()).substr(0, 200))
		return

	if _context_menu != null:
		_context_menu.queue_free()

	_context_menu = PopupMenu.new()
	get_tree().root.add_child(_context_menu)

	var is_own: bool = msg_data.get("memberId", "") == _my_member_id
	if is_own:
		_context_menu.add_item("Edit", 0)
		_context_menu.add_item("Delete", 1)
		_context_menu.add_separator()
	_context_menu.add_item("Copy Text", 2)

	_context_menu.id_pressed.connect(func(id: int):
		match id:
			0:  # Edit
				_start_edit(msg_key, msg_data)
			1:  # Delete
				_confirm_delete(msg_key, msg_data)
			2:  # Copy
				DisplayServer.clipboard_set(msg_data.get("text", ""))
		_context_menu.queue_free()
		_context_menu = null
	)

	_context_menu.popup(Rect2i(
		Vector2i(get_viewport().get_mouse_position()),
		Vector2i.ZERO
	))


func _start_edit(msg_key: String, msg_data: Dictionary) -> void:
	_editing_msg_id = msg_data.get("id", null)
	message_input.text = msg_data.get("text", "")
	send_btn.text = "Save"
	message_input.grab_focus()


func _confirm_delete(msg_key: String, msg_data: Dictionary) -> void:
	var dialog := ConfirmationDialog.new()
	dialog.title = "Delete Message"
	dialog.dialog_text = "Delete this message?"
	dialog.confirmed.connect(func():
		KeetService.chat.delete(msg_data.get("id", null), func(_result):
			# Reload history to reflect deletion
			KeetService.chat.load_history()
		)
		dialog.queue_free()
	)
	dialog.canceled.connect(func(): dialog.queue_free())
	add_child(dialog)
	dialog.popup_centered()


func _on_search_toggled(toggled_on: bool) -> void:
	if toggled_on:
		_open_search()
	else:
		_close_search()


func _open_search() -> void:
	_search_active = true
	search_bar.visible = true
	search_btn.button_pressed = true
	search_input.text = ""
	search_count.text = ""
	search_input.grab_focus()


func _close_search() -> void:
	_search_active = false
	search_bar.visible = false
	search_btn.button_pressed = false
	search_input.text = ""
	search_count.text = ""
	# Re-render without highlights
	_rerender_messages()


func _on_search_input_changed(_text: String) -> void:
	_search_debounce.start()


func _do_search() -> void:
	var query: String = search_input.text.strip_edges()
	if query == "":
		search_count.text = ""
		_rerender_messages()
		return
	_rerender_messages_with_highlight(query)


func _rerender_messages() -> void:
	chat_display.text = ""
	_message_files.clear()
	_msg_map.clear()
	for msg: Variant in _cached_messages:
		if msg is Dictionary:
			_append_message(msg)


func _rerender_messages_with_highlight(query: String) -> void:
	chat_display.text = ""
	_message_files.clear()
	_msg_map.clear()
	var query_lower: String = query.to_lower()
	var match_count: int = 0

	for msg: Variant in _cached_messages:
		if not msg is Dictionary:
			continue
		var text: String = msg.get("text", "")
		if text.to_lower().find(query_lower) != -1:
			match_count += 1
			# Render with highlight
			_append_message_highlighted(msg, query)
		else:
			_append_message(msg)

	search_count.text = "%d found" % match_count


func _append_message_highlighted(msg: Dictionary, query: String) -> void:
	var sender_name: String = msg.get("name", "?")
	var text: String = msg.get("text", "")
	var ts: Variant = msg.get("timestamp", 0)
	var system: Variant = msg.get("system", null)
	var deleted: bool = msg.get("deleted", false)
	var files: Variant = msg.get("files", null)
	var msg_id: Variant = msg.get("id", null)
	var member_id: String = msg.get("memberId", "")

	var time_str: String = _format_timestamp(ts)
	var msg_key: String = _make_msg_key(msg)

	if msg_key != "" and system == null:
		_msg_map[msg_key] = {"id": msg_id, "memberId": member_id, "text": text, "name": sender_name}

	if system != null:
		chat_display.append_text("[color=yellow]%s * %s[/color]\n" % [time_str, _format_system(system)])
		return
	if deleted:
		chat_display.append_text("[color=gray]%s %s: [i]deleted[/i][/color]\n" % [time_str, sender_name])
		return

	if text.begins_with('{"_hs":'):
		return

	# Bridge + highlight
	var bridge: Dictionary = _parse_bridge_prefix(text)
	if not bridge.is_empty():
		var badge: String = _bridge_badge(bridge["platform"])
		var display_nick: String = bridge.get("nick", sender_name)
		var highlighted: String = _highlight_text(_bb_escape(bridge.get("text", "")), query)
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] %s[b]%s[/b]: %s\n" % [msg_key, time_str, badge, _bb_escape(display_nick), highlighted])
	elif text != "":
		var highlighted: String = _highlight_text(_bb_escape(text), query)
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] [b]%s[/b]: %s\n" % [msg_key, time_str, _bb_escape(sender_name), highlighted])
	elif files != null:
		chat_display.append_text("[url=msg:%s][color=gray]%s[/color][/url] [b]%s[/b]:\n" % [msg_key, time_str, _bb_escape(sender_name)])

	if files is Array:
		for file_data: Variant in files:
			if file_data is Dictionary:
				_append_file_attachment(file_data)


func _highlight_text(escaped_text: String, query: String) -> String:
	# Case-insensitive highlight with [color=yellow][/color] tags
	var result: String = ""
	var text_lower: String = escaped_text.to_lower()
	var query_lower: String = query.to_lower()
	var pos: int = 0
	while pos < escaped_text.length():
		var found: int = text_lower.find(query_lower, pos)
		if found == -1:
			result += escaped_text.substr(pos)
			break
		result += escaped_text.substr(pos, found - pos)
		result += "[color=yellow]" + escaped_text.substr(found, query.length()) + "[/color]"
		pos = found + query.length()
	return result


func _bb_escape(text: String) -> String:
	return text.replace("[", "[lb]")


## Parse bridge message prefix: "[IRC] <nick> text" → {platform, nick, text}
## Returns empty dict if not a bridge message.
static func _parse_bridge_prefix(text: String) -> Dictionary:
	if not text.begins_with("["):
		return {}
	var bracket_end: int = text.find("] ")
	if bracket_end < 2 or bracket_end > 12:
		return {}
	var platform: String = text.substr(1, bracket_end - 1)
	if not BRIDGE_COLORS.has(platform):
		return {}
	var rest: String = text.substr(bracket_end + 2)
	# Extract <nick> if present
	var nick: String = ""
	var body: String = rest
	if rest.begins_with("<"):
		var nick_end: int = rest.find("> ")
		if nick_end > 0:
			nick = rest.substr(1, nick_end - 1)
			body = rest.substr(nick_end + 2)
	return {"platform": platform, "nick": nick, "text": body}


## Format a platform badge as BBCode: [IRC] in orange, [Discord] in blurple, etc.
static func _bridge_badge(platform: String) -> String:
	var color: String = BRIDGE_COLORS.get(platform, "#aaaaaa")
	return "[color=%s][%s][/color] " % [color, platform]