use dioxus::prelude::*;
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
use matrix_sdk::attachment::{AttachmentConfig, AttachmentInfo, BaseAudioInfo};
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
use crate::room::composer::attachment_bar::{AttachmentBar, PendingAttachment};
use crate::room::composer::command_autocomplete::{CommandAutocomplete, SlashCommand};
use crate::room::composer::emoji_picker::EmojiPicker;
use crate::room::composer::emoji_shortcode::EmojiShortcodeAutocomplete;
use crate::room::composer::location_picker::LocationPicker;
use crate::room::composer::mention_autocomplete::{MentionAutocomplete, MentionSuggestion};
use crate::room::composer::poll_creator::PollCreator;
use crate::room::composer::reply_bar::ReplyBar;
use crate::room::composer::rich_text_editor::RichTextEditor;
use crate::room::room_upgrade::{RoomUpgradeDialog, perform_room_upgrade};
use crate::room::composer::sticker_picker::StickerPicker;
use crate::room::composer::voice_recorder::{RecordedVoiceMessage, VoiceRecorder};
use crate::pages::devtools::DevToolsDialog;
use crate::components::message_effects::EffectType;
use crate::platform::file_dialog::pick_file;
use crate::pages::rageshake::RageshakeDialog;
use crate::state::app_state::{AppState, AppView};
use crate::state::app_state::RightPanelView;
use crate::state::room_state::ReplyingTo;
async fn send_text_message(
state: Signal<AppState>,
room_id_str: &str,
text: &str,
reply_info: Option<ReplyingTo>,
) -> Result<(), String> {
let client = { state.read().client.clone() };
let client = client.ok_or_else(|| "Not logged in".to_string())?;
let room_id: OwnedRoomId = room_id_str
.try_into()
.map_err(|e| format!("Invalid room ID: {e}"))?;
let room = client
.get_room(&room_id)
.ok_or_else(|| format!("Room not found: {room_id}"))?;
if let Some(reply) = reply_info {
use matrix_sdk::ruma::events::room::message::RoomMessageEventContentWithoutRelation;
let content = RoomMessageEventContentWithoutRelation::text_plain(text);
let reply_meta = matrix_sdk::room::reply::Reply {
event_id: reply.event_id,
enforce_thread: matrix_sdk::room::reply::EnforceThread::Unthreaded,
};
let reply_content = room
.make_reply_event(content, reply_meta)
.await
.map_err(|e| format!("Failed to create reply: {e}"))?;
room.send(reply_content)
.await
.map_err(|e| format!("Send failed: {e}"))?;
} else {
let content = RoomMessageEventContent::text_plain(text);
room.send(content)
.await
.map_err(|e| format!("Send failed: {e}"))?;
}
Ok(())
}
async fn send_edit(
state: Signal<AppState>,
room_id_str: &str,
original_event_id: &OwnedEventId,
new_text: &str,
) -> Result<(), String> {
let client = { state.read().client.clone() };
let client = client.ok_or_else(|| "Not logged in".to_string())?;
let room_id: OwnedRoomId = room_id_str
.try_into()
.map_err(|e| format!("Invalid room ID: {e}"))?;
let room = client
.get_room(&room_id)
.ok_or_else(|| format!("Room not found: {room_id}"))?;
use matrix_sdk::ruma::events::room::message::ReplacementMetadata;
let metadata = ReplacementMetadata::new(original_event_id.clone(), None);
let new_content = RoomMessageEventContent::text_plain(new_text)
.make_replacement(metadata);
room.send(new_content)
.await
.map_err(|e| format!("Edit failed: {e}"))?;
Ok(())
}
async fn send_typing(state: Signal<AppState>, room_id_str: &str, typing: bool) {
let client = { state.read().client.clone() };
let Some(client) = client else { return };
let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(room_id_str) else { return };
let Some(room) = client.get_room(&room_id) else { return };
if typing {
let _ = room.typing_notice(true).await;
} else {
let _ = room.typing_notice(false).await;
}
}
pub async fn send_read_receipt(state: Signal<AppState>, room_id_str: &str, event_id_str: &str) {
use matrix_sdk::ruma::OwnedEventId;
let client = { state.read().client.clone() };
let Some(client) = client else { return };
let Ok(room_id) = <&str as TryInto<OwnedRoomId>>::try_into(room_id_str) else { return };
let Ok(event_id) = <&str as TryInto<OwnedEventId>>::try_into(event_id_str) else { return };
let Some(room) = client.get_room(&room_id) else { return };
let _ = room.send_single_receipt(
matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType::Read,
matrix_sdk::ruma::events::receipt::ReceiptThread::Unthreaded,
event_id,
).await;
}
fn set_active_room(mut state: Signal<AppState>, room_id: OwnedRoomId) {
let mut state_write = state.write();
state_write.active_room_id = Some(room_id.clone());
state_write.current_view = AppView::Room(room_id);
}
fn attachment_reply_config(reply_info: Option<ReplyingTo>) -> Option<matrix_sdk::room::reply::Reply> {
reply_info.map(|reply| matrix_sdk::room::reply::Reply {
event_id: reply.event_id,
enforce_thread: matrix_sdk::room::reply::EnforceThread::Unthreaded,
})
}
fn raw_state_content_json(
raw: matrix_sdk::deserialized_responses::RawAnySyncOrStrippedState,
) -> Result<serde_json::Value, String> {
let value = match raw {
matrix_sdk::deserialized_responses::RawAnySyncOrStrippedState::Sync(raw) => raw
.deserialize_as::<serde_json::Value>()
.map_err(|e| format!("Failed to parse room state: {e}"))?,
matrix_sdk::deserialized_responses::RawAnySyncOrStrippedState::Stripped(raw) => raw
.deserialize_as::<serde_json::Value>()
.map_err(|e| format!("Failed to parse room state: {e}"))?,
};
Ok(value
.get("content")
.cloned()
.unwrap_or_else(|| serde_json::json!({})))
}
async fn ensure_direct_message_room(
client: &matrix_sdk::Client,
target: &OwnedUserId,
) -> Result<OwnedRoomId, String> {
for room in client.rooms() {
if room.is_direct().await.unwrap_or(false)
&& room.direct_targets().iter().any(|candidate| candidate == target)
{
return Ok(room.room_id().to_owned());
}
}
use matrix_sdk::ruma::api::client::room::create_room::v3::{
Request as CreateRoomRequest,
RoomPreset,
};
let mut request = CreateRoomRequest::new();
request.is_direct = true;
request.invite = vec![target.clone()];
request.preset = Some(RoomPreset::TrustedPrivateChat);
let response = client
.send(request)
.await
.map_err(|e| format!("Failed to create direct message room: {e}"))?;
if let Err(e) = client
.account()
.mark_as_dm(&response.room_id, &[target.clone()])
.await
{
tracing::warn!("Created DM room {} but failed to mark it as DM: {e}", response.room_id);
}
Ok(response.room_id)
}
#[derive(Clone, Copy)]
struct SlashCommandUi {
show_devtools: Signal<bool>,
show_rageshake: Signal<bool>,
show_room_upgrade: Signal<bool>,
}
async fn handle_slash_command(
mut state: Signal<AppState>,
room_id_str: &str,
cmd: &SlashCommand,
args: &str,
mut ui: SlashCommandUi,
) -> Result<(), String> {
let client = { state.read().client.clone() };
let client = client.ok_or_else(|| "Not logged in".to_string())?;
let room_id: OwnedRoomId = room_id_str.try_into().map_err(|e| format!("Invalid room ID: {e}"))?;
let room = client.get_room(&room_id).ok_or_else(|| format!("Room not found: {room_id}"))?;
match cmd.name.as_str() {
"me" => {
use matrix_sdk::ruma::events::room::message::RoomMessageEventContent;
let content = RoomMessageEventContent::emote_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"topic" => {
use matrix_sdk::ruma::events::room::topic::RoomTopicEventContent;
let content = RoomTopicEventContent::new(args.to_string());
room.send_state_event(content).await.map_err(|e| format!("Failed to set topic: {e}"))?;
}
"invite" => {
if let Ok(user_id) = matrix_sdk::ruma::OwnedUserId::try_from(args.trim()) {
room.invite_user_by_id(&user_id).await.map_err(|e| format!("Invite failed: {e}"))?;
} else {
return Err("Invalid user ID".to_string());
}
}
"kick" => {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let user_str = parts.first().unwrap_or(&"");
let reason = parts.get(1).map(|s| s.to_string());
if let Ok(user_id) = matrix_sdk::ruma::OwnedUserId::try_from(*user_str) {
room.kick_user(&user_id, reason.as_deref()).await.map_err(|e| format!("Kick failed: {e}"))?;
} else {
return Err("Invalid user ID".to_string());
}
}
"ban" => {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let user_str = parts.first().unwrap_or(&"");
let reason = parts.get(1).map(|s| s.to_string());
if let Ok(user_id) = matrix_sdk::ruma::OwnedUserId::try_from(*user_str) {
room.ban_user(&user_id, reason.as_deref()).await.map_err(|e| format!("Ban failed: {e}"))?;
} else {
return Err("Invalid user ID".to_string());
}
}
"nick" => {
client.account().set_display_name(Some(args)).await.map_err(|e| format!("Nick change failed: {e}"))?;
}
"join" => {
client.join_room_by_id_or_alias(
<&str as TryInto<matrix_sdk::ruma::OwnedRoomOrAliasId>>::try_into(args.trim())
.map_err(|e| format!("Invalid room: {e}"))?.as_ref(),
&[],
).await.map_err(|e| format!("Join failed: {e}"))?;
}
"leave" => {
room.leave().await.map_err(|e| format!("Leave failed: {e}"))?;
}
"shrug" => {
let text = if args.is_empty() { "¯\\_(ツ)_/¯".to_string() } else { format!("{args} ¯\\_(ツ)_/¯") };
let content = RoomMessageEventContent::text_plain(&text);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"tableflip" => {
let text = if args.is_empty() { "(╯°□°)╯︵ ┻━┻".to_string() } else { format!("{args} (╯°□°)╯︵ ┻━┻") };
let content = RoomMessageEventContent::text_plain(&text);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"unflip" => {
let text = if args.is_empty() { "┬─┬ノ( º _ ºノ)".to_string() } else { format!("{args} ┬─┬ノ( º _ ºノ)") };
let content = RoomMessageEventContent::text_plain(&text);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"lenny" => {
let text = if args.is_empty() { "( ͡° ͜ʖ ͡°)".to_string() } else { format!("{args} ( ͡° ͜ʖ ͡°)") };
let content = RoomMessageEventContent::text_plain(&text);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"plain" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"html" => {
let content = RoomMessageEventContent::text_html(args, args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"spoiler" => {
let html = format!("<span data-mx-spoiler>{args}</span>");
let content = RoomMessageEventContent::text_html(args, &html);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"unban" => {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let user_str = parts.first().unwrap_or(&"");
let reason = parts.get(1).map(|s| s.to_string());
if let Ok(user_id) = matrix_sdk::ruma::OwnedUserId::try_from(*user_str) {
room.unban_user(&user_id, reason.as_deref()).await.map_err(|e| format!("Unban failed: {e}"))?;
} else {
return Err("Invalid user ID".to_string());
}
}
"rainbow" => {
let colors = ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#8b00ff"];
let html: String = args.chars().enumerate()
.map(|(i, c)| {
if c == ' ' {
" ".to_string()
} else {
format!("<span style=\"color:{}\">{}</span>", colors[i % colors.len()], c)
}
})
.collect();
let content = RoomMessageEventContent::text_html(args, &html);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"rainbowme" => {
let colors = ["#ff0000", "#ff7f00", "#ffff00", "#00ff00", "#0000ff", "#4b0082", "#8b00ff"];
let html: String = args.chars().enumerate()
.map(|(i, c)| {
if c == ' ' {
" ".to_string()
} else {
format!("<span style=\"color:{}\">{}</span>", colors[i % colors.len()], c)
}
})
.collect();
let content = RoomMessageEventContent::emote_html(args, &html);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
}
"confetti" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
state.write().active_effect = Some(EffectType::Confetti);
}
"fireworks" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
state.write().active_effect = Some(EffectType::Fireworks);
}
"rainfall" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
state.write().active_effect = Some(EffectType::Rainfall);
}
"snowfall" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
state.write().active_effect = Some(EffectType::Snow);
}
"spaceinvaders" => {
let content = RoomMessageEventContent::text_plain(args);
room.send(content).await.map_err(|e| format!("Send failed: {e}"))?;
state.write().active_effect = Some(EffectType::SpaceInvaders);
}
"ignore" => {
let user_str = args.trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Invalid user ID".to_string())?;
client
.account()
.ignore_user(&user_id)
.await
.map_err(|e| format!("Failed to ignore user: {e}"))?;
let user_id_str = user_id.to_string();
let mut state_write = state.write();
if !state_write.ignored_users.iter().any(|existing| existing == &user_id_str) {
state_write.ignored_users.push(user_id_str);
}
}
"unignore" => {
let user_str = args.trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Invalid user ID".to_string())?;
client
.account()
.unignore_user(&user_id)
.await
.map_err(|e| format!("Failed to unignore user: {e}"))?;
let user_id_str = user_id.to_string();
state
.write()
.ignored_users
.retain(|existing| existing != &user_id_str);
}
"op" => {
let parts: Vec<&str> = args.splitn(2, ' ').collect();
let user_str = parts.first().unwrap_or(&"").trim();
let level_str = parts.get(1).unwrap_or(&"").trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Invalid user ID. Usage: /op <user_id> [level]".to_string())?;
let level = if level_str.is_empty() {
50
} else {
level_str
.parse::<i32>()
.map_err(|_| "Invalid power level. Usage: /op <user_id> [level]".to_string())?
};
room.update_power_levels(vec![(&user_id, level.into())])
.await
.map_err(|e| format!("Failed to update power level: {e}"))?;
}
"deop" => {
let user_str = args.trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Invalid user ID. Usage: /deop <user_id>".to_string())?;
let default_level = room
.power_levels()
.await
.map_err(|e| format!("Failed to read current power levels: {e}"))?;
let default_level = i32::try_from(i64::from(default_level.users_default))
.map_err(|_| "Default power level is out of range".to_string())?;
room.update_power_levels(vec![(&user_id, default_level.into())])
.await
.map_err(|e| format!("Failed to reset power level: {e}"))?;
}
"discardsession" => {
tracing::info!("Discarding current encryption session for room {room_id_str}");
}
"addwidget" => {
let url = args.trim();
tracing::info!("Adding widget: {url}");
}
"upgraderoom" => {
let version = args.trim();
if version.is_empty() {
ui.show_room_upgrade.set(true);
} else {
let new_room_id = perform_room_upgrade(state, room_id_str, version).await?;
if let Ok(parsed_room_id) = OwnedRoomId::try_from(new_room_id.as_str()) {
set_active_room(state, parsed_room_id);
}
}
}
"whois" => {
let user_str = args.trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Usage: /whois <user_id>".to_string())?;
state.write().right_panel = RightPanelView::MemberDetail(user_id.to_string());
}
"rageshake" => {
let _description = args.trim();
ui.show_rageshake.set(true);
}
"goto" => {
let target = args.trim();
if target.is_empty() {
return Err("Usage: /goto <room_id_or_alias>".to_string());
}
let target = <&str as TryInto<matrix_sdk::ruma::OwnedRoomOrAliasId>>::try_into(target)
.map_err(|e| format!("Invalid room or alias: {e}"))?;
let joined_room = client
.join_room_by_id_or_alias(target.as_ref(), &[])
.await
.map_err(|e| format!("Failed to open room: {e}"))?;
set_active_room(state, joined_room.room_id().to_owned());
}
"holdcall" => {
let mut state_write = state.write();
if !state_write.call_state.is_active() {
return Err("No active call to hold".to_string());
}
state_write.call_state.is_on_hold = true;
state_write.call_state.is_muted = true;
state_write.call_state.is_video_enabled = false;
}
"unholdcall" => {
let mut state_write = state.write();
if !state_write.call_state.is_active() {
return Err("No active call to resume".to_string());
}
state_write.call_state.is_on_hold = false;
state_write.call_state.is_muted = false;
if matches!(state_write.call_state.call_type, crate::room::call::call_state::CallType::Video) {
state_write.call_state.is_video_enabled = true;
}
}
"jumptodate" => {
let date = args.trim();
tracing::info!("Jumping to date: {date}");
}
"msg" | "myroomnick" | "myroomavatar" | "roomavatar"
| "devtools" | "converttodm" | "converttoroom" => {
match cmd.name.as_str() {
"msg" => {
let parts: Vec<&str> = args.trim().splitn(2, ' ').collect();
let user_str = parts.first().unwrap_or(&"").trim();
let message = parts.get(1).copied().unwrap_or("").trim();
let user_id = matrix_sdk::ruma::OwnedUserId::try_from(user_str)
.map_err(|_| "Invalid user ID. Usage: /msg <user_id> [message]".to_string())?;
let dm_room_id = ensure_direct_message_room(&client, &user_id).await?;
set_active_room(state, dm_room_id.clone());
if !message.is_empty() {
let dm_room = client
.get_room(&dm_room_id)
.ok_or_else(|| "Direct message room is not available yet; try again after sync".to_string())?;
dm_room
.send(RoomMessageEventContent::text_plain(message))
.await
.map_err(|e| format!("Failed to send direct message: {e}"))?;
}
}
"myroomnick" => {
if args.trim().is_empty() {
return Err("Usage: /myroomnick <display_name>".to_string());
}
room.set_own_member_display_name(Some(args.trim().to_string()))
.await
.map_err(|e| format!("Failed to set room nickname: {e}"))?;
}
"myroomavatar" => {
let avatar_url = args.trim();
if avatar_url.is_empty() {
return Err("Usage: /myroomavatar <mxc_url>".to_string());
}
let user_id = client
.user_id()
.ok_or_else(|| "Not logged in".to_string())?
.to_owned();
let mut content = match room
.get_state_event(matrix_sdk::ruma::events::StateEventType::RoomMember, user_id.as_str())
.await
.map_err(|e| format!("Failed to read current member state: {e}"))?
{
Some(raw) => raw_state_content_json(raw)?,
None => serde_json::json!({ "membership": "join" }),
};
let content_object = content
.as_object_mut()
.ok_or_else(|| "Current member state is not a JSON object".to_string())?;
content_object
.entry("membership".to_string())
.or_insert_with(|| serde_json::Value::String("join".to_string()));
content_object.insert(
"avatar_url".to_string(),
serde_json::Value::String(avatar_url.to_string()),
);
room.send_state_event_raw("m.room.member", user_id.as_str(), content)
.await
.map_err(|e| format!("Failed to set room avatar override: {e}"))?;
}
"roomavatar" => {
let avatar_url = args.trim();
if avatar_url.is_empty() {
return Err("Usage: /roomavatar <mxc_url>".to_string());
}
let avatar_uri: matrix_sdk::ruma::OwnedMxcUri = avatar_url.to_string().into();
room.set_avatar_url(&avatar_uri, None)
.await
.map_err(|e| format!("Failed to set room avatar: {e}"))?;
}
"devtools" => {
ui.show_devtools.set(true);
}
"converttodm" => {
room.set_is_direct(true)
.await
.map_err(|e| format!("Failed to mark room as direct message: {e}"))?;
if let Some(summary) = state.write().rooms.get_mut(&room_id) {
summary.is_direct = true;
}
}
"converttoroom" => {
room.set_is_direct(false)
.await
.map_err(|e| format!("Failed to convert direct message to room: {e}"))?;
if let Some(summary) = state.write().rooms.get_mut(&room_id) {
summary.is_direct = false;
}
}
_ => {}
}
}
_ => {
return Err(format!("Unknown command: /{}", cmd.name));
}
}
Ok(())
}
async fn send_file_attachment(
state: Signal<AppState>,
room_id_str: &str,
file_name: String,
mime_type: String,
data: Vec<u8>,
) -> Result<(), String> {
let client = { state.read().client.clone() };
let client = client.ok_or_else(|| "Not logged in".to_string())?;
let room_id: OwnedRoomId = room_id_str.try_into().map_err(|e| format!("Invalid room ID: {e}"))?;
let room = client.get_room(&room_id).ok_or_else(|| format!("Room not found: {room_id}"))?;
let content_type: mime::Mime = mime_type.parse().unwrap_or(mime::APPLICATION_OCTET_STREAM);
room.send_attachment(&file_name, &content_type, data, AttachmentConfig::new())
.await
.map_err(|e| format!("Upload failed: {e}"))?;
Ok(())
}
async fn send_voice_message(
state: Signal<AppState>,
room_id_str: &str,
message: RecordedVoiceMessage,
reply_info: Option<ReplyingTo>,
) -> Result<(), String> {
let client = { state.read().client.clone() };
let client = client.ok_or_else(|| "Not logged in".to_string())?;
let room_id: OwnedRoomId = room_id_str.try_into().map_err(|e| format!("Invalid room ID: {e}"))?;
let room = client.get_room(&room_id).ok_or_else(|| format!("Room not found: {room_id}"))?;
let content_type: mime::Mime = message
.mime_type
.parse()
.unwrap_or(mime::APPLICATION_OCTET_STREAM);
let info = BaseAudioInfo {
duration: Some(std::time::Duration::from_millis(message.duration_ms)),
size: None,
waveform: Some(message.waveform),
};
room.send_attachment(
&message.file_name,
&content_type,
message.data,
AttachmentConfig::new()
.info(AttachmentInfo::Voice(info))
.reply(attachment_reply_config(reply_info)),
)
.await
.map_err(|e| format!("Failed to send voice message: {e}"))?;
Ok(())
}
#[component]
pub fn MessageInput(room_id: String) -> Element {
let mut state = use_context::<Signal<AppState>>();
let mut message_text = use_signal(|| String::new());
let mut is_sending = use_signal(|| false);
let mut send_error = use_signal(|| Option::<String>::None);
let mut typing_sent = use_signal(|| false);
let mut show_emoji_picker = use_signal(|| false);
let mut show_sticker_picker = use_signal(|| false);
let mut show_poll_creator = use_signal(|| false);
let mut show_location_picker = use_signal(|| false);
let mut show_voice_recorder = use_signal(|| false);
let mut show_rich_editor = use_signal(|| false);
let mut show_devtools_dialog = use_signal(|| false);
let mut show_rageshake_dialog = use_signal(|| false);
let mut show_room_upgrade_dialog = use_signal(|| false);
let mut pending_attachments = use_signal(Vec::<PendingAttachment>::new);
let room_id_for_send = room_id.clone();
let room_id_for_key = room_id.clone();
let room_id_for_typing = room_id.clone();
let room_id_for_attach = room_id.clone();
let room_id_for_voice = room_id.clone();
let replying_to = state.read().replying_to.clone();
let editing = state.read().editing_message.clone();
let has_reply = replying_to.is_some();
let is_editing = editing.is_some();
let reply_sender = replying_to.as_ref().map(|r| r.sender_name.clone());
let reply_body = replying_to.as_ref().map(|r| r.body.clone());
let mut prev_editing_id = use_signal(|| Option::<String>::None);
{
let current_editing_id = editing.as_ref().map(|e| e.event_id.to_string());
let prev_id = prev_editing_id.read().clone();
if current_editing_id != prev_id {
prev_editing_id.set(current_editing_id.clone());
if let Some(ref edit) = editing {
message_text.set(edit.original_body.clone());
}
}
}
let current_text = message_text.read().clone();
let show_command_autocomplete = current_text.starts_with('/') && !current_text.contains(' ');
let command_query = if show_command_autocomplete {
current_text[1..].to_string()
} else {
String::new()
};
let mention_query = {
let text = ¤t_text;
if let Some(at_pos) = text.rfind('@') {
let before_at = if at_pos > 0 { text.as_bytes()[at_pos - 1] } else { b' ' };
if before_at == b' ' || at_pos == 0 {
let after_at = &text[at_pos + 1..];
if !after_at.contains(' ') && !after_at.is_empty() {
Some(after_at.to_string())
} else {
None
}
} else {
None
}
} else {
None
}
};
let show_mention_autocomplete = mention_query.is_some() && !show_command_autocomplete;
let emoji_shortcode_query = {
let text = ¤t_text;
if let Some(colon_pos) = text.rfind(':') {
let before_colon = if colon_pos > 0 { text.as_bytes()[colon_pos - 1] } else { b' ' };
if before_colon == b' ' || colon_pos == 0 {
let after_colon = &text[colon_pos + 1..];
if after_colon.len() >= 2 && !after_colon.contains(' ') && !after_colon.contains(':') {
Some(after_colon.to_string())
} else {
None
}
} else {
None
}
} else {
None
}
};
let show_emoji_shortcode = emoji_shortcode_query.is_some() && !show_command_autocomplete && !show_mention_autocomplete;
let mut mention_suggestions_signal = use_signal(Vec::<MentionSuggestion>::new);
let mention_query_for_effect = mention_query.clone();
use_effect(move || {
let query = mention_query_for_effect.clone();
if let Some(q) = query {
let q = q.to_lowercase();
spawn(async move {
let (client, active_room_id) = {
let s = state.read();
(s.client.clone(), s.active_room_id.clone())
};
if let (Some(client), Some(room_id)) = (client, active_room_id) {
if let Some(room) = client.get_room(&room_id) {
match room.members(matrix_sdk::RoomMemberships::ACTIVE).await {
Ok(members) => {
let suggestions: Vec<MentionSuggestion> = members.iter()
.filter(|m| {
let name = m.display_name().unwrap_or_default().to_lowercase();
let uid = m.user_id().to_string().to_lowercase();
name.contains(&q) || uid.contains(&q)
})
.take(8)
.map(|m| MentionSuggestion {
user_id: m.user_id().to_string(),
display_name: m.display_name().unwrap_or_default().to_string(),
avatar_url: m.avatar_url().map(|u| u.to_string()),
})
.collect();
mention_suggestions_signal.set(suggestions);
}
Err(e) => {
tracing::debug!("Failed to load members for autocomplete: {e}");
mention_suggestions_signal.set(Vec::new());
}
}
}
}
});
} else {
mention_suggestions_signal.set(Vec::new());
}
});
let mention_suggestions = mention_suggestions_signal.read().clone();
let mut do_send = move |rid: String| {
let text = message_text.read().clone();
if text.trim().is_empty() {
return;
}
is_sending.set(true);
send_error.set(None);
let editing = state.read().editing_message.clone();
let reply = state.read().replying_to.clone();
if editing.is_none() && !text.starts_with('/') {
let s = state.read();
let sender_name = s.user_profile.as_ref()
.and_then(|p| p.display_name.clone())
.unwrap_or_else(|| "Me".to_string());
let _sender_id = s.user_profile.as_ref()
.and_then(|p| p.user_id.as_ref().map(|u| u.to_string()))
.unwrap_or_else(|| "@me:local".to_string());
drop(s);
tracing::debug!("Local echo: {sender_name}: {text}");
}
spawn(async move {
if text.starts_with('/') && editing.is_none() {
let parts: Vec<&str> = text[1..].splitn(2, ' ').collect();
let cmd_name = parts.first().unwrap_or(&"").to_string();
let cmd_args = parts.get(1).unwrap_or(&"").to_string();
let cmd = SlashCommand {
name: cmd_name.clone(),
description: String::new(),
usage: String::new(),
};
let command_ui = SlashCommandUi {
show_devtools: show_devtools_dialog,
show_rageshake: show_rageshake_dialog,
show_room_upgrade: show_room_upgrade_dialog,
};
match handle_slash_command(state, &rid, &cmd, &cmd_args, command_ui).await {
Ok(()) => {
message_text.set(String::new());
typing_sent.set(false);
}
Err(e) => {
send_error.set(Some(e));
}
}
is_sending.set(false);
return;
}
let result = if let Some(edit) = editing {
send_edit(state, &rid, &edit.event_id, &text).await
} else {
send_text_message(state, &rid, &text, reply).await
};
match result {
Ok(()) => {
tracing::info!("Message sent to {rid}");
message_text.set(String::new());
typing_sent.set(false);
let mut s = state.write();
s.replying_to = None;
s.editing_message = None;
}
Err(e) => {
tracing::error!("Failed to send message: {e}");
send_error.set(Some(e));
}
}
is_sending.set(false);
});
};
let mut do_send_click = do_send.clone();
let rid_for_send = room_id_for_send.clone();
let on_send = move |_evt: Event<MouseData>| {
do_send_click(rid_for_send.clone());
};
let on_keydown = move |evt: Event<KeyboardData>| {
if evt.key() == Key::Enter && !evt.modifiers().shift() {
evt.prevent_default();
let text = message_text.read().clone();
if !text.trim().is_empty() && !is_editing {
let mut s = state.write();
s.composer_history.push(text.clone());
if s.composer_history.len() > 50 {
s.composer_history.remove(0);
}
s.composer_history_index = -1;
}
do_send(room_id_for_key.clone());
} else if evt.key() == Key::ArrowUp && message_text.read().is_empty() && !is_editing {
let mut s = state.write();
let history_len = s.composer_history.len() as i32;
if history_len > 0 {
let new_idx = if s.composer_history_index < 0 {
history_len - 1
} else if s.composer_history_index > 0 {
s.composer_history_index - 1
} else {
0
};
s.composer_history_index = new_idx;
let text = s.composer_history[new_idx as usize].clone();
drop(s);
message_text.set(text);
}
} else if evt.key() == Key::ArrowDown && state.read().composer_history_index >= 0 {
let mut s = state.write();
let history_len = s.composer_history.len() as i32;
let new_idx = s.composer_history_index + 1;
if new_idx >= history_len {
s.composer_history_index = -1;
drop(s);
message_text.set(String::new());
} else {
s.composer_history_index = new_idx;
let text = s.composer_history[new_idx as usize].clone();
drop(s);
message_text.set(text);
}
} else if evt.key() == Key::Escape && is_editing {
message_text.set(String::new());
state.write().editing_message = None;
}
};
let placeholder = if is_editing {
"Edit message..."
} else if has_reply {
"Reply..."
} else {
"Send a message..."
};
let attachments_list = pending_attachments.read().clone();
let is_rich = *show_rich_editor.read();
rsx! {
div {
class: if is_editing { "message-input message-input--editing" } else { "message-input" },
if let Some(err) = send_error.read().as_ref() {
div {
class: "message-input__error",
span { "{err}" }
button {
onclick: move |_| send_error.set(None),
"✕"
}
}
}
if is_editing {
div {
class: "message-input__edit-bar",
span { class: "message-input__edit-label", "✏ Editing message" }
button {
class: "message-input__edit-cancel",
title: "Cancel edit",
onclick: move |_| {
message_text.set(String::new());
state.write().editing_message = None;
},
"✕"
}
}
}
if has_reply {
ReplyBar {
sender_name: reply_sender,
body: reply_body,
on_cancel: move |_| {
state.write().replying_to = None;
},
}
}
if !attachments_list.is_empty() {
AttachmentBar {
attachments: attachments_list,
on_remove: move |idx: usize| {
pending_attachments.write().remove(idx);
},
on_clear_all: move |_| {
pending_attachments.write().clear();
},
}
}
if show_command_autocomplete {
CommandAutocomplete {
query: command_query,
on_select: move |cmd: SlashCommand| {
message_text.set(format!("/{} ", cmd.name));
},
}
}
if show_mention_autocomplete {
MentionAutocomplete {
query: mention_query.clone().unwrap_or_default(),
members: mention_suggestions,
on_select: move |user_id: String| {
let text = message_text.read().clone();
if let Some(at_pos) = text.rfind('@') {
let before = &text[..at_pos];
message_text.set(format!("{before}{user_id} "));
}
},
}
}
if show_emoji_shortcode {
EmojiShortcodeAutocomplete {
query: emoji_shortcode_query.clone().unwrap_or_default(),
on_select: move |emoji: String| {
let text = message_text.read().clone();
if let Some(colon_pos) = text.rfind(':') {
let before = &text[..colon_pos];
message_text.set(format!("{before}{emoji}"));
}
},
}
}
if *show_emoji_picker.read() {
EmojiPicker {
on_select: move |emoji: String| {
let current = message_text.read().clone();
message_text.set(format!("{current}{emoji}"));
show_emoji_picker.set(false);
},
on_close: move |_| {
show_emoji_picker.set(false);
},
}
}
if *show_sticker_picker.read() {
StickerPicker {
on_select: move |sticker: String| {
show_sticker_picker.set(false);
let current = message_text.read().clone();
message_text.set(format!("{current}{sticker}"));
},
on_close: move |_| {
show_sticker_picker.set(false);
},
}
}
if *show_poll_creator.read() {
PollCreator {
room_id: room_id.clone(),
on_close: move |_| show_poll_creator.set(false),
}
}
if *show_location_picker.read() {
LocationPicker {
room_id: room_id.clone(),
on_close: move |_| show_location_picker.set(false),
}
}
if *show_devtools_dialog.read() {
DevToolsDialog {
room_id: room_id.clone(),
on_close: move |_| show_devtools_dialog.set(false),
}
}
if *show_rageshake_dialog.read() {
RageshakeDialog {
on_close: move |_| show_rageshake_dialog.set(false),
}
}
if *show_room_upgrade_dialog.read() {
RoomUpgradeDialog {
room_id: room_id.clone(),
on_close: move |_| show_room_upgrade_dialog.set(false),
}
}
div {
class: "message-input__row",
if !is_editing {
button {
class: "message-input__attach-btn",
title: "Attach file",
onclick: move |_| {
let rid = room_id_for_attach.clone();
spawn(async move {
match pick_file("Choose a file to send", &[]).await {
Ok(Some(file)) => {
let file_name = file.file_name;
let data = file.bytes;
let mime_type = mime_guess::from_path(&file_name)
.first_or_octet_stream()
.to_string();
let file_size = data.len() as u64;
pending_attachments.write().push(PendingAttachment {
file_name: file_name.clone(),
file_size,
mime_type: mime_type.clone(),
is_uploading: true,
});
let idx = pending_attachments.read().len() - 1;
match send_file_attachment(state, &rid, file_name.clone(), mime_type, data).await {
Ok(()) => {
tracing::info!("File sent: {file_name}");
pending_attachments.write().remove(idx);
}
Err(e) => {
tracing::error!("File upload failed: {e}");
if let Some(att) = pending_attachments.write().get_mut(idx) {
att.is_uploading = false;
}
send_error.set(Some(format!("Upload failed: {e}")));
}
}
}
Ok(None) => {}
Err(err) => {
send_error.set(Some(err));
}
}
});
},
"📎"
}
}
if !is_editing {
button {
class: if is_rich { "message-input__richtext-btn message-input__richtext-btn--active" } else { "message-input__richtext-btn" },
title: if is_rich { "Switch to plain text" } else { "Switch to rich text editor" },
onclick: move |_| {
let current = *show_rich_editor.read();
show_rich_editor.set(!current);
},
"Aa"
}
}
if is_rich && !is_editing {
RichTextEditor {
value: current_text.clone(),
on_input: move |val: String| {
let was_empty = message_text.read().is_empty();
let is_empty_now = val.is_empty();
message_text.set(val);
if was_empty && !is_empty_now && !*typing_sent.read() {
typing_sent.set(true);
let rid = room_id_for_typing.clone();
spawn(async move { send_typing(state, &rid, true).await; });
} else if !was_empty && is_empty_now && *typing_sent.read() {
typing_sent.set(false);
let rid = room_id_for_typing.clone();
spawn(async move { send_typing(state, &rid, false).await; });
}
},
placeholder: Some(placeholder.to_string()),
disabled: Some(*is_sending.read()),
}
} else {
textarea {
class: "message-input__textarea",
placeholder: placeholder,
value: "{message_text}",
onpaste: move |_evt| {
tracing::debug!("Paste event detected in composer");
},
oninput: move |evt| {
let val = evt.value();
let was_empty = message_text.read().is_empty();
let is_empty = val.is_empty();
message_text.set(val);
if was_empty && !is_empty && !*typing_sent.read() {
typing_sent.set(true);
let rid = room_id_for_typing.clone();
spawn(async move {
send_typing(state, &rid, true).await;
});
} else if !was_empty && is_empty && *typing_sent.read() {
typing_sent.set(false);
let rid = room_id_for_typing.clone();
spawn(async move {
send_typing(state, &rid, false).await;
});
}
},
onkeydown: on_keydown,
disabled: *is_sending.read(),
rows: "1",
}
}
if !is_editing && *show_voice_recorder.read() {
VoiceRecorder {
on_send: move |message: RecordedVoiceMessage| {
show_voice_recorder.set(false);
is_sending.set(true);
send_error.set(None);
let reply = state.read().replying_to.clone();
let rid = room_id_for_voice.clone();
spawn(async move {
match send_voice_message(state, &rid, message, reply).await {
Ok(()) => {
tracing::info!("Voice message sent to {rid}");
state.write().replying_to = None;
}
Err(err) => {
tracing::error!("Failed to send voice message: {err}");
send_error.set(Some(err));
}
}
is_sending.set(false);
});
},
on_cancel: move |_| {
show_voice_recorder.set(false);
},
}
}
if !is_editing {
button {
class: "message-input__action-btn",
title: "Stickers",
onclick: move |_| {
let current = *show_sticker_picker.read();
show_sticker_picker.set(!current);
},
"🎨"
}
button {
class: "message-input__action-btn",
title: "Create poll",
onclick: move |_| show_poll_creator.set(true),
"📊"
}
button {
class: "message-input__action-btn",
title: "Share location",
onclick: move |_| show_location_picker.set(true),
"📍"
}
button {
class: "message-input__action-btn",
title: "Voice message",
onclick: move |_| {
let current = *show_voice_recorder.read();
show_voice_recorder.set(!current);
},
"🎤"
}
}
button {
class: "message-input__emoji-btn",
title: "Emoji",
onclick: move |_| {
let current = *show_emoji_picker.read();
show_emoji_picker.set(!current);
},
"😀"
}
button {
class: "message-input__send-btn",
onclick: on_send,
disabled: message_text.read().trim().is_empty() || *is_sending.read(),
title: if is_editing { "Save edit" } else { "Send message" },
if *is_sending.read() {
"..."
} else if is_editing {
"✓"
} else {
"➤"
}
}
}
}
}
}