fixed up duplicate dm's
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
|
use surrealdb::types::{RecordId, RecordIdKey};
|
||||||
use surrealdb::Notification;
|
use surrealdb::Notification;
|
||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -50,6 +51,46 @@ fn validate_message_body(body: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn record_key_string(id: &RecordId) -> String {
|
||||||
|
match &id.key {
|
||||||
|
RecordIdKey::String(value) => value.clone(),
|
||||||
|
RecordIdKey::Number(value) => value.to_string(),
|
||||||
|
RecordIdKey::Uuid(value) => value.to_string(),
|
||||||
|
other => format!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_record_key(user_id: &str) -> String {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if let Some((table, key)) = user_id.split_once(':') {
|
||||||
|
if table == "user" {
|
||||||
|
return format!("user:{key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("user:{user_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_id_key(user_id: &str) -> String {
|
||||||
|
let user_id = user_id.trim();
|
||||||
|
if let Some((table, key)) = user_id.split_once(':') {
|
||||||
|
if table == "user" {
|
||||||
|
return key.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direct_room_key(current_user: &RecordId, target_user_id: &str) -> String {
|
||||||
|
let mut participants = [
|
||||||
|
user_record_key(&record_key_string(current_user)),
|
||||||
|
user_record_key(target_user_id),
|
||||||
|
];
|
||||||
|
participants.sort();
|
||||||
|
participants.join("|")
|
||||||
|
}
|
||||||
|
|
||||||
async fn current_user(state: &State<'_, AppState>) -> Result<User, String> {
|
async fn current_user(state: &State<'_, AppState>) -> Result<User, String> {
|
||||||
let mut result: Vec<User> = state
|
let mut result: Vec<User> = state
|
||||||
.db
|
.db
|
||||||
@@ -129,6 +170,26 @@ async fn hydrate_direct_rooms(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dedupe_direct_rooms(rooms: Vec<Room>) -> Vec<Room> {
|
||||||
|
let mut seen_direct_users = HashMap::new();
|
||||||
|
let mut deduped = Vec::with_capacity(rooms.len());
|
||||||
|
|
||||||
|
for room in rooms {
|
||||||
|
if room.kind == "direct" {
|
||||||
|
if let Some(other_user) = &room.other_user {
|
||||||
|
let key = user_record_key(&record_key_string(&other_user.id));
|
||||||
|
if seen_direct_users.insert(key, ()).is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped.push(room);
|
||||||
|
}
|
||||||
|
|
||||||
|
deduped
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new chat room and add the creator as owner.
|
/// Create a new chat room and add the creator as owner.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn create_room(
|
pub async fn create_room(
|
||||||
@@ -197,7 +258,7 @@ pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String>
|
|||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
hydrate_direct_rooms(&state, &mut result).await?;
|
hydrate_direct_rooms(&state, &mut result).await?;
|
||||||
Ok(result)
|
Ok(dedupe_direct_rooms(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a user to a room. Room owners can invite others.
|
/// Add a user to a room. Room owners can invite others.
|
||||||
@@ -232,16 +293,15 @@ pub async fn get_or_create_direct_room(
|
|||||||
user_id: String,
|
user_id: String,
|
||||||
) -> Result<Room, String> {
|
) -> Result<Room, String> {
|
||||||
let me = current_user(&state).await?;
|
let me = current_user(&state).await?;
|
||||||
let me_key =
|
let target_user_id = user_id_key(&user_id);
|
||||||
serde_json::to_string(&me.id).map_err(|e| into_err(AppError::Auth(e.to_string())))?;
|
let current_user_key = record_key_string(&me.id);
|
||||||
let target_key = serde_json::json!({
|
if user_record_key(¤t_user_key) == user_record_key(&target_user_id) {
|
||||||
"table": "user",
|
return Err(
|
||||||
"key": { "String": user_id.clone() }
|
AppError::Auth("cannot start a direct message with yourself".into()).to_string(),
|
||||||
})
|
);
|
||||||
.to_string();
|
}
|
||||||
let mut participants = [me_key, target_key];
|
|
||||||
participants.sort();
|
let direct_key = direct_room_key(&me.id, &target_user_id);
|
||||||
let direct_key = participants.join("|");
|
|
||||||
|
|
||||||
let mut existing: Vec<Room> = state
|
let mut existing: Vec<Room> = state
|
||||||
.db
|
.db
|
||||||
@@ -257,6 +317,27 @@ pub async fn get_or_create_direct_room(
|
|||||||
return Ok(room);
|
return Ok(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut existing_by_members: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"SELECT * FROM room
|
||||||
|
WHERE kind = 'direct'
|
||||||
|
AND id IN (SELECT VALUE room FROM room_member WHERE user = $auth)
|
||||||
|
AND id IN (SELECT VALUE room FROM room_member WHERE user = type::record('user', $user_id))
|
||||||
|
ORDER BY updated DESC, created DESC
|
||||||
|
LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(("user_id", target_user_id.clone()))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
if let Some(mut room) = existing_by_members.pop() {
|
||||||
|
hydrate_direct_rooms(&state, std::slice::from_mut(&mut room)).await?;
|
||||||
|
return Ok(room);
|
||||||
|
}
|
||||||
|
|
||||||
let mut created: Vec<Room> = state
|
let mut created: Vec<Room> = state
|
||||||
.db
|
.db
|
||||||
.query(
|
.query(
|
||||||
@@ -285,7 +366,7 @@ pub async fn get_or_create_direct_room(
|
|||||||
CREATE room_member SET room = $room, user = type::record('user', $user_id), role = 'member', joined = time::now(), muted = false;",
|
CREATE room_member SET room = $room, user = type::record('user', $user_id), role = 'member', joined = time::now(), muted = false;",
|
||||||
)
|
)
|
||||||
.bind(("room", room.id.clone()))
|
.bind(("room", room.id.clone()))
|
||||||
.bind(("user_id", user_id))
|
.bind(("user_id", target_user_id))
|
||||||
.await
|
.await
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,332 +1,444 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from "svelte";
|
||||||
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
import LoadingScreen from "$lib/components/LoadingScreen.svelte";
|
||||||
import AuthCard from '$lib/components/AuthCard.svelte';
|
import AuthCard from "$lib/components/AuthCard.svelte";
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from "$lib/components/Sidebar.svelte";
|
||||||
import ChatMain from '$lib/components/ChatMain.svelte';
|
import ChatMain from "$lib/components/ChatMain.svelte";
|
||||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
import ContextMenu from "$lib/components/ContextMenu.svelte";
|
||||||
import type { User, Room, Message, LiveEvent, ContextMenuItem, UserSearchResult } from '$lib/types';
|
import type {
|
||||||
import { sid, full, cmd } from '$lib/helpers';
|
User,
|
||||||
|
Room,
|
||||||
|
Message,
|
||||||
|
LiveEvent,
|
||||||
|
ContextMenuItem,
|
||||||
|
UserSearchResult,
|
||||||
|
} from "$lib/types";
|
||||||
|
import { sid, full, cmd } from "$lib/helpers";
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
let user = $state<User | null>(null);
|
let user = $state<User | null>(null);
|
||||||
let rooms = $state<Room[]>([]);
|
let rooms = $state<Room[]>([]);
|
||||||
let activeRoom = $state<Room | null>(null);
|
let activeRoom = $state<Room | null>(null);
|
||||||
let messages = $state<Message[]>([]);
|
let messages = $state<Message[]>([]);
|
||||||
let contacts = $state<User[]>([]);
|
let contacts = $state<User[]>([]);
|
||||||
let subId = $state<string | null>(null);
|
let subId = $state<string | null>(null);
|
||||||
let unlisten = $state<(() => void) | null>(null);
|
let unlisten = $state<(() => void) | null>(null);
|
||||||
let hasOlderMessages = $state(false);
|
let hasOlderMessages = $state(false);
|
||||||
let isLoadingOlder = $state(false);
|
let isLoadingOlder = $state(false);
|
||||||
let unreadCounts = $state<Record<string, number>>({});
|
let unreadCounts = $state<Record<string, number>>({});
|
||||||
|
|
||||||
let view = $state<'loading' | 'auth' | 'app'>('loading');
|
let view = $state<"loading" | "auth" | "app">("loading");
|
||||||
let authMode = $state<'signin' | 'signup'>('signin');
|
let authMode = $state<"signin" | "signup">("signin");
|
||||||
let showNewRoom= $state(false);
|
let showNewRoom = $state(false);
|
||||||
let err = $state('');
|
let err = $state("");
|
||||||
|
|
||||||
let fEmail = $state(''); let fPass = $state('');
|
let fEmail = $state("");
|
||||||
let fUser = $state(''); let fMsg = $state('');
|
let fPass = $state("");
|
||||||
let fRoom = $state('');
|
let fUser = $state("");
|
||||||
let fRoomKind = $state<'public' | 'private'>('public');
|
let fMsg = $state("");
|
||||||
let replyTo = $state<Message | null>(null);
|
let fRoom = $state("");
|
||||||
|
let fRoomKind = $state<"public" | "private">("public");
|
||||||
|
let replyTo = $state<Message | null>(null);
|
||||||
|
|
||||||
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
let contextMenu = $state<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
contextMenu = { x: e.clientX, y: e.clientY, items };
|
contextMenu = { x: e.clientX, y: e.clientY, items };
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Auth ─────────────────────────────────────────────────────────────────
|
|
||||||
async function init() {
|
|
||||||
try {
|
|
||||||
user = await cmd<User>('restore_session');
|
|
||||||
view = 'app';
|
|
||||||
await loadRooms();
|
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
|
||||||
requestNotificationPermission();
|
|
||||||
} catch {
|
|
||||||
view = 'auth';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function signin() {
|
// ─── Auth ─────────────────────────────────────────────────────────────────
|
||||||
err = '';
|
async function init() {
|
||||||
try {
|
try {
|
||||||
await cmd('signin', { email: fEmail, password: fPass });
|
user = await cmd<User>("restore_session");
|
||||||
user = await cmd<User>('get_me');
|
view = "app";
|
||||||
view = 'app';
|
await loadRooms();
|
||||||
await loadRooms();
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
requestNotificationPermission();
|
||||||
requestNotificationPermission();
|
} catch {
|
||||||
} catch (e) { err = String(e); }
|
view = "auth";
|
||||||
}
|
|
||||||
|
|
||||||
async function signup() {
|
|
||||||
err = '';
|
|
||||||
try {
|
|
||||||
user = await cmd<User>('signup', { email: fEmail, username: fUser, password: fPass });
|
|
||||||
view = 'app';
|
|
||||||
await loadRooms();
|
|
||||||
requestNotificationPermission();
|
|
||||||
} catch (e) { err = String(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestNotificationPermission() {
|
|
||||||
if ('Notification' in window && Notification.permission === 'default') {
|
|
||||||
Notification.requestPermission().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signout() {
|
|
||||||
await cmd('signout').catch(() => {});
|
|
||||||
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
|
||||||
if (unlisten){ unlisten(); unlisten = null; }
|
|
||||||
user = null; rooms = []; messages = []; activeRoom = null; unreadCounts = {};
|
|
||||||
view = 'auth';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Rooms ────────────────────────────────────────────────────────────────
|
|
||||||
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; }
|
|
||||||
|
|
||||||
activeRoom = room;
|
|
||||||
replyTo = null;
|
|
||||||
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id), limit: 50 });
|
|
||||||
hasOlderMessages = messages.length === 50;
|
|
||||||
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
|
|
||||||
await cmd('mark_room_read', { roomId: sid(room.id) }).catch(() => {});
|
|
||||||
|
|
||||||
subId = await cmd<string>('subscribe_room', { roomId: sid(room.id) });
|
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
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); }
|
|
||||||
cmd('mark_room_read', { roomId: currentRoomId }).catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadOlderMessages() {
|
async function signin() {
|
||||||
if (!activeRoom || isLoadingOlder || !hasOlderMessages || messages.length === 0) return;
|
err = "";
|
||||||
isLoadingOlder = true;
|
try {
|
||||||
try {
|
await cmd("signin", { email: fEmail, password: fPass });
|
||||||
const older = await cmd<Message[]>('get_messages', {
|
user = await cmd<User>("get_me");
|
||||||
roomId: sid(activeRoom.id),
|
view = "app";
|
||||||
before: messages[0].created,
|
await loadRooms();
|
||||||
limit: 50,
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
});
|
requestNotificationPermission();
|
||||||
messages = [...older, ...messages];
|
} catch (e) {
|
||||||
hasOlderMessages = older.length === 50;
|
err = String(e);
|
||||||
} catch (e) { err = String(e); }
|
}
|
||||||
finally { isLoadingOlder = false; }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async function createRoom() {
|
async function signup() {
|
||||||
if (!fRoom.trim()) return;
|
err = "";
|
||||||
err = '';
|
try {
|
||||||
try {
|
user = await cmd<User>("signup", {
|
||||||
const r = await cmd<Room>('create_room', { name: fRoom.trim(), kind: fRoomKind });
|
email: fEmail,
|
||||||
rooms = [r, ...rooms];
|
username: fUser,
|
||||||
fRoom = ''; showNewRoom = false;
|
password: fPass,
|
||||||
await selectRoom(r);
|
});
|
||||||
} catch (e) { err = String(e); }
|
view = "app";
|
||||||
}
|
await loadRooms();
|
||||||
|
requestNotificationPermission();
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Messages ─────────────────────────────────────────────────────────────
|
function requestNotificationPermission() {
|
||||||
async function sendMessage() {
|
if ("Notification" in window && Notification.permission === "default") {
|
||||||
if (!fMsg.trim() || !activeRoom) return;
|
Notification.requestPermission().catch(() => {});
|
||||||
err = '';
|
}
|
||||||
try {
|
}
|
||||||
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim(), replyTo: replyTo ? sid(replyTo.id) : null });
|
|
||||||
fMsg = '';
|
|
||||||
replyTo = null;
|
|
||||||
} catch (e) { err = String(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteMessage(msgId: string) {
|
async function signout() {
|
||||||
err = '';
|
await cmd("signout").catch(() => {});
|
||||||
try {
|
if (subId) {
|
||||||
await cmd('delete_message', { messageId: msgId });
|
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
messages = messages.filter(m => full(m.id) !== msgId);
|
subId = null;
|
||||||
} catch (e) { err = String(e); }
|
}
|
||||||
}
|
if (unlisten) {
|
||||||
|
unlisten();
|
||||||
|
unlisten = null;
|
||||||
|
}
|
||||||
|
user = null;
|
||||||
|
rooms = [];
|
||||||
|
messages = [];
|
||||||
|
activeRoom = null;
|
||||||
|
unreadCounts = {};
|
||||||
|
view = "auth";
|
||||||
|
}
|
||||||
|
|
||||||
async function editMessage(msgId: string, body: string) {
|
// ─── Rooms ────────────────────────────────────────────────────────────────
|
||||||
err = '';
|
async function loadRooms() {
|
||||||
try {
|
rooms = await cmd<Room[]>("get_rooms");
|
||||||
const updated = await cmd<Message>('edit_message', { messageId: msgId, body });
|
if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
|
||||||
messages = messages.map(m => full(m.id) === full(updated.id) ? updated : m);
|
}
|
||||||
} catch (e) { err = String(e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleReaction(msgId: string, emoji: string) {
|
async function selectRoom(room: Room) {
|
||||||
err = '';
|
if (subId) {
|
||||||
try {
|
await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
await cmd('toggle_reaction', { messageId: msgId, emoji });
|
subId = null;
|
||||||
if (activeRoom) {
|
}
|
||||||
messages = await cmd<Message[]>('get_messages', {
|
if (unlisten) {
|
||||||
roomId: sid(activeRoom.id),
|
unlisten();
|
||||||
limit: Math.max(50, Math.min(messages.length, 100)),
|
unlisten = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRoom = room;
|
||||||
|
replyTo = null;
|
||||||
|
messages = await cmd<Message[]>("get_messages", {
|
||||||
|
roomId: sid(room.id),
|
||||||
|
limit: 50,
|
||||||
});
|
});
|
||||||
}
|
hasOlderMessages = messages.length === 50;
|
||||||
} catch (e) { err = String(e); }
|
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
|
||||||
}
|
await cmd("mark_room_read", { roomId: sid(room.id) }).catch(() => {});
|
||||||
|
|
||||||
async function updateProfile(fields: { username?: string; avatar?: string }) {
|
subId = await cmd<string>("subscribe_room", { roomId: sid(room.id) });
|
||||||
user = await cmd<User>('update_profile', fields);
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
cmd("mark_room_read", { roomId: currentRoomId }).catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function addContact(userId: string) {
|
async function loadOlderMessages() {
|
||||||
await cmd('add_contact', { userId });
|
if (
|
||||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
!activeRoom ||
|
||||||
}
|
isLoadingOlder ||
|
||||||
|
!hasOlderMessages ||
|
||||||
|
messages.length === 0
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
isLoadingOlder = true;
|
||||||
|
try {
|
||||||
|
const older = await cmd<Message[]>("get_messages", {
|
||||||
|
roomId: sid(activeRoom.id),
|
||||||
|
before: messages[0].created,
|
||||||
|
limit: 50,
|
||||||
|
});
|
||||||
|
messages = [...older, ...messages];
|
||||||
|
hasOlderMessages = older.length === 50;
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
} finally {
|
||||||
|
isLoadingOlder = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function searchUsers(query: string) {
|
async function createRoom() {
|
||||||
return await cmd<UserSearchResult[]>('search_users', { query });
|
if (!fRoom.trim()) return;
|
||||||
}
|
err = "";
|
||||||
|
try {
|
||||||
|
const r = await cmd<Room>("create_room", {
|
||||||
|
name: fRoom.trim(),
|
||||||
|
kind: fRoomKind,
|
||||||
|
});
|
||||||
|
rooms = [r, ...rooms];
|
||||||
|
fRoom = "";
|
||||||
|
showNewRoom = false;
|
||||||
|
await selectRoom(r);
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function startDirectMessage(userId: string) {
|
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||||
err = '';
|
async function sendMessage() {
|
||||||
try {
|
if (!fMsg.trim() || !activeRoom) return;
|
||||||
const room = await cmd<Room>('get_or_create_direct_room', { userId });
|
err = "";
|
||||||
if (!rooms.some(r => full(r.id) === full(room.id))) rooms = [room, ...rooms];
|
try {
|
||||||
await selectRoom(room);
|
await cmd("send_message", {
|
||||||
} catch (e) { err = String(e); }
|
roomId: sid(activeRoom.id),
|
||||||
}
|
body: fMsg.trim(),
|
||||||
|
replyTo: replyTo ? sid(replyTo.id) : null,
|
||||||
|
});
|
||||||
|
fMsg = "";
|
||||||
|
replyTo = null;
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function inviteToActiveRoom(userId: string) {
|
async function deleteMessage(msgId: string) {
|
||||||
if (!activeRoom || activeRoom.kind === 'direct') return;
|
err = "";
|
||||||
err = '';
|
try {
|
||||||
try {
|
await cmd("delete_message", { messageId: msgId });
|
||||||
await cmd('invite_to_room', { roomId: sid(activeRoom.id), userId });
|
messages = messages.filter((m) => full(m.id) !== msgId);
|
||||||
} catch (e) { err = String(e); }
|
} catch (e) {
|
||||||
}
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(init);
|
async function editMessage(msgId: string, body: string) {
|
||||||
onDestroy(async () => {
|
err = "";
|
||||||
if (subId) await cmd('unsubscribe_room', { subId }).catch(() => {});
|
try {
|
||||||
if (unlisten) unlisten();
|
const updated = await cmd<Message>("edit_message", {
|
||||||
});
|
messageId: msgId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
messages = messages.map((m) =>
|
||||||
|
full(m.id) === full(updated.id) ? updated : m,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleReaction(msgId: string, emoji: string) {
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
await cmd("toggle_reaction", { messageId: msgId, emoji });
|
||||||
|
if (activeRoom) {
|
||||||
|
messages = await cmd<Message[]>("get_messages", {
|
||||||
|
roomId: sid(activeRoom.id),
|
||||||
|
limit: Math.max(50, Math.min(messages.length, 100)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateProfile(fields: {
|
||||||
|
username?: string;
|
||||||
|
avatar?: string;
|
||||||
|
}) {
|
||||||
|
user = await cmd<User>("update_profile", fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addContact(userId: string) {
|
||||||
|
await cmd("add_contact", { userId });
|
||||||
|
contacts = await cmd<User[]>("get_contacts").catch(() => []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers(query: string) {
|
||||||
|
return await cmd<UserSearchResult[]>("search_users", { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDirectMessage(userId: string) {
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
const room = await cmd<Room>("get_or_create_direct_room", {
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
if (!rooms.some((r) => full(r.id) === full(room.id)))
|
||||||
|
rooms = [room, ...rooms];
|
||||||
|
await selectRoom(room);
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteToActiveRoom(userId: string) {
|
||||||
|
if (!activeRoom || activeRoom.kind === "direct") return;
|
||||||
|
err = "";
|
||||||
|
try {
|
||||||
|
await cmd("invite_to_room", { roomId: sid(activeRoom.id), userId });
|
||||||
|
} catch (e) {
|
||||||
|
err = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(init);
|
||||||
|
onDestroy(async () => {
|
||||||
|
if (subId) await cmd("unsubscribe_room", { subId }).catch(() => {});
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if view === 'loading'}
|
{#if view === "loading"}
|
||||||
<LoadingScreen />
|
<LoadingScreen />
|
||||||
|
{:else if view === "auth"}
|
||||||
{:else if view === 'auth'}
|
<AuthCard
|
||||||
<AuthCard
|
{authMode}
|
||||||
{authMode}
|
{err}
|
||||||
{err}
|
bind:fEmail
|
||||||
bind:fEmail
|
bind:fPass
|
||||||
bind:fPass
|
bind:fUser
|
||||||
bind:fUser
|
onSignin={signin}
|
||||||
onSignin={signin}
|
onSignup={signup}
|
||||||
onSignup={signup}
|
onToggleMode={() => {
|
||||||
onToggleMode={() => { authMode = authMode === 'signin' ? 'signup' : 'signin'; err = ''; }}
|
authMode = authMode === "signin" ? "signup" : "signin";
|
||||||
/>
|
err = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
{user}
|
{user}
|
||||||
{rooms}
|
{rooms}
|
||||||
{contacts}
|
{contacts}
|
||||||
{activeRoom}
|
{activeRoom}
|
||||||
bind:showNewRoom
|
bind:showNewRoom
|
||||||
bind:fRoom
|
bind:fRoom
|
||||||
bind:fRoomKind
|
bind:fRoomKind
|
||||||
{unreadCounts}
|
{unreadCounts}
|
||||||
onSelectRoom={selectRoom}
|
onSelectRoom={selectRoom}
|
||||||
onCreateRoom={createRoom}
|
onCreateRoom={createRoom}
|
||||||
onSignout={signout}
|
onSignout={signout}
|
||||||
onShowMenu={showMenu}
|
onShowMenu={showMenu}
|
||||||
onUpdateProfile={updateProfile}
|
onUpdateProfile={updateProfile}
|
||||||
onAddContact={addContact}
|
onAddContact={addContact}
|
||||||
onSearchUsers={searchUsers}
|
onSearchUsers={searchUsers}
|
||||||
onStartDirectMessage={startDirectMessage}
|
onStartDirectMessage={startDirectMessage}
|
||||||
onInviteToRoom={inviteToActiveRoom}
|
onInviteToRoom={inviteToActiveRoom}
|
||||||
/>
|
/>
|
||||||
<ChatMain
|
<ChatMain
|
||||||
{activeRoom}
|
{activeRoom}
|
||||||
{messages}
|
{messages}
|
||||||
{user}
|
{user}
|
||||||
{err}
|
{err}
|
||||||
{hasOlderMessages}
|
{hasOlderMessages}
|
||||||
{isLoadingOlder}
|
{isLoadingOlder}
|
||||||
bind:fMsg
|
bind:fMsg
|
||||||
bind:replyTo
|
bind:replyTo
|
||||||
onLoadOlderMessages={loadOlderMessages}
|
onLoadOlderMessages={loadOlderMessages}
|
||||||
onSendMessage={sendMessage}
|
onSendMessage={sendMessage}
|
||||||
onDeleteMessage={deleteMessage}
|
onDeleteMessage={deleteMessage}
|
||||||
onEditMessage={editMessage}
|
onEditMessage={editMessage}
|
||||||
onToggleReaction={toggleReaction}
|
onToggleReaction={toggleReaction}
|
||||||
onShowMenu={showMenu}
|
onShowMenu={showMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if contextMenu}
|
{#if contextMenu}
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
items={contextMenu.items}
|
items={contextMenu.items}
|
||||||
onclose={() => contextMenu = null}
|
onclose={() => (contextMenu = null)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ─── Reset & base ──────────────────────────────────────────────────────── */
|
/* ─── Reset & base ──────────────────────────────────────────────────────── */
|
||||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
:global(*, *::before, *::after) {
|
||||||
:global(html, body) {
|
box-sizing: border-box;
|
||||||
width: 100%; height: 100%; overflow: hidden;
|
margin: 0;
|
||||||
background: #09090b;
|
padding: 0;
|
||||||
font-family: 'Martian Mono', 'Courier New', monospace;
|
}
|
||||||
font-size: 13px;
|
:global(html, body) {
|
||||||
color: #ddd8d0;
|
width: 100%;
|
||||||
-webkit-font-smoothing: antialiased;
|
height: 100%;
|
||||||
}
|
overflow: hidden;
|
||||||
|
background: #09090b;
|
||||||
|
font-family: "Martian Mono", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ddd8d0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Design tokens ─────────────────────────────────────────────────────── */
|
/* ─── Design tokens ─────────────────────────────────────────────────────── */
|
||||||
:global(:root) {
|
:global(:root) {
|
||||||
--bg: #09090b;
|
--bg: #09090b;
|
||||||
--sidebar-bg: #0d0d10;
|
--sidebar-bg: #0d0d10;
|
||||||
--surface: #111115;
|
--surface: #111115;
|
||||||
--surface-2: #161619;
|
--surface-2: #161619;
|
||||||
--border: #1c1c22;
|
--border: #1c1c22;
|
||||||
--border-subtle: #161619;
|
--border-subtle: #161619;
|
||||||
--accent: #b5621a;
|
--accent: #b5621a;
|
||||||
--accent-glow: rgba(181, 98, 26, 0.14);
|
--accent-glow: rgba(181, 98, 26, 0.14);
|
||||||
--accent-soft: rgba(181, 98, 26, 0.08);
|
--accent-soft: rgba(181, 98, 26, 0.08);
|
||||||
--text: #ddd8d0;
|
--text: #ddd8d0;
|
||||||
--text-2: #9994a0;
|
--text-2: #9994a0;
|
||||||
--muted: #46464f;
|
--muted: #46464f;
|
||||||
--online: #3cb870;
|
--online: #3cb870;
|
||||||
--danger: #b83030;
|
--danger: #b83030;
|
||||||
--r: 2px;
|
--r: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: flex; height: 100vh; width: 100%;
|
display: flex;
|
||||||
animation: rise 0.2s ease;
|
height: 100vh;
|
||||||
}
|
width: 100%;
|
||||||
@keyframes rise {
|
animation: rise 0.2s ease;
|
||||||
from { opacity: 0; transform: translateY(10px); }
|
}
|
||||||
to { opacity: 1; transform: translateY(0); }
|
@keyframes rise {
|
||||||
}
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
2
surreal/connect-database.sh
Executable file
2
surreal/connect-database.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
surreal sql -e http://127.0.0.1:8000 --namespace dev --database oxyde --user root --pass root
|
||||||
Reference in New Issue
Block a user