Some changes codex made that I want to test how they work
This commit is contained in:
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -3824,7 +3824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxyde"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"serde",
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use surrealdb::Notification;
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::db::AppState;
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Message, Room};
|
||||
use crate::models::{Message, MessageReaction, MessageReactionSummary, Room, User};
|
||||
|
||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
const MAX_PAGE_SIZE: i64 = 100;
|
||||
const MAX_MESSAGE_LEN: usize = 4000;
|
||||
const MAX_ROOM_NAME_LEN: usize = 80;
|
||||
|
||||
/// Wrapper emitted to the frontend for each LIVE query notification.
|
||||
/// Includes the action type so the frontend can distinguish create/update/delete.
|
||||
@@ -15,92 +22,379 @@ struct LiveMessageEvent<'a> {
|
||||
data: &'a Message,
|
||||
}
|
||||
|
||||
/// Create a new chat room.
|
||||
fn validate_room_name(name: &str) -> Result<(), String> {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Auth("room name is required".into()).to_string());
|
||||
}
|
||||
if trimmed.chars().count() > MAX_ROOM_NAME_LEN {
|
||||
return Err(AppError::Auth(format!(
|
||||
"room name must be {MAX_ROOM_NAME_LEN} characters or less"
|
||||
))
|
||||
.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_message_body(body: &str) -> Result<(), String> {
|
||||
let trimmed = body.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(AppError::Auth("message cannot be empty".into()).to_string());
|
||||
}
|
||||
if trimmed.chars().count() > MAX_MESSAGE_LEN {
|
||||
return Err(AppError::Auth(format!(
|
||||
"message must be {MAX_MESSAGE_LEN} characters or less"
|
||||
))
|
||||
.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn current_user(state: &State<'_, AppState>) -> Result<User, String> {
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM $auth")
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||
}
|
||||
|
||||
async fn hydrate_reactions(
|
||||
state: &State<'_, AppState>,
|
||||
user: &User,
|
||||
messages: &mut [Message],
|
||||
) -> Result<(), String> {
|
||||
for message in messages {
|
||||
let reactions: Vec<MessageReaction> = state
|
||||
.db
|
||||
.query("SELECT * FROM message_reaction WHERE message = $message")
|
||||
.bind(("message", message.id.clone()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
let mut grouped: HashMap<String, MessageReactionSummary> = HashMap::new();
|
||||
for reaction in reactions {
|
||||
let entry = grouped
|
||||
.entry(reaction.emoji.clone())
|
||||
.or_insert(MessageReactionSummary {
|
||||
emoji: reaction.emoji,
|
||||
count: 0,
|
||||
reacted_by_me: false,
|
||||
});
|
||||
entry.count += 1;
|
||||
if reaction.user == user.id {
|
||||
entry.reacted_by_me = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut summaries: Vec<MessageReactionSummary> = grouped.into_values().collect();
|
||||
summaries.sort_by(|a, b| a.emoji.cmp(&b.emoji));
|
||||
message.reactions = Some(summaries);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_direct_rooms(
|
||||
state: &State<'_, AppState>,
|
||||
rooms: &mut [Room],
|
||||
) -> Result<(), String> {
|
||||
for room in rooms.iter_mut().filter(|room| room.kind == "direct") {
|
||||
let mut users: Vec<User> = state
|
||||
.db
|
||||
.query(
|
||||
"SELECT * FROM user
|
||||
WHERE id IN (
|
||||
SELECT VALUE user FROM room_member
|
||||
WHERE room = $room AND user != $auth
|
||||
)
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(("room", room.id.clone()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
room.other_user = users.pop();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new chat room and add the creator as owner.
|
||||
#[tauri::command]
|
||||
pub async fn create_room(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
kind: Option<String>,
|
||||
) -> Result<Room, String> {
|
||||
validate_room_name(&name)?;
|
||||
let room_kind = kind.unwrap_or_else(|| "public".to_string());
|
||||
if !matches!(room_kind.as_str(), "public" | "private") {
|
||||
return Err(AppError::Auth("room kind must be public or private".into()).to_string());
|
||||
}
|
||||
|
||||
let mut result: Vec<Room> = state
|
||||
.db
|
||||
.query("CREATE room SET name = $name, created = time::now()")
|
||||
.bind(("name", name))
|
||||
.query(
|
||||
"CREATE room SET
|
||||
name = $name,
|
||||
kind = $kind,
|
||||
created_by = $auth,
|
||||
created = time::now(),
|
||||
updated = time::now()",
|
||||
)
|
||||
.bind(("name", name.trim().to_string()))
|
||||
.bind(("kind", room_kind))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
||||
let room = result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("room after create".into())))?;
|
||||
|
||||
state
|
||||
.db
|
||||
.query(
|
||||
"CREATE room_member SET
|
||||
room = $room,
|
||||
user = $auth,
|
||||
role = 'owner',
|
||||
joined = time::now(),
|
||||
last_read_at = time::now(),
|
||||
muted = false",
|
||||
)
|
||||
.bind(("room", room.id.clone()))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(room)
|
||||
}
|
||||
|
||||
/// Fetch all rooms.
|
||||
/// Fetch public rooms and rooms the current user belongs to.
|
||||
#[tauri::command]
|
||||
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||
let result: Vec<Room> = state
|
||||
let mut result: Vec<Room> = state
|
||||
.db
|
||||
.query("SELECT * FROM room ORDER BY created DESC")
|
||||
.query(
|
||||
"SELECT * FROM room
|
||||
WHERE kind = 'public' OR id IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||
ORDER BY updated DESC, created DESC",
|
||||
)
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
hydrate_direct_rooms(&state, &mut result).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Add a user to a room. Room owners can invite others.
|
||||
#[tauri::command]
|
||||
pub async fn invite_to_room(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.query(
|
||||
"CREATE room_member SET
|
||||
room = type::record('room', $room_id),
|
||||
user = type::record('user', $user_id),
|
||||
role = 'member',
|
||||
joined = time::now(),
|
||||
muted = false",
|
||||
)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("user_id", user_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return an existing direct room for two users or create it.
|
||||
#[tauri::command]
|
||||
pub async fn get_or_create_direct_room(
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<Room, String> {
|
||||
let me = current_user(&state).await?;
|
||||
let me_key =
|
||||
serde_json::to_string(&me.id).map_err(|e| into_err(AppError::Auth(e.to_string())))?;
|
||||
let target_key = serde_json::json!({
|
||||
"table": "user",
|
||||
"key": { "String": user_id.clone() }
|
||||
})
|
||||
.to_string();
|
||||
let mut participants = [me_key, target_key];
|
||||
participants.sort();
|
||||
let direct_key = participants.join("|");
|
||||
|
||||
let mut existing: Vec<Room> = state
|
||||
.db
|
||||
.query("SELECT * FROM room WHERE kind = 'direct' AND direct_key = $direct_key LIMIT 1")
|
||||
.bind(("direct_key", direct_key.clone()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
if let Some(mut room) = existing.pop() {
|
||||
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||
return Ok(room);
|
||||
}
|
||||
|
||||
let mut created: Vec<Room> = state
|
||||
.db
|
||||
.query(
|
||||
"CREATE room SET
|
||||
name = NONE,
|
||||
kind = 'direct',
|
||||
direct_key = $direct_key,
|
||||
created_by = $auth,
|
||||
created = time::now(),
|
||||
updated = time::now()",
|
||||
)
|
||||
.bind(("direct_key", direct_key))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
let room = created
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("direct room after create".into())))?;
|
||||
|
||||
state
|
||||
.db
|
||||
.query(
|
||||
"CREATE room_member SET room = $room, user = $auth, role = 'owner', joined = time::now(), last_read_at = time::now(), muted = false;
|
||||
CREATE room_member SET room = $room, user = type::record('user', $user_id), role = 'member', joined = time::now(), muted = false;",
|
||||
)
|
||||
.bind(("room", room.id.clone()))
|
||||
.bind(("user_id", user_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
let mut room = room;
|
||||
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||
Ok(room)
|
||||
}
|
||||
|
||||
/// Send a message to a room.
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
body: String,
|
||||
reply_to: Option<String>,
|
||||
) -> Result<Message, String> {
|
||||
let mut result: Vec<Message> = state
|
||||
.db
|
||||
.query(
|
||||
"CREATE message SET
|
||||
validate_message_body(&body)?;
|
||||
|
||||
let query = if reply_to.is_some() {
|
||||
"CREATE message SET
|
||||
room = type::record('room', $room_id),
|
||||
author = $auth,
|
||||
author_username = $auth.username,
|
||||
body = $body,
|
||||
created = time::now()",
|
||||
)
|
||||
reply_to = type::record('message', $reply_to),
|
||||
deleted = false,
|
||||
created = time::now();
|
||||
UPDATE type::record('room', $room_id) SET updated = time::now();"
|
||||
} else {
|
||||
"CREATE message SET
|
||||
room = type::record('room', $room_id),
|
||||
author = $auth,
|
||||
author_username = $auth.username,
|
||||
body = $body,
|
||||
deleted = false,
|
||||
created = time::now();
|
||||
UPDATE type::record('room', $room_id) SET updated = time::now();"
|
||||
};
|
||||
|
||||
let mut response = state
|
||||
.db
|
||||
.query(query)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("body", body))
|
||||
.bind(("body", body.trim().to_string()));
|
||||
|
||||
if let Some(reply_to) = reply_to {
|
||||
response = response.bind(("reply_to", reply_to));
|
||||
}
|
||||
|
||||
let mut result: Vec<Message> = response
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||
}
|
||||
|
||||
/// Fetch all messages in a room, oldest first.
|
||||
/// Fetch a bounded page of messages in a room, oldest first.
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
before: Option<String>,
|
||||
limit: Option<i64>,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
let result: Vec<Message> = state
|
||||
let limit = limit.unwrap_or(DEFAULT_PAGE_SIZE).clamp(1, MAX_PAGE_SIZE);
|
||||
let query = if before.is_some() {
|
||||
"SELECT * FROM message
|
||||
WHERE room = type::record('room', $room_id) AND created < <datetime>$before
|
||||
ORDER BY created DESC
|
||||
LIMIT $limit"
|
||||
} else {
|
||||
"SELECT * FROM message
|
||||
WHERE room = type::record('room', $room_id)
|
||||
ORDER BY created DESC
|
||||
LIMIT $limit"
|
||||
};
|
||||
|
||||
let mut response = state
|
||||
.db
|
||||
.query("SELECT * FROM message WHERE room = type::record('room', $room_id) ORDER BY created ASC")
|
||||
.query(query)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("limit", limit));
|
||||
|
||||
if let Some(before) = before {
|
||||
response = response.bind(("before", before));
|
||||
}
|
||||
|
||||
let mut result: Vec<Message> = response
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.reverse();
|
||||
let user = current_user(&state).await?;
|
||||
hydrate_reactions(&state, &user, &mut result).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Delete a message by its ID string (e.g. "message:abc123").
|
||||
/// Soft-delete a message by its ID string.
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(
|
||||
state: State<'_, AppState>,
|
||||
message_id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn delete_message(state: State<'_, AppState>, message_id: String) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.query("DELETE type::record($id) WHERE author = $auth")
|
||||
.query("UPDATE type::record($id) SET deleted = true, body = '', updated = time::now() WHERE author = $auth")
|
||||
.bind(("id", message_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
@@ -108,6 +402,85 @@ pub async fn delete_message(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edit the current user's message.
|
||||
#[tauri::command]
|
||||
pub async fn edit_message(
|
||||
state: State<'_, AppState>,
|
||||
message_id: String,
|
||||
body: String,
|
||||
) -> Result<Message, String> {
|
||||
validate_message_body(&body)?;
|
||||
let mut result: Vec<Message> = state
|
||||
.db
|
||||
.query("UPDATE type::record($id) SET body = $body, updated = time::now() WHERE author = $auth RETURN AFTER")
|
||||
.bind(("id", message_id))
|
||||
.bind(("body", body.trim().to_string()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("message".into())))
|
||||
}
|
||||
|
||||
/// Toggle one emoji reaction for the current user.
|
||||
#[tauri::command]
|
||||
pub async fn toggle_reaction(
|
||||
state: State<'_, AppState>,
|
||||
message_id: String,
|
||||
emoji: String,
|
||||
) -> Result<(), String> {
|
||||
let emoji = emoji.trim();
|
||||
if emoji.is_empty() || emoji.chars().count() > 16 {
|
||||
return Err(AppError::Auth("invalid reaction".into()).to_string());
|
||||
}
|
||||
|
||||
let existing: Vec<MessageReaction> = state
|
||||
.db
|
||||
.query("SELECT * FROM message_reaction WHERE message = type::record($message_id) AND user = $auth AND emoji = $emoji")
|
||||
.bind(("message_id", message_id.clone()))
|
||||
.bind(("emoji", emoji.to_string()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
if existing.is_empty() {
|
||||
state
|
||||
.db
|
||||
.query("CREATE message_reaction SET message = type::record($message_id), user = $auth, emoji = $emoji, created = time::now()")
|
||||
.bind(("message_id", message_id))
|
||||
.bind(("emoji", emoji.to_string()))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
} else {
|
||||
state
|
||||
.db
|
||||
.query("DELETE message_reaction WHERE message = type::record($message_id) AND user = $auth AND emoji = $emoji")
|
||||
.bind(("message_id", message_id))
|
||||
.bind(("emoji", emoji.to_string()))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mark the room read for the current user.
|
||||
#[tauri::command]
|
||||
pub async fn mark_room_read(state: State<'_, AppState>, room_id: String) -> Result<(), String> {
|
||||
state
|
||||
.db
|
||||
.query("UPDATE room_member SET last_read_at = time::now() WHERE room = type::record('room', $room_id) AND user = $auth")
|
||||
.bind(("room_id", room_id))
|
||||
.await
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a LIVE query for new messages in a room.
|
||||
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||
///
|
||||
@@ -133,10 +506,13 @@ pub async fn subscribe_room(
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Some(Ok(notification)) = stream.next().await {
|
||||
let _ = app_handle.emit("chat:message", &LiveMessageEvent {
|
||||
action: format!("{:?}", notification.action),
|
||||
data: ¬ification.data,
|
||||
});
|
||||
let _ = app_handle.emit(
|
||||
"chat:message",
|
||||
&LiveMessageEvent {
|
||||
action: format!("{:?}", notification.action),
|
||||
data: ¬ification.data,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,10 +524,7 @@ pub async fn subscribe_room(
|
||||
/// Stop a LIVE query subscription.
|
||||
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||
#[tauri::command]
|
||||
pub async fn unsubscribe_room(
|
||||
state: State<'_, AppState>,
|
||||
sub_id: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn unsubscribe_room(state: State<'_, AppState>, sub_id: String) -> Result<(), String> {
|
||||
let uuid = sub_id
|
||||
.parse::<Uuid>()
|
||||
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||
|
||||
@@ -7,6 +7,58 @@ use crate::models::{Contact, User};
|
||||
|
||||
const SESSION_STORE: &str = "session.json";
|
||||
const TOKEN_KEY: &str = "token";
|
||||
const MIN_PASSWORD_LEN: usize = 8;
|
||||
const MAX_USERNAME_LEN: usize = 32;
|
||||
const MAX_EMAIL_LEN: usize = 254;
|
||||
|
||||
fn validate_email(email: &str) -> Result<(), String> {
|
||||
let email = email.trim();
|
||||
if email.is_empty() || email.len() > MAX_EMAIL_LEN || !email.contains('@') {
|
||||
return Err(AppError::Auth("enter a valid email address".into()).to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), String> {
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(AppError::Auth(format!(
|
||||
"password must be at least {MIN_PASSWORD_LEN} characters"
|
||||
))
|
||||
.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_username(username: &str) -> Result<(), String> {
|
||||
let username = username.trim();
|
||||
if username.is_empty() || username.chars().count() > MAX_USERNAME_LEN {
|
||||
return Err(
|
||||
AppError::Auth(format!("username must be 1-{MAX_USERNAME_LEN} characters")).to_string(),
|
||||
);
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||
{
|
||||
return Err(
|
||||
AppError::Auth("username can use letters, numbers, _, -, and .".into()).to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_avatar(avatar: &Option<String>) -> Result<(), String> {
|
||||
if let Some(avatar) = avatar {
|
||||
let avatar = avatar.trim();
|
||||
if !avatar.is_empty() && !(avatar.starts_with("https://") || avatar.starts_with("http://"))
|
||||
{
|
||||
return Err(
|
||||
AppError::Auth("avatar must be a valid http or https URL".into()).to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||
/// Returns the created User record. Persists the JWT token to disk.
|
||||
@@ -18,13 +70,17 @@ pub async fn signup(
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<User, String> {
|
||||
validate_email(&email)?;
|
||||
validate_username(&username)?;
|
||||
validate_password(&password)?;
|
||||
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: SURREAL_ACCESS.to_string(),
|
||||
namespace: SURREAL_NS.to_string(),
|
||||
database: SURREAL_DB.to_string(),
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"username": username,
|
||||
"email": email.trim(),
|
||||
"username": username.trim(),
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
@@ -41,7 +97,9 @@ pub async fn signup(
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
||||
}
|
||||
|
||||
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||
@@ -53,16 +111,25 @@ pub async fn signin(
|
||||
email: String,
|
||||
password: String,
|
||||
) -> Result<String, String> {
|
||||
validate_email(&email)?;
|
||||
validate_password(&password)?;
|
||||
|
||||
let credentials = surrealdb::opt::auth::Record {
|
||||
access: SURREAL_ACCESS.to_string(),
|
||||
namespace: SURREAL_NS.to_string(),
|
||||
database: SURREAL_DB.to_string(),
|
||||
params: serde_json::json!({
|
||||
"email": email,
|
||||
"email": email.trim(),
|
||||
"password": password,
|
||||
}),
|
||||
};
|
||||
let token_str = state.db.signin(credentials).await.map_err(into_err)?.access.into_insecure_token();
|
||||
let token_str = state
|
||||
.db
|
||||
.signin(credentials)
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.access
|
||||
.into_insecure_token();
|
||||
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||
save_token(&app_handle, &token_str)?;
|
||||
Ok(token_str)
|
||||
@@ -70,10 +137,7 @@ pub async fn signin(
|
||||
|
||||
/// Clear the current session. Invalidates the token in state and removes it from disk.
|
||||
#[tauri::command]
|
||||
pub async fn signout(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<(), String> {
|
||||
pub async fn signout(state: State<'_, AppState>, app_handle: AppHandle) -> Result<(), String> {
|
||||
state.db.invalidate().await.map_err(into_err)?;
|
||||
*state.token.lock().unwrap() = None;
|
||||
clear_token(&app_handle)?;
|
||||
@@ -89,11 +153,14 @@ pub async fn restore_session(
|
||||
state: State<'_, AppState>,
|
||||
app_handle: AppHandle,
|
||||
) -> Result<User, String> {
|
||||
let token_str = load_token(&app_handle)?.ok_or_else(|| {
|
||||
AppError::Auth("no saved session".into()).to_string()
|
||||
})?;
|
||||
let token_str = load_token(&app_handle)?
|
||||
.ok_or_else(|| AppError::Auth("no saved session".into()).to_string())?;
|
||||
|
||||
match state.db.authenticate(surrealdb::opt::auth::Token::from(token_str.clone())).await {
|
||||
match state
|
||||
.db
|
||||
.authenticate(surrealdb::opt::auth::Token::from(token_str.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
*state.token.lock().unwrap() = Some(token_str);
|
||||
|
||||
@@ -105,7 +172,9 @@ pub async fn restore_session(
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("session restored but $auth not set".into())))
|
||||
result.pop().ok_or_else(|| {
|
||||
into_err(AppError::Auth("session restored but $auth not set".into()))
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = clear_token(&app_handle);
|
||||
@@ -126,7 +195,9 @@ pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||
}
|
||||
|
||||
/// Update mutable profile fields. Only provided fields are changed.
|
||||
@@ -136,6 +207,11 @@ pub async fn update_profile(
|
||||
username: Option<String>,
|
||||
avatar: Option<String>,
|
||||
) -> Result<User, String> {
|
||||
if let Some(username) = &username {
|
||||
validate_username(username)?;
|
||||
}
|
||||
validate_avatar(&avatar)?;
|
||||
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query(
|
||||
@@ -144,14 +220,46 @@ pub async fn update_profile(
|
||||
avatar = $avatar ?? avatar
|
||||
RETURN AFTER",
|
||||
)
|
||||
.bind(("username", username))
|
||||
.bind(("avatar", avatar))
|
||||
.bind(("username", username.map(|s| s.trim().to_string())))
|
||||
.bind((
|
||||
"avatar",
|
||||
avatar
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||
}
|
||||
|
||||
/// Search users by username. Returns safe profile fields only.
|
||||
#[tauri::command]
|
||||
pub async fn search_users(state: State<'_, AppState>, query: String) -> Result<Vec<User>, String> {
|
||||
let query = query.trim();
|
||||
if query.chars().count() < 2 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let result: Vec<User> = state
|
||||
.db
|
||||
.query(
|
||||
"SELECT id, username, email, avatar, created FROM user
|
||||
WHERE id != $auth AND string::lowercase(username) CONTAINS string::lowercase($query)
|
||||
ORDER BY username
|
||||
LIMIT 10",
|
||||
)
|
||||
.bind(("query", query.to_string()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Return the contacts list for the current user.
|
||||
@@ -159,7 +267,11 @@ pub async fn update_profile(
|
||||
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||
let result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT target.* FROM contact WHERE owner = $auth")
|
||||
.query(
|
||||
"SELECT * FROM user
|
||||
WHERE id IN (SELECT VALUE target FROM contact WHERE owner = $auth)
|
||||
ORDER BY username",
|
||||
)
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
@@ -170,10 +282,7 @@ pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, Strin
|
||||
|
||||
/// Add a user to the current user's contact list.
|
||||
#[tauri::command]
|
||||
pub async fn add_contact(
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<Contact, String> {
|
||||
pub async fn add_contact(state: State<'_, AppState>, user_id: String) -> Result<Contact, String> {
|
||||
let mut result: Vec<Contact> = state
|
||||
.db
|
||||
.query("CREATE contact SET owner = $auth, target = type::record('user', $uid)")
|
||||
@@ -183,7 +292,9 @@ pub async fn add_contact(
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||
result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
@@ -196,7 +307,9 @@ fn save_token(app: &AppHandle, token: &str) -> Result<(), String> {
|
||||
|
||||
fn load_token(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let store = app.store(SESSION_STORE).map_err(|e| e.to_string())?;
|
||||
Ok(store.get(TOKEN_KEY).and_then(|v| v.as_str().map(String::from)))
|
||||
Ok(store
|
||||
.get(TOKEN_KEY)
|
||||
.and_then(|v| v.as_str().map(String::from)))
|
||||
}
|
||||
|
||||
fn clear_token(app: &AppHandle) -> Result<(), String> {
|
||||
|
||||
@@ -18,9 +18,13 @@ pub fn run() {
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let surreal = init_db(SURREAL_URL.as_str(), SURREAL_NS.as_str(), SURREAL_DB.as_str())
|
||||
.await
|
||||
.expect("Failed to connect to SurrealDB");
|
||||
let surreal = init_db(
|
||||
SURREAL_URL.as_str(),
|
||||
SURREAL_NS.as_str(),
|
||||
SURREAL_DB.as_str(),
|
||||
)
|
||||
.await
|
||||
.expect("Failed to connect to SurrealDB");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(surreal),
|
||||
@@ -39,13 +43,19 @@ pub fn run() {
|
||||
commands::user::get_me,
|
||||
commands::user::restore_session,
|
||||
commands::user::update_profile,
|
||||
commands::user::search_users,
|
||||
commands::user::get_contacts,
|
||||
commands::user::add_contact,
|
||||
commands::chat::create_room,
|
||||
commands::chat::get_rooms,
|
||||
commands::chat::invite_to_room,
|
||||
commands::chat::get_or_create_direct_room,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::delete_message,
|
||||
commands::chat::edit_message,
|
||||
commands::chat::toggle_reaction,
|
||||
commands::chat::mark_room_read,
|
||||
commands::chat::subscribe_room,
|
||||
commands::chat::unsubscribe_room,
|
||||
])
|
||||
|
||||
@@ -6,7 +6,7 @@ use surrealdb_types::SurrealValue;
|
||||
pub struct User {
|
||||
pub id: RecordId,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub avatar: Option<String>,
|
||||
pub created: Datetime,
|
||||
}
|
||||
@@ -14,8 +14,27 @@ pub struct User {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct Room {
|
||||
pub id: RecordId,
|
||||
pub name: String,
|
||||
pub name: Option<String>,
|
||||
pub kind: String,
|
||||
pub created_by: Option<RecordId>,
|
||||
pub direct_key: Option<String>,
|
||||
pub created: Datetime,
|
||||
pub updated: Option<Datetime>,
|
||||
pub last_message: Option<Message>,
|
||||
pub unread_count: Option<i64>,
|
||||
pub other_user: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RoomMember {
|
||||
pub id: RecordId,
|
||||
pub room: RecordId,
|
||||
pub user: RecordId,
|
||||
pub role: String,
|
||||
pub joined: Datetime,
|
||||
pub last_read_at: Option<Datetime>,
|
||||
pub muted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
@@ -26,6 +45,26 @@ pub struct Message {
|
||||
pub author_username: Option<String>,
|
||||
pub body: String,
|
||||
pub created: Datetime,
|
||||
pub updated: Option<Datetime>,
|
||||
pub deleted: Option<bool>,
|
||||
pub reply_to: Option<RecordId>,
|
||||
pub reactions: Option<Vec<MessageReactionSummary>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct MessageReaction {
|
||||
pub id: RecordId,
|
||||
pub message: RecordId,
|
||||
pub user: RecordId,
|
||||
pub emoji: String,
|
||||
pub created: Datetime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct MessageReactionSummary {
|
||||
pub emoji: String,
|
||||
pub count: i64,
|
||||
pub reacted_by_me: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
@@ -45,7 +84,10 @@ mod tests {
|
||||
fn _assert_serialize<T: Serialize + for<'de> Deserialize<'de>>() {}
|
||||
_assert_serialize::<User>();
|
||||
_assert_serialize::<Room>();
|
||||
_assert_serialize::<RoomMember>();
|
||||
_assert_serialize::<Message>();
|
||||
_assert_serialize::<MessageReaction>();
|
||||
_assert_serialize::<MessageReactionSummary>();
|
||||
_assert_serialize::<Contact>();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user