Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9337ea01f2 | |||
| 555e79b390 | |||
| 0ccb77be40 | |||
| c7cb73b360 | |||
| 3c3118c74d | |||
| ba6e158ee2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oxyde",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"description": "A simple Tauri chat app, built with rust, vite, and surrealdb",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -3824,7 +3824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "oxyde"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "oxyde"
|
||||
version = "0.1.1"
|
||||
version = "0.1.4"
|
||||
description = "A simple Tauri chat app, built with rust, vite, and surrealdb"
|
||||
authors = ["qdust41"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use surrealdb::types::{RecordId, RecordIdKey};
|
||||
@@ -6,13 +7,35 @@ 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, MessageReaction, MessageReactionSummary, Room, User};
|
||||
use surrealdb::engine::remote::ws::Client;
|
||||
use surrealdb::Surreal;
|
||||
|
||||
use crate::models::{Message, MessageReaction, MessageReactionSummary, MessageSnippet, Room, User};
|
||||
use crate::AppState;
|
||||
|
||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||
const MAX_PAGE_SIZE: i64 = 100;
|
||||
const MAX_MESSAGE_LEN: usize = 4000;
|
||||
const MAX_CACHED_ROOMS: usize = 5;
|
||||
|
||||
fn cache_put(
|
||||
cache: &Arc<Mutex<HashMap<String, Vec<Message>>>>,
|
||||
order: &Arc<Mutex<Vec<String>>>,
|
||||
room_id: &str,
|
||||
messages: Vec<Message>,
|
||||
) {
|
||||
let mut c = cache.lock().unwrap();
|
||||
let mut o = order.lock().unwrap();
|
||||
c.insert(room_id.to_string(), messages);
|
||||
o.retain(|id| id != room_id);
|
||||
o.insert(0, room_id.to_string());
|
||||
while o.len() > MAX_CACHED_ROOMS {
|
||||
if let Some(evicted) = o.pop() {
|
||||
c.remove(&evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
const MAX_ROOM_NAME_LEN: usize = 80;
|
||||
|
||||
/// Wrapper emitted to the frontend for each LIVE query notification.
|
||||
@@ -143,6 +166,22 @@ async fn hydrate_reactions(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_replies(db: &Surreal<Client>, messages: &mut [Message]) -> Result<(), String> {
|
||||
for message in messages.iter_mut() {
|
||||
if let Some(reply_to_id) = &message.reply_to {
|
||||
let mut result: Vec<MessageSnippet> = db
|
||||
.query("SELECT id, author_username, body FROM message WHERE id = $id")
|
||||
.bind(("id", reply_to_id.clone()))
|
||||
.await
|
||||
.map_err(into_err)?
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
message.replied_to_message = result.pop();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn hydrate_direct_rooms(
|
||||
state: &State<'_, AppState>,
|
||||
rooms: &mut [Room],
|
||||
@@ -422,12 +461,32 @@ pub async fn send_message(
|
||||
.take(0)
|
||||
.map_err(into_err)?;
|
||||
|
||||
result
|
||||
let mut msg = result
|
||||
.pop()
|
||||
.ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||
.ok_or_else(|| into_err(AppError::NotFound("message after create".into())))?;
|
||||
|
||||
hydrate_replies(&state.db, std::slice::from_mut(&mut msg)).await?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Return cached messages for a room without hitting the remote DB.
|
||||
/// Returns an empty vec if the room has not been cached yet.
|
||||
#[tauri::command]
|
||||
pub async fn get_cached_messages(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
Ok(state
|
||||
.msg_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&room_id)
|
||||
.cloned()
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Fetch a bounded page of messages in a room, oldest first.
|
||||
/// Also updates the in-process message cache.
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(
|
||||
state: State<'_, AppState>,
|
||||
@@ -451,11 +510,11 @@ pub async fn get_messages(
|
||||
let mut response = state
|
||||
.db
|
||||
.query(query)
|
||||
.bind(("room_id", room_id))
|
||||
.bind(("room_id", room_id.clone()))
|
||||
.bind(("limit", limit));
|
||||
|
||||
if let Some(before) = before {
|
||||
response = response.bind(("before", before));
|
||||
if let Some(ref before) = before {
|
||||
response = response.bind(("before", before.clone()));
|
||||
}
|
||||
|
||||
let mut result: Vec<Message> = response
|
||||
@@ -467,6 +526,24 @@ pub async fn get_messages(
|
||||
result.reverse();
|
||||
let user = current_user(&state).await?;
|
||||
hydrate_reactions(&state, &user, &mut result).await?;
|
||||
hydrate_replies(&state.db, &mut result).await?;
|
||||
|
||||
if before.is_none() {
|
||||
cache_put(
|
||||
&state.msg_cache,
|
||||
&state.cache_order,
|
||||
&room_id,
|
||||
result.clone(),
|
||||
);
|
||||
} else {
|
||||
let mut c = state.msg_cache.lock().unwrap();
|
||||
if let Some(existing) = c.get_mut(&room_id) {
|
||||
let mut merged = result.clone();
|
||||
merged.extend_from_slice(existing);
|
||||
*existing = merged;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -563,7 +640,8 @@ pub async fn mark_room_read(state: State<'_, AppState>, room_id: String) -> Resu
|
||||
}
|
||||
|
||||
/// Start a LIVE query for new messages in a room.
|
||||
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||
/// Spawns a background tokio task that emits "chat:message" Tauri events
|
||||
/// and keeps the in-process message cache in sync.
|
||||
///
|
||||
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
|
||||
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
|
||||
@@ -574,6 +652,9 @@ pub async fn subscribe_room(
|
||||
room_id: String,
|
||||
) -> Result<String, String> {
|
||||
let db = state.db.clone();
|
||||
let msg_cache = Arc::clone(&state.msg_cache);
|
||||
let cache_order = Arc::clone(&state.cache_order);
|
||||
let room_id_cache = room_id.clone();
|
||||
|
||||
let mut stream = db
|
||||
.query("LIVE SELECT * FROM message WHERE room = type::record('room', $room_id)")
|
||||
@@ -587,11 +668,44 @@ pub async fn subscribe_room(
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Some(Ok(notification)) = stream.next().await {
|
||||
let action = format!("{:?}", notification.action);
|
||||
let mut data = notification.data.clone();
|
||||
|
||||
if data.reply_to.is_some() {
|
||||
let _ = hydrate_replies(&db, std::slice::from_mut(&mut data)).await;
|
||||
}
|
||||
|
||||
{
|
||||
let mut c = msg_cache.lock().unwrap();
|
||||
let mut o = cache_order.lock().unwrap();
|
||||
if let Some(msgs) = c.get_mut(&room_id_cache) {
|
||||
match action.as_str() {
|
||||
"Create" => msgs.push(data.clone()),
|
||||
"Update" => {
|
||||
if let Some(m) = msgs.iter_mut().find(|m| m.id == data.id) {
|
||||
*m = data.clone();
|
||||
}
|
||||
}
|
||||
"Delete" => msgs.retain(|m| m.id != data.id),
|
||||
_ => {}
|
||||
}
|
||||
} else if action == "Create" {
|
||||
c.insert(room_id_cache.clone(), vec![data.clone()]);
|
||||
o.retain(|id| id != &room_id_cache);
|
||||
o.insert(0, room_id_cache.clone());
|
||||
while o.len() > MAX_CACHED_ROOMS {
|
||||
if let Some(evicted) = o.pop() {
|
||||
c.remove(&evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = app_handle.emit(
|
||||
"chat:message",
|
||||
&LiveMessageEvent {
|
||||
action: format!("{:?}", notification.action),
|
||||
data: ¬ification.data,
|
||||
action,
|
||||
data: &data,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use tauri::{AppHandle, State};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::db::{AppState, SURREAL_ACCESS, SURREAL_DB, SURREAL_NS};
|
||||
use crate::db::{SURREAL_ACCESS, SURREAL_DB, SURREAL_NS};
|
||||
use crate::error::{into_err, AppError};
|
||||
use crate::models::{Contact, User};
|
||||
use crate::AppState;
|
||||
|
||||
const SESSION_STORE: &str = "session.json";
|
||||
const TOKEN_KEY: &str = "token";
|
||||
@@ -86,7 +87,6 @@ pub async fn signup(
|
||||
};
|
||||
let token = state.db.signup(credentials).await.map_err(into_err)?;
|
||||
let token_str = token.access.into_insecure_token();
|
||||
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||
save_token(&app_handle, &token_str)?;
|
||||
|
||||
let mut result: Vec<User> = state
|
||||
@@ -130,7 +130,6 @@ pub async fn signin(
|
||||
.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)
|
||||
}
|
||||
@@ -139,7 +138,6 @@ pub async fn signin(
|
||||
#[tauri::command]
|
||||
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)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -162,8 +160,6 @@ pub async fn restore_session(
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
*state.token.lock().unwrap() = Some(token_str);
|
||||
|
||||
let mut result: Vec<User> = state
|
||||
.db
|
||||
.query("SELECT * FROM $auth")
|
||||
@@ -178,7 +174,6 @@ pub async fn restore_session(
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = clear_token(&app_handle);
|
||||
*state.token.lock().unwrap() = None;
|
||||
Err(AppError::Auth("session expired, please sign in again".into()).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use surrealdb::engine::remote::ws::{Client, Ws, Wss};
|
||||
use surrealdb::Surreal;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// This should set the env variable correctly both during compile time and runtime (for development).
|
||||
pub static SURREAL_URL: LazyLock<String> = LazyLock::new(|| {
|
||||
option_env!("SURREAL_URL")
|
||||
.map(str::to_string)
|
||||
@@ -33,19 +29,6 @@ pub static SURREAL_ACCESS: LazyLock<String> = LazyLock::new(|| {
|
||||
.unwrap_or_else(|| env::var("SURREAL_ACCESS").unwrap_or_else(|_| "account".to_string()))
|
||||
});
|
||||
|
||||
pub struct AppState {
|
||||
/// Long-lived authenticated WebSocket connection to SurrealDB.
|
||||
pub db: Arc<Surreal<Client>>,
|
||||
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
|
||||
/// std::sync::Mutex is intentional: lock is acquired and released before any .await.
|
||||
pub token: Mutex<Option<String>>,
|
||||
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
|
||||
/// Abort a handle + KILL the query to clean up.
|
||||
/// std::sync::Mutex is intentional: guards are never held across .await points.
|
||||
/// If a future command needs to lock across .await, switch to tokio::sync::Mutex.
|
||||
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
/// Connect to SurrealDB over WebSocket and select namespace/database.
|
||||
/// URL may include protocol prefix: `ws://`, `wss://`, `http://`, or `https://`.
|
||||
/// `wss://` and `https://` use TLS; others use plain WebSocket.
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use surrealdb::engine::remote::ws::Client;
|
||||
use surrealdb::Surreal;
|
||||
use tauri::Manager;
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod commands;
|
||||
mod db;
|
||||
mod error;
|
||||
mod models;
|
||||
|
||||
use db::{init_db, AppState, SURREAL_DB, SURREAL_NS, SURREAL_URL};
|
||||
use db::{init_db, SURREAL_DB, SURREAL_NS, SURREAL_URL};
|
||||
use models::Message;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Surreal<Client>>,
|
||||
/// In-process message cache keyed by room_id string. Arc so the live-event
|
||||
/// task in subscribe_room can hold a reference without borrowing AppState.
|
||||
pub msg_cache: Arc<Mutex<HashMap<String, Vec<Message>>>>,
|
||||
/// LRU order of cached room IDs (front = most recent). Evicts beyond 5.
|
||||
pub cache_order: Arc<Mutex<Vec<String>>>,
|
||||
/// std::sync::Mutex is intentional: guards are never held across .await points.
|
||||
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -28,7 +44,8 @@ pub fn run() {
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(surreal),
|
||||
token: Mutex::new(None),
|
||||
msg_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
cache_order: Arc::new(Mutex::new(Vec::new())),
|
||||
subscriptions: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
@@ -52,6 +69,7 @@ pub fn run() {
|
||||
commands::chat::get_or_create_direct_room,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::get_cached_messages,
|
||||
commands::chat::delete_message,
|
||||
commands::chat::edit_message,
|
||||
commands::chat::toggle_reaction,
|
||||
|
||||
@@ -37,6 +37,13 @@ pub struct RoomMember {
|
||||
pub muted: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct MessageSnippet {
|
||||
pub id: RecordId,
|
||||
pub author_username: Option<String>,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||
pub struct Message {
|
||||
pub id: RecordId,
|
||||
@@ -48,6 +55,7 @@ pub struct Message {
|
||||
pub updated: Option<Datetime>,
|
||||
pub deleted: Option<bool>,
|
||||
pub reply_to: Option<RecordId>,
|
||||
pub replied_to_message: Option<MessageSnippet>,
|
||||
pub reactions: Option<Vec<MessageReactionSummary>>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "oxyde",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.4",
|
||||
"identifier": "com.jimweaver.oxyde",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
36
src/app.html
36
src/app.html
@@ -1,19 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Oxyde</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Oxyde</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossorigin=""
|
||||
/>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,119 +1,205 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
authMode: 'signin' | 'signup';
|
||||
err: string;
|
||||
fEmail: string;
|
||||
fPass: string;
|
||||
fUser: string;
|
||||
onSignin: () => void;
|
||||
onSignup: () => void;
|
||||
onToggleMode: () => void;
|
||||
}
|
||||
interface Props {
|
||||
authMode: "signin" | "signup";
|
||||
err: string;
|
||||
fEmail: string;
|
||||
fPass: string;
|
||||
fUser: string;
|
||||
onSignin: () => void;
|
||||
onSignup: () => void;
|
||||
onToggleMode: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
authMode,
|
||||
err,
|
||||
fEmail = $bindable(),
|
||||
fPass = $bindable(),
|
||||
fUser = $bindable(),
|
||||
onSignin,
|
||||
onSignup,
|
||||
onToggleMode,
|
||||
}: Props = $props();
|
||||
let {
|
||||
authMode,
|
||||
err,
|
||||
fEmail = $bindable(),
|
||||
fPass = $bindable(),
|
||||
fUser = $bindable(),
|
||||
onSignin,
|
||||
onSignup,
|
||||
onToggleMode,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-brand">OXYDE</h1>
|
||||
<p class="auth-tagline">realtime · native · focused</p>
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-brand">OXYDE</h1>
|
||||
<p class="auth-tagline">realtime · native · focused</p>
|
||||
|
||||
{#if err}
|
||||
<div class="err-banner">{err}</div>
|
||||
{/if}
|
||||
{#if err}
|
||||
<div class="err-banner">{err}</div>
|
||||
{/if}
|
||||
|
||||
{#if authMode === 'signin'}
|
||||
<div class="field-stack">
|
||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSignin()} autocomplete="current-password" />
|
||||
<button class="btn-primary" onclick={onSignin}>sign in</button>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
no account? create one →
|
||||
</button>
|
||||
{:else}
|
||||
<div class="field-stack">
|
||||
<input class="field" type="text" placeholder="username" bind:value={fUser} autocomplete="username" />
|
||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSignup()} autocomplete="new-password" />
|
||||
<button class="btn-primary" onclick={onSignup}>create account</button>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
← back to sign in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if authMode === "signin"}
|
||||
<div class="field-stack">
|
||||
<input
|
||||
class="field"
|
||||
type="email"
|
||||
placeholder="email"
|
||||
bind:value={fEmail}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<input
|
||||
class="field"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
bind:value={fPass}
|
||||
onkeydown={(e) => e.key === "Enter" && onSignin()}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button class="btn-primary" onclick={onSignin}>sign in</button>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
no account? create one →
|
||||
</button>
|
||||
{:else}
|
||||
<div class="field-stack">
|
||||
<input
|
||||
class="field"
|
||||
type="text"
|
||||
placeholder="username"
|
||||
bind:value={fUser}
|
||||
autocomplete="username"
|
||||
/>
|
||||
<input
|
||||
class="field"
|
||||
type="email"
|
||||
placeholder="email"
|
||||
bind:value={fEmail}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<input
|
||||
class="field"
|
||||
type="password"
|
||||
placeholder="password"
|
||||
bind:value={fPass}
|
||||
onkeydown={(e) => e.key === "Enter" && onSignup()}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button class="btn-primary" onclick={onSignup}
|
||||
>create account</button
|
||||
>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
← back to sign in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-wrap {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; background: var(--bg);
|
||||
animation: rise 0.28s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.auth-card {
|
||||
width: 360px; padding: 52px 44px;
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
.auth-brand {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 52px; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: 0.22em;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-tagline {
|
||||
text-align: center; color: var(--muted);
|
||||
font-size: 9.5px; letter-spacing: 0.15em;
|
||||
margin-top: 8px; margin-bottom: 36px;
|
||||
}
|
||||
.err-banner {
|
||||
padding: 10px 14px; margin-bottom: 18px;
|
||||
background: rgba(184, 48, 48, 0.10);
|
||||
border: 1px solid rgba(184, 48, 48, 0.28);
|
||||
border-radius: var(--r);
|
||||
color: #d98080; font-size: 11px; line-height: 1.5;
|
||||
}
|
||||
.field-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.field {
|
||||
width: 100%; padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 12px;
|
||||
outline: none; transition: border-color 0.12s;
|
||||
}
|
||||
.field:focus { border-color: var(--accent); }
|
||||
.field::placeholder { color: var(--muted); }
|
||||
.btn-primary {
|
||||
width: 100%; padding: 11px;
|
||||
background: var(--accent); border: none; border-radius: var(--r);
|
||||
color: #fff; font-family: inherit; font-size: 12px;
|
||||
font-weight: 500; letter-spacing: 0.07em;
|
||||
cursor: pointer; transition: opacity 0.12s, transform 0.08s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-primary:active { transform: scale(0.98); }
|
||||
.btn-ghost {
|
||||
display: block; width: 100%; text-align: center;
|
||||
padding: 9px; background: none; border: none;
|
||||
color: var(--muted); font-family: inherit; font-size: 11px;
|
||||
cursor: pointer; transition: color 0.12s;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text-2); }
|
||||
.auth-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: var(--bg);
|
||||
animation: rise 0.28s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.auth-card {
|
||||
width: 360px;
|
||||
padding: 52px 44px;
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
.auth-brand {
|
||||
font-family: "Cormorant Garamond", Georgia, serif;
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.22em;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-tagline {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.15em;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
.err-banner {
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 18px;
|
||||
background: rgba(184, 48, 48, 0.1);
|
||||
border: 1px solid rgba(184, 48, 48, 0.28);
|
||||
border-radius: var(--r);
|
||||
color: #d98080;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.field-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.field:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.field::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 11px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--r);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.07em;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
transform 0.08s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.btn-primary:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.btn-ghost {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 9px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,379 +1,631 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { User, Room, Message, ContextMenuItem } from '$lib/types';
|
||||
import { full, sid, fmt } from '$lib/helpers';
|
||||
import { tick } from "svelte";
|
||||
import type { User, Room, Message, ContextMenuItem } from "$lib/types";
|
||||
import { full, sid, fmt } from "$lib/helpers";
|
||||
|
||||
interface Props {
|
||||
activeRoom: Room | null;
|
||||
messages: Message[];
|
||||
user: User | null;
|
||||
err: string;
|
||||
hasOlderMessages: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
fMsg: string;
|
||||
replyTo: Message | null;
|
||||
onLoadOlderMessages: () => void;
|
||||
onSendMessage: () => void;
|
||||
onDeleteMessage: (msgId: string) => void;
|
||||
onEditMessage: (msgId: string, body: string) => void;
|
||||
onToggleReaction: (msgId: string, emoji: string) => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
}
|
||||
interface Props {
|
||||
activeRoom: Room | null;
|
||||
messages: Message[];
|
||||
user: User | null;
|
||||
err: string;
|
||||
hasOlderMessages: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
fMsg: string;
|
||||
replyTo: Message | null;
|
||||
onLoadOlderMessages: () => void;
|
||||
onSendMessage: () => void;
|
||||
onDeleteMessage: (msgId: string) => void;
|
||||
onEditMessage: (msgId: string, body: string) => void;
|
||||
onToggleReaction: (msgId: string, emoji: string) => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
activeRoom,
|
||||
messages,
|
||||
user,
|
||||
err,
|
||||
hasOlderMessages,
|
||||
isLoadingOlder,
|
||||
fMsg = $bindable(),
|
||||
replyTo = $bindable(),
|
||||
onLoadOlderMessages,
|
||||
onSendMessage,
|
||||
onDeleteMessage,
|
||||
onEditMessage,
|
||||
onToggleReaction,
|
||||
onShowMenu,
|
||||
}: Props = $props();
|
||||
let {
|
||||
activeRoom,
|
||||
messages,
|
||||
user,
|
||||
err,
|
||||
hasOlderMessages,
|
||||
isLoadingOlder,
|
||||
fMsg = $bindable(),
|
||||
replyTo = $bindable(),
|
||||
onLoadOlderMessages,
|
||||
onSendMessage,
|
||||
onDeleteMessage,
|
||||
onEditMessage,
|
||||
onToggleReaction,
|
||||
onShowMenu,
|
||||
}: Props = $props();
|
||||
|
||||
let msgEl: HTMLElement;
|
||||
let inputEl: HTMLTextAreaElement;
|
||||
let editingId = $state<string | null>(null);
|
||||
let editBody = $state('');
|
||||
let msgEl: HTMLElement;
|
||||
let inputEl: HTMLTextAreaElement;
|
||||
let editingId = $state<string | null>(null);
|
||||
let editBody = $state("");
|
||||
|
||||
function scrollBottom() {
|
||||
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
||||
}
|
||||
function scrollBottom() {
|
||||
tick().then(() => {
|
||||
if (msgEl) msgEl.scrollTop = msgEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
if (!inputEl) return;
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
||||
}
|
||||
function autoResize() {
|
||||
if (!inputEl) return;
|
||||
inputEl.style.height = "auto";
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px";
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); }
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function roomLabel(room: Room | null): string {
|
||||
if (!room) return 'select a room';
|
||||
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
||||
return room.name ?? 'untitled';
|
||||
}
|
||||
function roomLabel(room: Room | null): string {
|
||||
if (!room) return "select a room";
|
||||
if (room.kind === "direct")
|
||||
return room.other_user?.username ?? room.name ?? "direct message";
|
||||
return room.name ?? "untitled";
|
||||
}
|
||||
|
||||
function isGrouped(i: number): boolean {
|
||||
if (i === 0) return false;
|
||||
if (messages[i].deleted || messages[i - 1].deleted) return false;
|
||||
return full(messages[i].author) === full(messages[i - 1].author);
|
||||
}
|
||||
function isGrouped(i: number): boolean {
|
||||
if (i === 0) return false;
|
||||
if (messages[i].deleted || messages[i - 1].deleted) return false;
|
||||
return full(messages[i].author) === full(messages[i - 1].author);
|
||||
}
|
||||
|
||||
function beginEdit(msg: Message) {
|
||||
editingId = full(msg.id);
|
||||
editBody = msg.body;
|
||||
}
|
||||
function beginEdit(msg: Message) {
|
||||
editingId = full(msg.id);
|
||||
editBody = msg.body;
|
||||
}
|
||||
|
||||
function submitEdit(msg: Message) {
|
||||
if (!editBody.trim()) return;
|
||||
onEditMessage(full(msg.id), editBody.trim());
|
||||
editingId = null;
|
||||
editBody = '';
|
||||
}
|
||||
function submitEdit(msg: Message) {
|
||||
if (!editBody.trim()) return;
|
||||
onEditMessage(full(msg.id), editBody.trim());
|
||||
editingId = null;
|
||||
editBody = "";
|
||||
}
|
||||
|
||||
function quickReact(msg: Message) {
|
||||
onToggleReaction(full(msg.id), '+1');
|
||||
}
|
||||
function quickReact(msg: Message) {
|
||||
onToggleReaction(full(msg.id), "+1");
|
||||
}
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
messages.length; // track length
|
||||
scrollBottom();
|
||||
});
|
||||
// Scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
messages.length; // track length
|
||||
scrollBottom();
|
||||
});
|
||||
|
||||
// Reset textarea height after message is cleared
|
||||
$effect(() => {
|
||||
if (fMsg === '') autoResize();
|
||||
});
|
||||
// Reset textarea height after message is cleared
|
||||
$effect(() => {
|
||||
if (fMsg === "") autoResize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
<!-- Channel header -->
|
||||
<header class="channel-header">
|
||||
<span class="ch-hash">{activeRoom?.kind === "direct" ? "@" : "#"}</span>
|
||||
<span class="ch-name">{roomLabel(activeRoom)}</span>
|
||||
{#if err}<span class="header-err">{err}</span>{/if}
|
||||
</header>
|
||||
|
||||
<!-- Channel header -->
|
||||
<header class="channel-header">
|
||||
<span class="ch-hash">{activeRoom?.kind === 'direct' ? '@' : '#'}</span>
|
||||
<span class="ch-name">{roomLabel(activeRoom)}</span>
|
||||
{#if err}<span class="header-err">{err}</span>{/if}
|
||||
</header>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="messages" bind:this={msgEl}>
|
||||
{#if !activeRoom}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>select a room to start chatting</p>
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>no messages yet — say hello</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasOlderMessages}
|
||||
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
|
||||
{isLoadingOlder ? 'loading...' : 'load older messages'}
|
||||
</button>
|
||||
{/if}
|
||||
{#each messages as msg, i (full(msg.id))}
|
||||
<div
|
||||
class="msg"
|
||||
class:grouped={isGrouped(i)}
|
||||
role="listitem"
|
||||
oncontextmenu={(e) => {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
|
||||
{ label: 'Reply', action: () => replyTo = msg },
|
||||
{ label: 'React +1', action: () => onToggleReaction(full(msg.id), '+1') },
|
||||
];
|
||||
if (user && full(msg.author) === full(user.id) && !msg.deleted) {
|
||||
items.push({ label: 'Edit message', action: () => beginEdit(msg) });
|
||||
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
|
||||
}
|
||||
onShowMenu(e, items);
|
||||
}}
|
||||
>
|
||||
{#if !isGrouped(i)}
|
||||
<div class="msg-header">
|
||||
<span
|
||||
class="msg-author"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
|
||||
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
|
||||
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
|
||||
]); }}
|
||||
>{msg.author_username ?? sid(msg.author)}</span>
|
||||
<span class="msg-time">{fmt(msg.created)}</span>
|
||||
{#if msg.updated}<span class="msg-time">edited</span>{/if}
|
||||
<!-- Message list -->
|
||||
<div class="messages" bind:this={msgEl}>
|
||||
{#if !activeRoom}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>select a room to start chatting</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.reply_to}
|
||||
<div class="reply-chip">replying to {sid(msg.reply_to)}</div>
|
||||
{/if}
|
||||
{#if !msg.deleted}
|
||||
<div class="msg-actions" aria-label="message actions">
|
||||
<button title="Reply" onclick={() => replyTo = msg}>reply</button>
|
||||
<button title="React" onclick={() => quickReact(msg)}>+1</button>
|
||||
{#if user && full(msg.author) === full(user.id)}
|
||||
<button title="Edit" onclick={() => beginEdit(msg)}>edit</button>
|
||||
{/if}
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>no messages yet — say hello</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.deleted}
|
||||
<p class="msg-body deleted">message deleted</p>
|
||||
{:else if editingId === full(msg.id)}
|
||||
<div class="edit-row">
|
||||
<textarea class="edit-input" bind:value={editBody} rows="2"></textarea>
|
||||
<button class="mini-btn" onclick={() => submitEdit(msg)}>save</button>
|
||||
<button class="mini-btn ghost" onclick={() => editingId = null}>cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="msg-body">{msg.body}</p>
|
||||
{/if}
|
||||
{#if msg.reactions?.length}
|
||||
<div class="reactions">
|
||||
{#each msg.reactions as reaction}
|
||||
{:else}
|
||||
{#if hasOlderMessages}
|
||||
<button
|
||||
class="reaction"
|
||||
class:mine={reaction.reacted_by_me}
|
||||
onclick={() => onToggleReaction(full(msg.id), reaction.emoji)}
|
||||
class="load-older"
|
||||
onclick={onLoadOlderMessages}
|
||||
disabled={isLoadingOlder}
|
||||
>
|
||||
{reaction.emoji} {reaction.count}
|
||||
{isLoadingOlder ? "loading..." : "load older messages"}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input bar -->
|
||||
{#if replyTo}
|
||||
<div class="reply-bar">
|
||||
<span>replying to {replyTo.author_username ?? sid(replyTo.author)}</span>
|
||||
<button class="mini-btn ghost" onclick={() => replyTo = null}>cancel</button>
|
||||
{/if}
|
||||
{#each messages as msg, i (full(msg.id))}
|
||||
<div
|
||||
class="msg"
|
||||
class:grouped={isGrouped(i)}
|
||||
role="listitem"
|
||||
oncontextmenu={(e) => {
|
||||
const items: ContextMenuItem[] = [
|
||||
{
|
||||
label: "Copy message",
|
||||
action: () =>
|
||||
navigator.clipboard.writeText(msg.body),
|
||||
},
|
||||
{ label: "Reply", action: () => (replyTo = msg) },
|
||||
{
|
||||
label: "React +1",
|
||||
action: () =>
|
||||
onToggleReaction(full(msg.id), "+1"),
|
||||
},
|
||||
];
|
||||
if (
|
||||
user &&
|
||||
full(msg.author) === full(user.id) &&
|
||||
!msg.deleted
|
||||
) {
|
||||
items.push({
|
||||
label: "Edit message",
|
||||
action: () => beginEdit(msg),
|
||||
});
|
||||
items.push({
|
||||
label: "Delete message",
|
||||
action: () => onDeleteMessage(full(msg.id)),
|
||||
});
|
||||
}
|
||||
onShowMenu(e, items);
|
||||
}}
|
||||
>
|
||||
{#if !isGrouped(i)}
|
||||
<div class="msg-header">
|
||||
<span
|
||||
class="msg-author"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
oncontextmenu={(e) => {
|
||||
e.stopPropagation();
|
||||
onShowMenu(e, [
|
||||
{
|
||||
label: "Copy username",
|
||||
action: () =>
|
||||
navigator.clipboard.writeText(
|
||||
msg.author_username ??
|
||||
sid(msg.author),
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Copy user ID",
|
||||
action: () =>
|
||||
navigator.clipboard.writeText(
|
||||
sid(msg.author),
|
||||
),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>{msg.author_username ?? sid(msg.author)}</span
|
||||
>
|
||||
<span class="msg-time">{fmt(msg.created)}</span>
|
||||
{#if msg.updated}<span class="msg-time">edited</span
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.reply_to}
|
||||
<div class="reply-chip">
|
||||
{#if msg.replied_to_message}
|
||||
replying to {msg.replied_to_message.author_username ?? 'unknown'}: {msg.replied_to_message.body.length > 80 ? msg.replied_to_message.body.slice(0, 80) + '…' : msg.replied_to_message.body}
|
||||
{:else}
|
||||
replying to message
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if !msg.deleted}
|
||||
<div class="msg-actions" aria-label="message actions">
|
||||
<button
|
||||
title="Reply"
|
||||
onclick={() => (replyTo = msg)}>reply</button
|
||||
>
|
||||
<button
|
||||
title="React"
|
||||
onclick={() => quickReact(msg)}>+1</button
|
||||
>
|
||||
{#if user && full(msg.author) === full(user.id)}
|
||||
<button
|
||||
title="Edit"
|
||||
onclick={() => beginEdit(msg)}>edit</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.deleted}
|
||||
<p class="msg-body deleted">message deleted</p>
|
||||
{:else if editingId === full(msg.id)}
|
||||
<div class="edit-row">
|
||||
<textarea
|
||||
class="edit-input"
|
||||
bind:value={editBody}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button
|
||||
class="mini-btn"
|
||||
onclick={() => submitEdit(msg)}>save</button
|
||||
>
|
||||
<button
|
||||
class="mini-btn ghost"
|
||||
onclick={() => (editingId = null)}
|
||||
>cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="msg-body">{msg.body}</p>
|
||||
{/if}
|
||||
{#if msg.reactions?.length}
|
||||
<div class="reactions">
|
||||
{#each msg.reactions as reaction}
|
||||
<button
|
||||
class="reaction"
|
||||
class:mine={reaction.reacted_by_me}
|
||||
onclick={() =>
|
||||
onToggleReaction(
|
||||
full(msg.id),
|
||||
reaction.emoji,
|
||||
)}
|
||||
>
|
||||
{reaction.emoji}
|
||||
{reaction.count}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
class="msg-input"
|
||||
placeholder={activeRoom ? `message ${activeRoom.kind === 'direct' ? '@' : '#'}${roomLabel(activeRoom)}` : 'select a room first'}
|
||||
bind:value={fMsg}
|
||||
onkeydown={onKey}
|
||||
oninput={autoResize}
|
||||
disabled={!activeRoom}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button title="" class="send-btn" onclick={onSendMessage} disabled={!activeRoom || !fMsg.trim()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input bar -->
|
||||
{#if replyTo}
|
||||
<div class="reply-bar">
|
||||
<span
|
||||
>replying to {replyTo.author_username ??
|
||||
sid(replyTo.author)}</span
|
||||
>
|
||||
<button class="mini-btn ghost" onclick={() => (replyTo = null)}
|
||||
>cancel</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
class="msg-input"
|
||||
placeholder={activeRoom
|
||||
? `message ${activeRoom.kind === "direct" ? "@" : "#"}${roomLabel(activeRoom)}`
|
||||
: "select a room first"}
|
||||
bind:value={fMsg}
|
||||
onkeydown={onKey}
|
||||
oninput={autoResize}
|
||||
disabled={!activeRoom}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button
|
||||
title=""
|
||||
class="send-btn"
|
||||
onclick={onSendMessage}
|
||||
disabled={!activeRoom || !fMsg.trim()}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<line x1="22" y1="2" x2="11" y2="13" />
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden; background: var(--bg);
|
||||
}
|
||||
.channel-header {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 0 24px; height: 50px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ch-hash { font-size: 17px; color: var(--muted); }
|
||||
.ch-name { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.header-err {
|
||||
margin-left: auto; font-size: 10px; color: #d98080;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 280px;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.channel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 24px;
|
||||
height: 50px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ch-hash {
|
||||
font-size: 17px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ch-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
.header-err {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: #d98080;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 20px 24px 8px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.messages::-webkit-scrollbar { width: 4px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; }
|
||||
.messages::-webkit-scrollbar-track { background: transparent; }
|
||||
.load-older {
|
||||
align-self: center; margin-bottom: 12px; padding: 6px 10px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--r); color: var(--text-2);
|
||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.load-older:hover { border-color: var(--accent); color: var(--text); }
|
||||
.load-older:disabled { opacity: 0.5; cursor: wait; }
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.messages::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.messages::-webkit-scrollbar-thumb {
|
||||
background: var(--surface-2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.load-older {
|
||||
align-self: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load-older:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.load-older:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 12px; color: var(--muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 32px; opacity: 0.2;
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
}
|
||||
.empty-state p { font-size: 11px; letter-spacing: 0.07em; }
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 32px;
|
||||
opacity: 0.2;
|
||||
font-family: "Cormorant Garamond", Georgia, serif;
|
||||
}
|
||||
.empty-state p {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.07em;
|
||||
}
|
||||
|
||||
.msg { padding: 1px 0; }
|
||||
.msg:hover .msg-actions,
|
||||
.msg:focus-within .msg-actions { opacity: 1; pointer-events: auto; }
|
||||
.msg.grouped { padding-top: 1px; }
|
||||
.msg {
|
||||
padding: 1px 0;
|
||||
}
|
||||
.msg:hover .msg-actions,
|
||||
.msg:focus-within .msg-actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.msg.grouped {
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex; align-items: baseline; gap: 9px;
|
||||
margin-top: 16px; margin-bottom: 3px;
|
||||
}
|
||||
.msg-author { font-size: 12px; font-weight: 500; color: var(--accent); }
|
||||
.msg-time { font-size: 9.5px; color: var(--muted); }
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.msg-author {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
.msg-time {
|
||||
font-size: 9.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.msg-body {
|
||||
color: var(--text); font-size: 13px;
|
||||
line-height: 1.6; white-space: pre-wrap; word-break: break-word;
|
||||
animation: msgIn 0.14s ease;
|
||||
}
|
||||
.msg.grouped .msg-body { color: var(--text-2); }
|
||||
.msg-body.deleted { color: var(--muted); font-style: italic; }
|
||||
.msg-actions {
|
||||
float: right; display: flex; gap: 4px; margin-left: 8px;
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.1s;
|
||||
}
|
||||
.msg-actions button {
|
||||
padding: 2px 5px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--muted); font-family: inherit; font-size: 9.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.reply-chip {
|
||||
display: inline-flex; margin: 2px 0 3px; padding: 3px 6px;
|
||||
border-left: 2px solid var(--accent); background: var(--surface);
|
||||
color: var(--muted); font-size: 10px;
|
||||
}
|
||||
.edit-row {
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1; resize: vertical; min-height: 44px; max-height: 120px;
|
||||
padding: 7px 9px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 12px;
|
||||
}
|
||||
.mini-btn {
|
||||
padding: 6px 8px; background: var(--accent); border: none;
|
||||
border-radius: var(--r); color: #fff; font-family: inherit;
|
||||
font-size: 10px; cursor: pointer;
|
||||
}
|
||||
.mini-btn.ghost {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.reactions {
|
||||
display: flex; gap: 5px; margin-top: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.reaction {
|
||||
padding: 2px 6px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text-2); font-family: inherit; font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reaction.mine { border-color: var(--accent); color: var(--accent); }
|
||||
@keyframes msgIn {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.msg-body {
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
animation: msgIn 0.14s ease;
|
||||
}
|
||||
.msg.grouped .msg-body {
|
||||
color: var(--text-2);
|
||||
}
|
||||
.msg-body.deleted {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.msg-actions {
|
||||
float: right;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.msg-actions button {
|
||||
padding: 2px 5px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: 9.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-actions button:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.reply-chip {
|
||||
display: inline-flex;
|
||||
margin: 2px 0 3px;
|
||||
padding: 3px 6px;
|
||||
border-left: 2px solid var(--accent);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
.edit-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1;
|
||||
resize: vertical;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
padding: 7px 9px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mini-btn {
|
||||
padding: 6px 8px;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--r);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mini-btn.ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.reactions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.reaction {
|
||||
padding: 2px 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reaction.mine {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
@keyframes msgIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex; align-items: flex-end; gap: 8px;
|
||||
padding: 12px 24px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reply-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 24px; border-top: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 11px; background: var(--surface);
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1; resize: none;
|
||||
padding: 9px 13px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 13px;
|
||||
line-height: 1.55; outline: none;
|
||||
transition: border-color 0.12s;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.msg-input:focus { border-color: var(--accent); }
|
||||
.msg-input:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.msg-input::placeholder { color: var(--muted); }
|
||||
.msg-input::-webkit-scrollbar { width: 0; }
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 12px 24px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reply-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-2);
|
||||
font-size: 11px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
padding: 9px 13px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
transition: border-color 0.12s;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.msg-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.msg-input:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.msg-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.msg-input::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 34px; height: 34px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); border: none; border-radius: var(--r);
|
||||
color: #fff; cursor: pointer;
|
||||
transition: opacity 0.12s, transform 0.08s;
|
||||
}
|
||||
.send-btn:hover { opacity: 0.82; }
|
||||
.send-btn:active { transform: scale(0.93); }
|
||||
.send-btn:disabled { opacity: 0.25; cursor: not-allowed; transform: none; }
|
||||
.send-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
border-radius: var(--r);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
transform 0.08s;
|
||||
}
|
||||
.send-btn:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
.send-btn:active {
|
||||
transform: scale(0.93);
|
||||
}
|
||||
.send-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,112 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { ContextMenuItem } from '$lib/types';
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import type { ContextMenuItem } from "$lib/types";
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onclose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { x, y, items, onclose }: Props = $props();
|
||||
let { x, y, items, onclose }: Props = $props();
|
||||
|
||||
let menuEl: HTMLElement;
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let menuEl: HTMLElement;
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Flip position if menu would overflow viewport
|
||||
onMount(() => {
|
||||
if (!menuEl) return;
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) menuEl.style.left = (x - rect.width) + 'px';
|
||||
if (rect.bottom > window.innerHeight) menuEl.style.top = (y - rect.height) + 'px';
|
||||
});
|
||||
// Flip position if menu would overflow viewport
|
||||
onMount(() => {
|
||||
if (!menuEl) return;
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth)
|
||||
menuEl.style.left = x - rect.width + "px";
|
||||
if (rect.bottom > window.innerHeight)
|
||||
menuEl.style.top = y - rect.height + "px";
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (closeTimer !== null) clearTimeout(closeTimer);
|
||||
});
|
||||
onDestroy(() => {
|
||||
if (closeTimer !== null) clearTimeout(closeTimer);
|
||||
});
|
||||
|
||||
function handleItem(item: ContextMenuItem, index: number) {
|
||||
item.action();
|
||||
copiedIndex = index;
|
||||
closeTimer = setTimeout(onclose, 1200);
|
||||
}
|
||||
function handleItem(item: ContextMenuItem, index: number) {
|
||||
item.action();
|
||||
copiedIndex = index;
|
||||
closeTimer = setTimeout(onclose, 1200);
|
||||
}
|
||||
|
||||
function onWindowClick() { onclose(); }
|
||||
function onWindowKey(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); }
|
||||
function onWindowContext(e: MouseEvent) { e.preventDefault(); onclose(); }
|
||||
function onWindowClick() {
|
||||
onclose();
|
||||
}
|
||||
function onWindowKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onclose();
|
||||
}
|
||||
function onWindowContext(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
onclose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onclick={onWindowClick}
|
||||
onkeydown={onWindowKey}
|
||||
oncontextmenu={onWindowContext}
|
||||
onclick={onWindowClick}
|
||||
onkeydown={onWindowKey}
|
||||
oncontextmenu={onWindowContext}
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="ctx-menu"
|
||||
bind:this={menuEl}
|
||||
style="left:{x}px; top:{y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
role="menu"
|
||||
class="ctx-menu"
|
||||
bind:this={menuEl}
|
||||
style="left:{x}px; top:{y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
role="menu"
|
||||
>
|
||||
{#each items as item, i}
|
||||
<li role="menuitem">
|
||||
<button
|
||||
class="ctx-item"
|
||||
class:copied={copiedIndex === i}
|
||||
onclick={() => handleItem(item, i)}
|
||||
>
|
||||
{copiedIndex === i ? 'Copied!' : item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#each items as item, i}
|
||||
<li role="menuitem">
|
||||
<button
|
||||
class="ctx-item"
|
||||
class:copied={copiedIndex === i}
|
||||
onclick={() => handleItem(item, i)}
|
||||
>
|
||||
{copiedIndex === i ? "Copied!" : item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.ctx-menu {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
list-style: none;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
z-index: 9999;
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.ctx-menu {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
list-style: none;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
z-index: 9999;
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--r);
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.ctx-item.copied {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.ctx-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--r);
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.1s,
|
||||
color 0.1s,
|
||||
border-color 0.1s;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.ctx-item.copied {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,46 @@
|
||||
<div class="loading">
|
||||
<span class="brand-mark">OXYDE</span>
|
||||
<div class="spinner"></div>
|
||||
<span class="brand-mark">OXYDE</span>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
height: 100vh; gap: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.brand-mark {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 32px; font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.22em;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.brand-mark {
|
||||
font-family: "Cormorant Garamond", Georgia, serif;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.22em;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,33 +2,39 @@
|
||||
// 3.x format: { table: "user", key: { String: "abc" } }
|
||||
// 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat)
|
||||
export function sid(thing: any): string {
|
||||
if (!thing) return '';
|
||||
if (typeof thing === 'string') {
|
||||
const i = thing.indexOf(':');
|
||||
if (!thing) return "";
|
||||
if (typeof thing === "string") {
|
||||
const i = thing.indexOf(":");
|
||||
const id = i >= 0 ? thing.slice(i + 1) : thing;
|
||||
return id.replace(/[⟨⟩]/g, '');
|
||||
return id.replace(/[⟨⟩]/g, "");
|
||||
}
|
||||
// 3.x: key field (may be nested variant or plain string)
|
||||
const key = thing?.key ?? thing?.id;
|
||||
if (typeof key === 'string') return key.replace(/[⟨⟩]/g, '');
|
||||
if (key?.String) return key.String;
|
||||
if (key?.Uuid) return key.Uuid;
|
||||
if (key?.Number !== undefined) return String(key.Number);
|
||||
if (typeof key === "string") return key.replace(/[⟨⟩]/g, "");
|
||||
if (key?.String) return key.String;
|
||||
if (key?.Uuid) return key.Uuid;
|
||||
if (key?.Number !== undefined) return String(key.Number);
|
||||
return JSON.stringify(thing);
|
||||
}
|
||||
|
||||
// Return canonical "table:id" string for equality checks
|
||||
export function full(thing: any): string {
|
||||
if (typeof thing === 'string') return thing;
|
||||
const table = thing?.table ?? thing?.tb ?? '';
|
||||
if (typeof thing === "string") return thing;
|
||||
const table = thing?.table ?? thing?.tb ?? "";
|
||||
return `${table}:${sid(thing)}`;
|
||||
}
|
||||
|
||||
export function fmt(ts: string): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export async function cmd<T>(name: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
export async function cmd<T>(
|
||||
name: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
return invoke<T>(name, args);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,65 @@
|
||||
export interface User { id: any; username: string; email?: string; avatar?: string; created?: string; }
|
||||
export interface Room { id: any; name?: string; kind?: 'public' | 'private' | 'direct'; direct_key?: string; created: string; updated?: string; created_by?: any; last_message?: Message; unread_count?: number; other_user?: User; }
|
||||
export interface RoomMember { id: any; room: any; user: any; role: 'owner' | 'member'; joined: string; last_read_at?: string; muted?: boolean; }
|
||||
export interface MessageReactionSummary { emoji: string; count: number; reacted_by_me: boolean; }
|
||||
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; updated?: string; deleted?: boolean; reply_to?: any; reactions?: MessageReactionSummary[]; }
|
||||
export interface UserSearchResult { id: any; username: string; avatar?: string; }
|
||||
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
||||
export interface ContextMenuItem { label: string; action: () => void; }
|
||||
export interface User {
|
||||
id: any;
|
||||
username: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
created?: string;
|
||||
}
|
||||
export interface Room {
|
||||
id: any;
|
||||
name?: string;
|
||||
kind?: "public" | "private" | "direct";
|
||||
direct_key?: string;
|
||||
created: string;
|
||||
updated?: string;
|
||||
created_by?: any;
|
||||
last_message?: Message;
|
||||
unread_count?: number;
|
||||
other_user?: User;
|
||||
}
|
||||
export interface RoomMember {
|
||||
id: any;
|
||||
room: any;
|
||||
user: any;
|
||||
role: "owner" | "member";
|
||||
joined: string;
|
||||
last_read_at?: string;
|
||||
muted?: boolean;
|
||||
}
|
||||
export interface MessageReactionSummary {
|
||||
emoji: string;
|
||||
count: number;
|
||||
reacted_by_me: boolean;
|
||||
}
|
||||
export interface Message {
|
||||
id: any;
|
||||
room: any;
|
||||
author: any;
|
||||
author_username?: string;
|
||||
body: string;
|
||||
created: string;
|
||||
updated?: string;
|
||||
deleted?: boolean;
|
||||
reply_to?: any;
|
||||
replied_to_message?: MessageSnippet;
|
||||
reactions?: MessageReactionSummary[];
|
||||
}
|
||||
|
||||
export interface MessageSnippet {
|
||||
id: any;
|
||||
author_username?: string;
|
||||
body: string;
|
||||
}
|
||||
export interface UserSearchResult {
|
||||
id: any;
|
||||
username: string;
|
||||
avatar?: string;
|
||||
}
|
||||
export interface LiveEvent {
|
||||
action: "Create" | "Update" | "Delete";
|
||||
data: Message;
|
||||
}
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
let hasOlderMessages = $state(false);
|
||||
let isLoadingOlder = $state(false);
|
||||
let unreadCounts = $state<Record<string, number>>({});
|
||||
let roomSelectionToken = 0;
|
||||
|
||||
let view = $state<"loading" | "auth" | "app">("loading");
|
||||
let authMode = $state<"signin" | "signup">("signin");
|
||||
@@ -101,6 +102,7 @@
|
||||
}
|
||||
|
||||
async function signout() {
|
||||
roomSelectionToken += 1;
|
||||
await cmd("signout").catch(() => {});
|
||||
if (subId) {
|
||||
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||
@@ -119,64 +121,125 @@
|
||||
}
|
||||
|
||||
// ─── Rooms ────────────────────────────────────────────────────────────────
|
||||
function isCurrentRoomSelection(token: number, roomId: string) {
|
||||
return (
|
||||
token === roomSelectionToken &&
|
||||
activeRoom !== null &&
|
||||
sid(activeRoom.id) === roomId
|
||||
);
|
||||
}
|
||||
|
||||
function onlyRoomMessages(roomId: string, source: Message[]) {
|
||||
return source.filter((message) => sid(message.room) === roomId);
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
rooms = await cmd<Room[]>("get_rooms");
|
||||
if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
|
||||
}
|
||||
|
||||
async function selectRoom(room: Room) {
|
||||
if (subId) {
|
||||
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||
subId = null;
|
||||
}
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
const token = ++roomSelectionToken;
|
||||
const roomId = sid(room.id);
|
||||
const previousSubId = subId;
|
||||
const previousUnlisten = unlisten;
|
||||
|
||||
subId = null;
|
||||
unlisten = null;
|
||||
|
||||
activeRoom = room;
|
||||
messages = [];
|
||||
hasOlderMessages = false;
|
||||
isLoadingOlder = false;
|
||||
replyTo = null;
|
||||
messages = await cmd<Message[]>("get_messages", {
|
||||
roomId: sid(room.id),
|
||||
|
||||
if (previousSubId) {
|
||||
await cmd("unsubscribe_room", { subId: previousSubId }).catch(
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
if (previousUnlisten) {
|
||||
previousUnlisten();
|
||||
}
|
||||
if (token !== roomSelectionToken) return;
|
||||
|
||||
const cached = await cmd<Message[]>("get_cached_messages", { roomId });
|
||||
if (!isCurrentRoomSelection(token, roomId)) return;
|
||||
if (cached.length > 0) {
|
||||
messages = onlyRoomMessages(roomId, cached);
|
||||
hasOlderMessages = false;
|
||||
}
|
||||
|
||||
const fresh = await cmd<Message[]>("get_messages", {
|
||||
roomId,
|
||||
limit: 50,
|
||||
});
|
||||
hasOlderMessages = messages.length === 50;
|
||||
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
|
||||
await cmd("mark_room_read", { roomId: sid(room.id) }).catch(() => {});
|
||||
if (!isCurrentRoomSelection(token, roomId)) return;
|
||||
messages = onlyRoomMessages(roomId, fresh);
|
||||
hasOlderMessages = fresh.length === 50;
|
||||
unreadCounts = { ...unreadCounts, [roomId]: 0 };
|
||||
await cmd("mark_room_read", { roomId }).catch(() => {});
|
||||
if (!isCurrentRoomSelection(token, roomId)) return;
|
||||
|
||||
subId = await cmd<string>("subscribe_room", { roomId: sid(room.id) });
|
||||
const nextSubId = await cmd<string>("subscribe_room", { roomId });
|
||||
if (!isCurrentRoomSelection(token, roomId)) {
|
||||
await cmd("unsubscribe_room", { subId: nextSubId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
subId = nextSubId;
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
unlisten = await listen<LiveEvent>("chat:message", ({ payload }) => {
|
||||
const { action, data } = payload;
|
||||
const eventRoomId = sid(data.room);
|
||||
const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
|
||||
if (eventRoomId !== currentRoomId) {
|
||||
unreadCounts = {
|
||||
...unreadCounts,
|
||||
[eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1,
|
||||
};
|
||||
if (
|
||||
"Notification" in window &&
|
||||
Notification.permission === "granted" &&
|
||||
document.hidden
|
||||
) {
|
||||
new Notification(data.author_username ?? "New message", {
|
||||
body: data.body || "New message",
|
||||
});
|
||||
const nextUnlisten = await listen<LiveEvent>(
|
||||
"chat:message",
|
||||
({ payload }) => {
|
||||
const { action, data } = payload;
|
||||
const eventRoomId = sid(data.room);
|
||||
const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
|
||||
if (eventRoomId !== currentRoomId) {
|
||||
unreadCounts = {
|
||||
...unreadCounts,
|
||||
[eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1,
|
||||
};
|
||||
if (
|
||||
"Notification" in window &&
|
||||
Notification.permission === "granted" &&
|
||||
document.hidden
|
||||
) {
|
||||
new Notification(
|
||||
data.author_username ?? "New message",
|
||||
{
|
||||
body: data.body || "New message",
|
||||
},
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "Create") {
|
||||
messages = [...messages, data];
|
||||
} else if (action === "Delete") {
|
||||
messages = messages.filter((m) => full(m.id) !== full(data.id));
|
||||
} else if (action === "Update") {
|
||||
messages = messages.map((m) =>
|
||||
full(m.id) === full(data.id) ? data : m,
|
||||
if (action === "Create") {
|
||||
messages = [...messages, data];
|
||||
} else if (action === "Delete") {
|
||||
messages = messages.filter(
|
||||
(m) => full(m.id) !== full(data.id),
|
||||
);
|
||||
} else if (action === "Update") {
|
||||
messages = messages.map((m) =>
|
||||
full(m.id) === full(data.id) ? data : m,
|
||||
);
|
||||
}
|
||||
cmd("mark_room_read", { roomId: currentRoomId }).catch(
|
||||
() => {},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!isCurrentRoomSelection(token, roomId)) {
|
||||
nextUnlisten();
|
||||
if (subId === nextSubId) {
|
||||
await cmd("unsubscribe_room", { subId: nextSubId }).catch(
|
||||
() => {},
|
||||
);
|
||||
subId = null;
|
||||
}
|
||||
cmd("mark_room_read", { roomId: currentRoomId }).catch(() => {});
|
||||
});
|
||||
return;
|
||||
}
|
||||
unlisten = nextUnlisten;
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
@@ -187,19 +250,24 @@
|
||||
messages.length === 0
|
||||
)
|
||||
return;
|
||||
const roomId = sid(activeRoom.id);
|
||||
const token = roomSelectionToken;
|
||||
isLoadingOlder = true;
|
||||
try {
|
||||
const older = await cmd<Message[]>("get_messages", {
|
||||
roomId: sid(activeRoom.id),
|
||||
roomId,
|
||||
before: messages[0].created,
|
||||
limit: 50,
|
||||
});
|
||||
messages = [...older, ...messages];
|
||||
if (!isCurrentRoomSelection(token, roomId)) return;
|
||||
messages = [...onlyRoomMessages(roomId, older), ...messages];
|
||||
hasOlderMessages = older.length === 50;
|
||||
} catch (e) {
|
||||
err = String(e);
|
||||
} finally {
|
||||
isLoadingOlder = false;
|
||||
if (isCurrentRoomSelection(token, roomId)) {
|
||||
isLoadingOlder = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,10 +335,15 @@
|
||||
try {
|
||||
await cmd("toggle_reaction", { messageId: msgId, emoji });
|
||||
if (activeRoom) {
|
||||
messages = await cmd<Message[]>("get_messages", {
|
||||
roomId: sid(activeRoom.id),
|
||||
const roomId = sid(activeRoom.id);
|
||||
const token = roomSelectionToken;
|
||||
const refreshed = await cmd<Message[]>("get_messages", {
|
||||
roomId,
|
||||
limit: Math.max(50, Math.min(messages.length, 100)),
|
||||
});
|
||||
if (isCurrentRoomSelection(token, roomId)) {
|
||||
messages = onlyRoomMessages(roomId, refreshed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
err = String(e);
|
||||
|
||||
Reference in New Issue
Block a user