6 Commits

Author SHA1 Message Date
9337ea01f2 chore: updated to version 0.1.4
Some checks failed
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled
2026-04-19 21:23:42 -04:00
555e79b390 fixed user replies showing the correct message and not the message id 2026-04-19 21:20:25 -04:00
0ccb77be40 small adjustments made by zed editor formatting 2026-04-19 20:49:02 -04:00
c7cb73b360 fixed up the frontend display for the local messages
Some checks failed
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled
2026-04-19 02:04:54 -04:00
3c3118c74d basic local message caching 2026-04-19 01:34:59 -04:00
ba6e158ee2 moved the AppState to lib.rs and refactored accordingly 2026-04-19 01:06:40 -04:00
18 changed files with 2167 additions and 1210 deletions

View File

@@ -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
View File

@@ -3824,7 +3824,7 @@ dependencies = [
[[package]]
name = "oxyde"
version = "0.1.1"
version = "0.1.4"
dependencies = [
"futures-util",
"serde",

View File

@@ -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"

View File

@@ -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: &notification.data,
action,
data: &data,
},
);
}

View File

@@ -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())
}
}

View File

@@ -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.

View File

@@ -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,

View File

@@ -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>>,
}

View File

@@ -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",

View File

@@ -6,7 +6,11 @@
<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
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"

View File

@@ -1,6 +1,6 @@
<script lang="ts">
interface Props {
authMode: 'signin' | 'signup';
authMode: "signin" | "signup";
err: string;
fEmail: string;
fPass: string;
@@ -31,11 +31,23 @@
<div class="err-banner">{err}</div>
{/if}
{#if authMode === 'signin'}
{#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" />
<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}>
@@ -43,11 +55,31 @@
</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>
<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
@@ -58,62 +90,116 @@
<style>
.auth-wrap {
display: flex; align-items: center; justify-content: center;
height: 100vh; background: var(--bg);
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); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.auth-card {
width: 360px; padding: 52px 44px;
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;
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;
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);
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;
color: #d98080;
font-size: 11px;
line-height: 1.5;
}
.field-stack {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.field-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
.field {
width: 100%; padding: 10px 14px;
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;
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);
}
.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;
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-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;
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);
}
.btn-ghost:hover { color: var(--text-2); }
</style>

View File

@@ -1,7 +1,7 @@
<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;
@@ -40,26 +40,32 @@
let msgEl: HTMLElement;
let inputEl: HTMLTextAreaElement;
let editingId = $state<string | null>(null);
let editBody = $state('');
let editBody = $state("");
function scrollBottom() {
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
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';
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(); }
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';
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 {
@@ -77,11 +83,11 @@
if (!editBody.trim()) return;
onEditMessage(full(msg.id), editBody.trim());
editingId = null;
editBody = '';
editBody = "";
}
function quickReact(msg: Message) {
onToggleReaction(full(msg.id), '+1');
onToggleReaction(full(msg.id), "+1");
}
// Scroll to bottom when messages change
@@ -92,15 +98,14 @@
// Reset textarea height after message is cleared
$effect(() => {
if (fMsg === '') autoResize();
if (fMsg === "") autoResize();
});
</script>
<main class="main">
<!-- Channel header -->
<header class="channel-header">
<span class="ch-hash">{activeRoom?.kind === 'direct' ? '@' : '#'}</span>
<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>
@@ -119,8 +124,12 @@
</div>
{:else}
{#if hasOlderMessages}
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
{isLoadingOlder ? 'loading...' : 'load older messages'}
<button
class="load-older"
onclick={onLoadOlderMessages}
disabled={isLoadingOlder}
>
{isLoadingOlder ? "loading..." : "load older messages"}
</button>
{/if}
{#each messages as msg, i (full(msg.id))}
@@ -130,13 +139,31 @@
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') },
{
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)) });
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);
}}
@@ -147,24 +174,57 @@
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>
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}
{#if msg.updated}<span class="msg-time">edited</span
>{/if}
</div>
{/if}
{#if msg.reply_to}
<div class="reply-chip">replying to {sid(msg.reply_to)}</div>
<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>
<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>
<button
title="Edit"
onclick={() => beginEdit(msg)}>edit</button
>
{/if}
</div>
{/if}
@@ -172,9 +232,20 @@
<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>
<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>
@@ -185,9 +256,14 @@
<button
class="reaction"
class:mine={reaction.reacted_by_me}
onclick={() => onToggleReaction(full(msg.id), reaction.emoji)}
onclick={() =>
onToggleReaction(
full(msg.id),
reaction.emoji,
)}
>
{reaction.emoji} {reaction.count}
{reaction.emoji}
{reaction.count}
</button>
{/each}
</div>
@@ -200,180 +276,356 @@
<!-- 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>
<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'}
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"/>
<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);
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;
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); }
.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;
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;
flex: 1;
overflow-y: auto;
padding: 20px 24px 8px;
display: flex; flex-direction: column;
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;
}
.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; }
.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-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;
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;
}
.msg-actions button:hover { border-color: var(--accent); color: var(--accent); }
.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;
}
.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-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;
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;
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;
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;
padding: 6px 8px;
background: var(--accent);
border: none;
border-radius: var(--r);
color: #fff;
font-family: inherit;
font-size: 10px;
cursor: pointer;
}
.reaction.mine { border-color: var(--accent); color: var(--accent); }
.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); }
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.input-bar {
display: flex; align-items: flex-end; gap: 8px;
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);
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;
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;
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;
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;
}
.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;
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:hover { opacity: 0.82; }
.send-btn:active { transform: scale(0.93); }
.send-btn:disabled { opacity: 0.25; cursor: not-allowed; transform: none; }
</style>

View File

@@ -1,6 +1,6 @@
<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;
@@ -19,8 +19,10 @@
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';
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(() => {
@@ -33,9 +35,16 @@
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
@@ -50,7 +59,10 @@
style="left:{x}px; top:{y}px"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
oncontextmenu={(e) => {
e.preventDefault();
e.stopPropagation();
}}
role="menu"
>
{#each items as item, i}
@@ -60,7 +72,7 @@
class:copied={copiedIndex === i}
onclick={() => handleItem(item, i)}
>
{copiedIndex === i ? 'Copied!' : item.label}
{copiedIndex === i ? "Copied!" : item.label}
</button>
</li>
{/each}
@@ -76,13 +88,19 @@
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
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); }
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ctx-item {
@@ -98,7 +116,10 @@
font-size: 11px;
text-align: left;
cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s;
transition:
background 0.1s,
color 0.1s,
border-color 0.1s;
}
.ctx-item:hover {
background: var(--surface-2);

View File

@@ -5,25 +5,42 @@
<style>
.loading {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
height: 100vh; gap: 20px;
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;
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; } }
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.spinner {
width: 20px; height: 20px;
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); } }
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,7 +1,12 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { User, Room, ContextMenuItem, UserSearchResult } from '$lib/types';
import { full, sid } from '$lib/helpers';
import { onDestroy, onMount } from "svelte";
import type {
User,
Room,
ContextMenuItem,
UserSearchResult,
} from "$lib/types";
import { full, sid } from "$lib/helpers";
interface Props {
user: User | null;
@@ -10,13 +15,16 @@
activeRoom: Room | null;
showNewRoom: boolean;
fRoom: string;
fRoomKind: 'public' | 'private';
fRoomKind: "public" | "private";
unreadCounts: Record<string, number>;
onSelectRoom: (room: Room) => void;
onCreateRoom: () => void;
onSignout: () => void;
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
onUpdateProfile: (fields: { username?: string; avatar?: string }) => Promise<void>;
onUpdateProfile: (fields: {
username?: string;
avatar?: string;
}) => Promise<void>;
onAddContact: (userId: string) => Promise<void>;
onSearchUsers: (query: string) => Promise<UserSearchResult[]>;
onStartDirectMessage: (userId: string) => Promise<void>;
@@ -44,8 +52,9 @@
}: Props = $props();
function roomLabel(room: Room): string {
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
return room.name ?? 'untitled';
if (room.kind === "direct")
return room.other_user?.username ?? room.name ?? "direct message";
return room.name ?? "untitled";
}
const minSidebarWidth = 228;
@@ -59,7 +68,7 @@
function setSidebarWidth(width: number) {
sidebarWidth = clampSidebarWidth(width);
localStorage.setItem('oxyde.sidebarWidth', String(sidebarWidth));
localStorage.setItem("oxyde.sidebarWidth", String(sidebarWidth));
}
function onResizeMove(e: PointerEvent) {
@@ -77,69 +86,74 @@
}
function onResizeKey(e: KeyboardEvent) {
if (e.key === 'ArrowLeft') {
if (e.key === "ArrowLeft") {
e.preventDefault();
setSidebarWidth(sidebarWidth - 16);
} else if (e.key === 'ArrowRight') {
} else if (e.key === "ArrowRight") {
e.preventDefault();
setSidebarWidth(sidebarWidth + 16);
}
}
onMount(() => {
const stored = Number(localStorage.getItem('oxyde.sidebarWidth'));
const stored = Number(localStorage.getItem("oxyde.sidebarWidth"));
if (Number.isFinite(stored)) sidebarWidth = clampSidebarWidth(stored);
window.addEventListener('pointermove', onResizeMove);
window.addEventListener('pointerup', stopResize);
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", stopResize);
});
onDestroy(() => {
window.removeEventListener('pointermove', onResizeMove);
window.removeEventListener('pointerup', stopResize);
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", stopResize);
});
// ── Profile edit ──────────────────────────────────────────────────────────
let showEditProfile = $state(false);
let fProfileUsername = $state('');
let fProfileAvatar = $state('');
let profileErr = $state('');
let fProfileUsername = $state("");
let fProfileAvatar = $state("");
let profileErr = $state("");
function openEditProfile() {
fProfileUsername = user?.username ?? '';
fProfileAvatar = user?.avatar ?? '';
profileErr = '';
fProfileUsername = user?.username ?? "";
fProfileAvatar = user?.avatar ?? "";
profileErr = "";
showEditProfile = true;
}
async function submitProfile() {
profileErr = '';
profileErr = "";
try {
await onUpdateProfile({
username: fProfileUsername.trim() || undefined,
avatar: fProfileAvatar.trim() || undefined,
});
showEditProfile = false;
} catch (e) { profileErr = String(e); }
} catch (e) {
profileErr = String(e);
}
}
// ── Add contact ───────────────────────────────────────────────────────────
let showAddContact = $state(false);
let fContactQuery = $state('');
let fContactQuery = $state("");
let searchResults = $state<UserSearchResult[]>([]);
let searchBusy = $state(false);
let contactErr = $state('');
let contactErr = $state("");
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function runUserSearch() {
const query = fContactQuery.trim();
searchResults = [];
if (query.length < 2) return;
contactErr = '';
contactErr = "";
searchBusy = true;
try {
searchResults = await onSearchUsers(query);
} catch (e) { contactErr = String(e); }
finally { searchBusy = false; }
} catch (e) {
contactErr = String(e);
} finally {
searchBusy = false;
}
}
function scheduleUserSearch() {
@@ -148,39 +162,53 @@
}
async function submitContact(userId: string) {
contactErr = '';
contactErr = "";
try {
await onAddContact(userId);
fContactQuery = '';
fContactQuery = "";
searchResults = [];
showAddContact = false;
} catch (e) { contactErr = String(e); }
} catch (e) {
contactErr = String(e);
}
}
async function startDm(userId: string) {
contactErr = '';
contactErr = "";
try {
await onStartDirectMessage(userId);
showAddContact = false;
} catch (e) { contactErr = String(e); }
} catch (e) {
contactErr = String(e);
}
}
async function invite(userId: string) {
contactErr = '';
contactErr = "";
try {
await onInviteToRoom(userId);
} catch (e) { contactErr = String(e); }
} catch (e) {
contactErr = String(e);
}
}
</script>
<aside class="sidebar" class:resizing style="width:{sidebarWidth}px; min-width:{sidebarWidth}px">
<aside
class="sidebar"
class:resizing
style="width:{sidebarWidth}px; min-width:{sidebarWidth}px"
>
<!-- Header -->
<div class="sidebar-head">
<span class="sidebar-brand">OXYDE</span>
<button class="icon-btn" title="New room"
onclick={() => { showNewRoom = !showNewRoom; }}>
{showNewRoom ? '×' : '+'}
<button
class="icon-btn"
title="New room"
onclick={() => {
showNewRoom = !showNewRoom;
}}
>
{showNewRoom ? "×" : "+"}
</button>
</div>
@@ -188,12 +216,22 @@
{#if showNewRoom}
<div class="panel-form">
<div class="panel-title">new room</div>
<input class="field-sm" placeholder="room name" bind:value={fRoom}
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
<input
class="field-sm"
placeholder="room name"
bind:value={fRoom}
onkeydown={(e) => e.key === "Enter" && onCreateRoom()}
/>
<div class="form-row">
<div class="segmented" aria-label="room visibility">
<button class:active={fRoomKind === 'public'} onclick={() => fRoomKind = 'public'}>public</button>
<button class:active={fRoomKind === 'private'} onclick={() => fRoomKind = 'private'}>private</button>
<button
class:active={fRoomKind === "public"}
onclick={() => (fRoomKind = "public")}>public</button
>
<button
class:active={fRoomKind === "private"}
onclick={() => (fRoomKind = "private")}>private</button
>
</div>
<button class="btn-xs" onclick={onCreateRoom}>create</button>
</div>
@@ -203,14 +241,22 @@
<!-- Rooms -->
<div class="section-label">ROOMS</div>
<nav class="room-list">
{#each rooms.filter((room) => room.kind !== 'direct') as room (full(room.id))}
{#each rooms.filter((room) => room.kind !== "direct") as room (full(room.id))}
<button
class="room-item"
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
class:active={activeRoom &&
full(room.id) === full(activeRoom.id)}
onclick={() => onSelectRoom(room)}
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
oncontextmenu={(e) =>
onShowMenu(e, [
{
label: "Copy room name",
action: () =>
navigator.clipboard.writeText(roomLabel(room)),
},
])}
>
<span class="hash">{room.kind === 'direct' ? '@' : '#'}</span>
<span class="hash">{room.kind === "direct" ? "@" : "#"}</span>
<span class="room-name">{roomLabel(room)}</span>
{#if unreadCounts[sid(room.id)]}
<span class="unread">{unreadCounts[sid(room.id)]}</span>
@@ -224,12 +270,20 @@
<!-- Direct messages -->
<div class="section-label">DIRECT</div>
<nav class="dm-list">
{#each rooms.filter((room) => room.kind === 'direct') as room (full(room.id))}
{#each rooms.filter((room) => room.kind === "direct") as room (full(room.id))}
<button
class="room-item"
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
class:active={activeRoom &&
full(room.id) === full(activeRoom.id)}
onclick={() => onSelectRoom(room)}
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
oncontextmenu={(e) =>
onShowMenu(e, [
{
label: "Copy name",
action: () =>
navigator.clipboard.writeText(roomLabel(room)),
},
])}
>
<span class="hash">@</span>
<span class="room-name">{roomLabel(room)}</span>
@@ -245,36 +299,69 @@
<!-- Contacts -->
<div class="section-label-row">
<span class="section-label">CONTACTS</span>
<button class="icon-btn" title="Add contact" onclick={() => { showAddContact = !showAddContact; }}>
{showAddContact ? '×' : '+'}
<button
class="icon-btn"
title="Add contact"
onclick={() => {
showAddContact = !showAddContact;
}}
>
{showAddContact ? "×" : "+"}
</button>
</div>
{#if showAddContact}
<div class="panel-form">
<div class="panel-title">find people</div>
<input class="field-sm" placeholder="search username" bind:value={fContactQuery}
<input
class="field-sm"
placeholder="search username"
bind:value={fContactQuery}
oninput={scheduleUserSearch}
onkeydown={(e) => e.key === 'Enter' && runUserSearch()} />
onkeydown={(e) => e.key === "Enter" && runUserSearch()}
/>
<div class="form-row">
<span class="helper-text">2+ characters</span>
<button class="btn-xs" onclick={runUserSearch} disabled={searchBusy}>
{searchBusy ? '...' : 'find'}
<button
class="btn-xs"
onclick={runUserSearch}
disabled={searchBusy}
>
{searchBusy ? "..." : "find"}
</button>
</div>
</div>
{#if contactErr}<p class="form-err" style="padding: 0 12px 6px">{contactErr}</p>{/if}
{#if contactErr}<p class="form-err" style="padding: 0 12px 6px">
{contactErr}
</p>{/if}
{#if searchResults.length > 0}
<div class="search-results">
{#each searchResults as result (full(result.id))}
<div class="search-result">
<span class="avatar mini">{result.username[0]?.toUpperCase() ?? '?'}</span>
<span class="avatar mini"
>{result.username[0]?.toUpperCase() ?? "?"}</span
>
<span class="contact-name">{result.username}</span>
<div class="row-actions">
{#if activeRoom && activeRoom.kind !== 'direct'}
<button class="mini-action" title="Invite" onclick={() => invite(sid(result.id))}>invite</button>
{#if activeRoom && activeRoom.kind !== "direct"}
<button
class="mini-action"
title="Invite"
onclick={() => invite(sid(result.id))}
>invite</button
>
{/if}
<button class="mini-action" title="Add contact" onclick={() => submitContact(sid(result.id))}>add</button>
<button class="mini-action primary" title="Message" onclick={() => startDm(sid(result.id))}>msg</button>
<button
class="mini-action"
title="Add contact"
onclick={() => submitContact(sid(result.id))}
>add</button
>
<button
class="mini-action primary"
title="Message"
onclick={() => startDm(sid(result.id))}
>msg</button
>
</div>
</div>
{/each}
@@ -287,7 +374,10 @@
<div class="contact-item">
<span class="presence online"></span>
<span class="contact-name">{c.username}</span>
<button class="mini-action contact-action" onclick={() => startDm(sid(c.id))}>msg</button>
<button
class="mini-action contact-action"
onclick={() => startDm(sid(c.id))}>msg</button
>
</div>
{/each}
</div>
@@ -296,29 +386,53 @@
<!-- User footer -->
{#if showEditProfile}
<div class="edit-profile-form">
<input class="field-sm" placeholder="username" bind:value={fProfileUsername}
onkeydown={(e) => e.key === 'Enter' && submitProfile()}
onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} />
<input class="field-sm" placeholder="avatar url (optional)" bind:value={fProfileAvatar}
onkeydown={(e) => e.key === 'Enter' && submitProfile()}
onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} />
<input
class="field-sm"
placeholder="username"
bind:value={fProfileUsername}
onkeydown={(e) => e.key === "Enter" && submitProfile()}
onkeyup={(e) => e.key === "Escape" && (showEditProfile = false)}
/>
<input
class="field-sm"
placeholder="avatar url (optional)"
bind:value={fProfileAvatar}
onkeydown={(e) => e.key === "Enter" && submitProfile()}
onkeyup={(e) => e.key === "Escape" && (showEditProfile = false)}
/>
{#if profileErr}<p class="form-err">{profileErr}</p>{/if}
<div class="form-row">
<button class="btn-xs" onclick={submitProfile}>save</button>
<button class="btn-xs btn-ghost" onclick={() => showEditProfile = false}>cancel</button>
<button
class="btn-xs btn-ghost"
onclick={() => (showEditProfile = false)}>cancel</button
>
</div>
</div>
{/if}
<div class="user-footer">
<button class="user-pill" title="Edit profile" onclick={openEditProfile}>
<span class="avatar">{user?.username?.[0]?.toUpperCase() ?? '?'}</span>
<span class="user-name">{user?.username ?? ''}</span>
<button
class="user-pill"
title="Edit profile"
onclick={openEditProfile}
>
<span class="avatar"
>{user?.username?.[0]?.toUpperCase() ?? "?"}</span
>
<span class="user-name">{user?.username ?? ""}</span>
</button>
<button class="icon-btn signout" title="Sign out" onclick={onSignout}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
@@ -330,7 +444,6 @@
onpointerdown={startResize}
onkeydown={onResizeKey}
></button>
</aside>
<style>
@@ -338,217 +451,427 @@
position: relative;
background: var(--sidebar-bg);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar.resizing,
.sidebar.resizing * { cursor: col-resize; user-select: none; }
.sidebar.resizing * {
cursor: col-resize;
user-select: none;
}
.resize-handle {
position: absolute; top: 0; right: -3px; bottom: 0;
width: 6px; cursor: col-resize; z-index: 4;
padding: 0; border: 0; background: transparent;
position: absolute;
top: 0;
right: -3px;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 4;
padding: 0;
border: 0;
background: transparent;
}
.resize-handle::after {
content: ''; position: absolute; top: 0; right: 2px; bottom: 0;
width: 1px; background: transparent;
content: "";
position: absolute;
top: 0;
right: 2px;
bottom: 0;
width: 1px;
background: transparent;
transition: background 0.12s;
}
.resize-handle:hover::after,
.resize-handle:focus-visible::after,
.sidebar.resizing .resize-handle::after { background: var(--accent); }
.sidebar.resizing .resize-handle::after {
background: var(--accent);
}
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 14px 14px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebar-brand {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 17px; font-weight: 700;
color: var(--accent); letter-spacing: 0.2em;
font-family: "Cormorant Garamond", Georgia, serif;
font-size: 17px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.2em;
}
.icon-btn {
width: 22px; height: 22px;
display: flex; align-items: center; justify-content: center;
background: none; border: 1px solid var(--border);
border-radius: var(--r); color: var(--muted);
font-size: 15px; line-height: 1;
cursor: pointer; transition: border-color 0.12s, color 0.12s;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--muted);
font-size: 15px;
line-height: 1;
cursor: pointer;
transition:
border-color 0.12s,
color 0.12s;
font-family: inherit;
}
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
.icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.icon-btn.signout:hover {
border-color: var(--danger);
color: var(--danger);
}
.panel-form {
display: flex; flex-direction: column; gap: 7px;
display: flex;
flex-direction: column;
gap: 7px;
padding: 10px 12px 11px;
border-bottom: 1px solid var(--border-subtle);
animation: rise 0.15s ease;
}
.panel-title {
font-size: 9px; letter-spacing: 0.14em;
color: var(--muted); text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.14em;
color: var(--muted);
text-transform: uppercase;
}
@keyframes rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.field-sm {
flex: 1; padding: 6px 10px;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--r); color: var(--text);
font-family: inherit; font-size: 11px; outline: none;
flex: 1;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text);
font-family: inherit;
font-size: 11px;
outline: none;
transition: border-color 0.12s;
}
.field-sm:focus { border-color: var(--accent); }
.field-sm::placeholder { color: var(--muted); }
.field-sm:focus {
border-color: var(--accent);
}
.field-sm::placeholder {
color: var(--muted);
}
.btn-xs {
padding: 6px 10px; flex-shrink: 0;
background: var(--accent); border: none;
border-radius: var(--r); color: #fff;
font-family: inherit; font-size: 11px; cursor: pointer;
padding: 6px 10px;
flex-shrink: 0;
background: var(--accent);
border: none;
border-radius: var(--r);
color: #fff;
font-family: inherit;
font-size: 11px;
cursor: pointer;
transition: opacity 0.12s;
}
.btn-xs:hover { opacity: 0.82; }
.btn-xs:disabled { opacity: 0.45; cursor: wait; }
.btn-xs:hover {
opacity: 0.82;
}
.btn-xs:disabled {
opacity: 0.45;
cursor: wait;
}
.form-row {
display: flex; align-items: center; justify-content: space-between;
gap: 7px; min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 7px;
min-width: 0;
}
.helper-text {
color: var(--muted); font-size: 10px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--muted);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.segmented {
display: flex; min-width: 0;
border: 1px solid var(--border); border-radius: var(--r);
overflow: hidden; background: var(--bg);
display: flex;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--r);
overflow: hidden;
background: var(--bg);
}
.segmented button {
padding: 5px 8px; background: transparent; border: none;
padding: 5px 8px;
background: transparent;
border: none;
border-right: 1px solid var(--border);
color: var(--muted); font-family: inherit; font-size: 10px;
color: var(--muted);
font-family: inherit;
font-size: 10px;
cursor: pointer;
}
.segmented button:last-child { border-right: 0; }
.segmented button:last-child {
border-right: 0;
}
.segmented button.active {
background: var(--accent-soft); color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.section-label {
padding: 14px 14px 5px;
font-size: 9px; letter-spacing: 0.14em;
color: var(--muted); font-weight: 500;
font-size: 9px;
letter-spacing: 0.14em;
color: var(--muted);
font-weight: 500;
}
.room-list { flex: 1; min-height: 70px; overflow-y: auto; padding: 3px 8px; }
.dm-list { max-height: 28%; overflow-y: auto; padding: 3px 8px; flex-shrink: 0; }
.room-list {
flex: 1;
min-height: 70px;
overflow-y: auto;
padding: 3px 8px;
}
.dm-list {
max-height: 28%;
overflow-y: auto;
padding: 3px 8px;
flex-shrink: 0;
}
.room-list::-webkit-scrollbar,
.dm-list::-webkit-scrollbar { width: 0; }
.dm-list::-webkit-scrollbar {
width: 0;
}
.room-item {
display: flex; align-items: center; gap: 5px;
width: 100%; padding: 5px 7px; margin-bottom: 1px;
background: none; border: none;
display: flex;
align-items: center;
gap: 5px;
width: 100%;
padding: 5px 7px;
margin-bottom: 1px;
background: none;
border: none;
border-left: 2px solid transparent;
border-radius: 0 var(--r) var(--r) 0;
color: var(--muted); font-family: inherit; font-size: 13px;
cursor: pointer; text-align: left; transition: all 0.1s;
color: var(--muted);
font-family: inherit;
font-size: 13px;
cursor: pointer;
text-align: left;
transition: all 0.1s;
}
.room-item:hover {
background: var(--surface);
color: var(--text-2);
}
.room-item:hover { background: var(--surface); color: var(--text-2); }
.room-item.active {
background: var(--accent-soft);
border-left-color: var(--accent);
color: var(--text);
}
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
.room-item.active .hash { color: var(--accent); }
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unread {
min-width: 18px; height: 18px; margin-left: auto;
display: inline-flex; align-items: center; justify-content: center;
border-radius: var(--r); background: var(--accent); color: #fff;
font-size: 10px; padding: 0 5px;
.hash {
color: var(--muted);
font-size: 14px;
flex-shrink: 0;
}
.room-item.active .hash {
color: var(--accent);
}
.room-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread {
min-width: 18px;
height: 18px;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--r);
background: var(--accent);
color: #fff;
font-size: 10px;
padding: 0 5px;
}
.list-empty {
padding: 8px 7px;
color: var(--muted);
font-size: 10.5px;
}
.list-empty { padding: 8px 7px; color: var(--muted); font-size: 10.5px; }
.contact-list { padding: 3px 8px; max-height: 24%; overflow-y: auto; flex-shrink: 0; }
.contact-list {
padding: 3px 8px;
max-height: 24%;
overflow-y: auto;
flex-shrink: 0;
}
.contact-item {
display: flex; align-items: center; gap: 7px;
padding: 5px 7px; color: var(--muted); font-size: 12px;
display: flex;
align-items: center;
gap: 7px;
padding: 5px 7px;
color: var(--muted);
font-size: 12px;
border-left: 2px solid transparent;
}
.contact-item:hover { background: var(--surface); color: var(--text-2); }
.contact-action { margin-left: auto; }
.contact-item:hover {
background: var(--surface);
color: var(--text-2);
}
.contact-action {
margin-left: auto;
}
.search-results {
padding: 4px 8px 8px;
border-bottom: 1px solid var(--border-subtle);
}
.search-result {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center; gap: 7px;
padding: 5px 4px; color: var(--text-2); font-size: 11px;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
padding: 5px 4px;
color: var(--text-2);
font-size: 11px;
}
.row-actions {
display: flex;
gap: 4px;
justify-content: flex-end;
}
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
.mini-action {
padding: 3px 6px; background: transparent;
border: 1px solid var(--border); border-radius: var(--r);
color: var(--muted); font-family: inherit; font-size: 10px;
cursor: pointer; white-space: nowrap;
padding: 3px 6px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--muted);
font-family: inherit;
font-size: 10px;
cursor: pointer;
white-space: nowrap;
}
.mini-action:hover {
border-color: var(--accent);
color: var(--accent);
}
.mini-action:hover { border-color: var(--accent); color: var(--accent); }
.mini-action.primary {
background: var(--accent-soft); border-color: rgba(181, 98, 26, 0.28);
background: var(--accent-soft);
border-color: rgba(181, 98, 26, 0.28);
color: var(--accent);
}
.presence {
width: 6px; height: 6px; border-radius: 50%;
background: var(--muted); flex-shrink: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
}
.presence.online {
background: var(--online);
box-shadow: 0 0 5px var(--online);
}
.contact-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.presence.online { background: var(--online); box-shadow: 0 0 5px var(--online); }
.contact-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.user-footer {
display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface); margin-top: auto;
background: var(--surface);
margin-top: auto;
}
.user-pill {
display: flex; align-items: center; gap: 8px; min-width: 0;
background: none; border: none; cursor: pointer; padding: 0;
font-family: inherit; text-align: left;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
background: none;
border: none;
cursor: pointer;
padding: 0;
font-family: inherit;
text-align: left;
}
.user-pill:hover .user-name {
color: var(--text);
}
.user-pill:hover .user-name { color: var(--text); }
.section-label-row {
display: flex; align-items: center; justify-content: space-between;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 14px 5px 14px;
}
.section-label-row .section-label { padding: 0; }
.section-label-row .section-label {
padding: 0;
}
.edit-profile-form {
display: flex; flex-direction: column; gap: 6px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid var(--border-subtle);
animation: rise 0.15s ease;
}
.btn-ghost {
background: transparent; border: 1px solid var(--border); color: var(--muted);
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.btn-ghost:hover {
opacity: 0.8;
border-color: var(--muted);
}
.btn-ghost:hover { opacity: 0.8; border-color: var(--muted); }
.form-err {
font-size: 10px; color: var(--danger);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 10px;
color: var(--danger);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.avatar {
width: 26px; height: 26px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: var(--accent); border-radius: var(--r);
color: #fff; font-size: 11px; font-weight: 600;
width: 26px;
height: 26px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent);
border-radius: var(--r);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.avatar.mini {
width: 20px;
height: 20px;
font-size: 9px;
}
.avatar.mini { width: 20px; height: 20px; font-size: 9px; }
.user-name {
font-size: 12px; color: var(--text-2);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px;
color: var(--text-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -2,15 +2,15 @@
// 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 (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);
@@ -19,16 +19,22 @@ export function sid(thing: any): string {
// 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);
}

View File

@@ -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;
}

View File

@@ -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,34 +121,76 @@
}
// ─── 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(() => {});
const token = ++roomSelectionToken;
const roomId = sid(room.id);
const previousSubId = subId;
const previousUnlisten = unlisten;
subId = null;
}
if (unlisten) {
unlisten();
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 nextUnlisten = await listen<LiveEvent>(
"chat:message",
({ payload }) => {
const { action, data } = payload;
const eventRoomId = sid(data.room);
const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
@@ -160,23 +204,42 @@
Notification.permission === "granted" &&
document.hidden
) {
new Notification(data.author_username ?? "New message", {
new Notification(
data.author_username ?? "New message",
{
body: data.body || "New message",
});
},
);
}
return;
}
if (action === "Create") {
messages = [...messages, data];
} else if (action === "Delete") {
messages = messages.filter((m) => full(m.id) !== full(data.id));
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(() => {});
});
cmd("mark_room_read", { roomId: currentRoomId }).catch(
() => {},
);
},
);
if (!isCurrentRoomSelection(token, roomId)) {
nextUnlisten();
if (subId === nextSubId) {
await cmd("unsubscribe_room", { subId: nextSubId }).catch(
() => {},
);
subId = null;
}
return;
}
unlisten = nextUnlisten;
}
async function loadOlderMessages() {
@@ -187,21 +250,26 @@
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 {
if (isCurrentRoomSelection(token, roomId)) {
isLoadingOlder = false;
}
}
}
async function createRoom() {
if (!fRoom.trim()) return;
@@ -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);