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]