fixed up duplicate dm's
Some checks failed
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-04-18 23:04:56 -04:00
parent 80a217fc5b
commit eafd12758a
3 changed files with 508 additions and 313 deletions

View File

@@ -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(&current_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)?;

View File

@@ -1,12 +1,19 @@
<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);
@@ -20,18 +27,24 @@
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 fRoom = $state("");
let fRoomKind = $state<"public" | "private">("public");
let replyTo = $state<Message | null>(null); 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();
@@ -41,196 +54,279 @@
// ─── Auth ───────────────────────────────────────────────────────────────── // ─── Auth ─────────────────────────────────────────────────────────────────
async function init() { async function init() {
try { try {
user = await cmd<User>('restore_session'); user = await cmd<User>("restore_session");
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 {
view = 'auth'; view = "auth";
} }
} }
async function signin() { async function signin() {
err = ''; err = "";
try { try {
await cmd('signin', { email: fEmail, password: fPass }); await cmd("signin", { email: fEmail, password: fPass });
user = await cmd<User>('get_me'); 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 (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
async function signup() { async function signup() {
err = ''; err = "";
try { try {
user = await cmd<User>('signup', { email: fEmail, username: fUser, password: fPass }); user = await cmd<User>("signup", {
view = 'app'; email: fEmail,
username: fUser,
password: fPass,
});
view = "app";
await loadRooms(); await loadRooms();
requestNotificationPermission(); requestNotificationPermission();
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
function requestNotificationPermission() { function requestNotificationPermission() {
if ('Notification' in window && Notification.permission === 'default') { if ("Notification" in window && Notification.permission === "default") {
Notification.requestPermission().catch(() => {}); Notification.requestPermission().catch(() => {});
} }
} }
async function signout() { async function signout() {
await cmd('signout').catch(() => {}); await cmd("signout").catch(() => {});
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; } if (subId) {
if (unlisten){ unlisten(); unlisten = null; } await cmd("unsubscribe_room", { subId }).catch(() => {});
user = null; rooms = []; messages = []; activeRoom = null; unreadCounts = {}; subId = null;
view = 'auth'; }
if (unlisten) {
unlisten();
unlisten = null;
}
user = null;
rooms = [];
messages = [];
activeRoom = null;
unreadCounts = {};
view = "auth";
} }
// ─── Rooms ──────────────────────────────────────────────────────────────── // ─── Rooms ────────────────────────────────────────────────────────────────
async function loadRooms() { async function loadRooms() {
rooms = await cmd<Room[]>('get_rooms'); rooms = await cmd<Room[]>("get_rooms");
if (rooms.length && !activeRoom) await selectRoom(rooms[0]); if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
} }
async function selectRoom(room: Room) { async function selectRoom(room: Room) {
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; } if (subId) {
if (unlisten){ unlisten(); unlisten = null; } await cmd("unsubscribe_room", { subId }).catch(() => {});
subId = null;
}
if (unlisten) {
unlisten();
unlisten = null;
}
activeRoom = room; activeRoom = room;
replyTo = null; replyTo = null;
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id), limit: 50 }); messages = await cmd<Message[]>("get_messages", {
roomId: sid(room.id),
limit: 50,
});
hasOlderMessages = messages.length === 50; hasOlderMessages = messages.length === 50;
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 }; unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
await cmd('mark_room_read', { roomId: sid(room.id) }).catch(() => {}); await cmd("mark_room_read", { roomId: sid(room.id) }).catch(() => {});
subId = await cmd<string>('subscribe_room', { roomId: sid(room.id) }); subId = await cmd<string>("subscribe_room", { roomId: sid(room.id) });
const { listen } = await import('@tauri-apps/api/event'); const { listen } = await import("@tauri-apps/api/event");
unlisten = await listen<LiveEvent>('chat:message', ({ payload }) => { unlisten = await listen<LiveEvent>("chat:message", ({ payload }) => {
const { action, data } = payload; const { action, data } = payload;
const eventRoomId = sid(data.room); const eventRoomId = sid(data.room);
const currentRoomId = activeRoom ? sid(activeRoom.id) : ''; const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
if (eventRoomId !== currentRoomId) { if (eventRoomId !== currentRoomId) {
unreadCounts = { ...unreadCounts, [eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1 }; unreadCounts = {
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) { ...unreadCounts,
new Notification(data.author_username ?? 'New message', { body: data.body || 'New message' }); [eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1,
};
if (
"Notification" in window &&
Notification.permission === "granted" &&
document.hidden
) {
new Notification(data.author_username ?? "New message", {
body: data.body || "New message",
});
} }
return; return;
} }
if (action === 'Create') { messages = [...messages, data]; } if (action === "Create") {
else if (action === 'Delete') { messages = messages.filter(m => full(m.id) !== full(data.id)); } messages = [...messages, data];
else if (action === 'Update') { messages = messages.map(m => full(m.id) === full(data.id) ? data : m); } } else if (action === "Delete") {
cmd('mark_room_read', { roomId: currentRoomId }).catch(() => {}); 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 loadOlderMessages() {
if (!activeRoom || isLoadingOlder || !hasOlderMessages || messages.length === 0) return; if (
!activeRoom ||
isLoadingOlder ||
!hasOlderMessages ||
messages.length === 0
)
return;
isLoadingOlder = true; isLoadingOlder = true;
try { try {
const older = await cmd<Message[]>('get_messages', { const older = await cmd<Message[]>("get_messages", {
roomId: sid(activeRoom.id), roomId: sid(activeRoom.id),
before: messages[0].created, before: messages[0].created,
limit: 50, limit: 50,
}); });
messages = [...older, ...messages]; messages = [...older, ...messages];
hasOlderMessages = older.length === 50; hasOlderMessages = older.length === 50;
} catch (e) { err = String(e); } } catch (e) {
finally { isLoadingOlder = false; } err = String(e);
} finally {
isLoadingOlder = false;
}
} }
async function createRoom() { async function createRoom() {
if (!fRoom.trim()) return; if (!fRoom.trim()) return;
err = ''; err = "";
try { try {
const r = await cmd<Room>('create_room', { name: fRoom.trim(), kind: fRoomKind }); const r = await cmd<Room>("create_room", {
name: fRoom.trim(),
kind: fRoomKind,
});
rooms = [r, ...rooms]; rooms = [r, ...rooms];
fRoom = ''; showNewRoom = false; fRoom = "";
showNewRoom = false;
await selectRoom(r); await selectRoom(r);
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
// ─── Messages ───────────────────────────────────────────────────────────── // ─── Messages ─────────────────────────────────────────────────────────────
async function sendMessage() { async function sendMessage() {
if (!fMsg.trim() || !activeRoom) return; if (!fMsg.trim() || !activeRoom) return;
err = ''; err = "";
try { try {
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim(), replyTo: replyTo ? sid(replyTo.id) : null }); await cmd("send_message", {
fMsg = ''; roomId: sid(activeRoom.id),
body: fMsg.trim(),
replyTo: replyTo ? sid(replyTo.id) : null,
});
fMsg = "";
replyTo = null; replyTo = null;
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
async function deleteMessage(msgId: string) { async function deleteMessage(msgId: string) {
err = ''; err = "";
try { try {
await cmd('delete_message', { messageId: msgId }); await cmd("delete_message", { messageId: msgId });
messages = messages.filter(m => full(m.id) !== msgId); messages = messages.filter((m) => full(m.id) !== msgId);
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
async function editMessage(msgId: string, body: string) { async function editMessage(msgId: string, body: string) {
err = ''; err = "";
try { try {
const updated = await cmd<Message>('edit_message', { messageId: msgId, body }); const updated = await cmd<Message>("edit_message", {
messages = messages.map(m => full(m.id) === full(updated.id) ? updated : m); messageId: msgId,
} catch (e) { err = String(e); } 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) { async function toggleReaction(msgId: string, emoji: string) {
err = ''; err = "";
try { try {
await cmd('toggle_reaction', { messageId: msgId, emoji }); await cmd("toggle_reaction", { messageId: msgId, emoji });
if (activeRoom) { if (activeRoom) {
messages = await cmd<Message[]>('get_messages', { messages = await cmd<Message[]>("get_messages", {
roomId: sid(activeRoom.id), roomId: sid(activeRoom.id),
limit: Math.max(50, Math.min(messages.length, 100)), limit: Math.max(50, Math.min(messages.length, 100)),
}); });
} }
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
async function updateProfile(fields: { username?: string; avatar?: string }) { async function updateProfile(fields: {
user = await cmd<User>('update_profile', fields); username?: string;
avatar?: string;
}) {
user = await cmd<User>("update_profile", fields);
} }
async function addContact(userId: string) { async function addContact(userId: string) {
await cmd('add_contact', { userId }); await cmd("add_contact", { userId });
contacts = await cmd<User[]>('get_contacts').catch(() => []); contacts = await cmd<User[]>("get_contacts").catch(() => []);
} }
async function searchUsers(query: string) { async function searchUsers(query: string) {
return await cmd<UserSearchResult[]>('search_users', { query }); return await cmd<UserSearchResult[]>("search_users", { query });
} }
async function startDirectMessage(userId: string) { async function startDirectMessage(userId: string) {
err = ''; err = "";
try { try {
const room = await cmd<Room>('get_or_create_direct_room', { userId }); const room = await cmd<Room>("get_or_create_direct_room", {
if (!rooms.some(r => full(r.id) === full(room.id))) rooms = [room, ...rooms]; userId,
});
if (!rooms.some((r) => full(r.id) === full(room.id)))
rooms = [room, ...rooms];
await selectRoom(room); await selectRoom(room);
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
async function inviteToActiveRoom(userId: string) { async function inviteToActiveRoom(userId: string) {
if (!activeRoom || activeRoom.kind === 'direct') return; if (!activeRoom || activeRoom.kind === "direct") return;
err = ''; err = "";
try { try {
await cmd('invite_to_room', { roomId: sid(activeRoom.id), userId }); await cmd("invite_to_room", { roomId: sid(activeRoom.id), userId });
} catch (e) { err = String(e); } } catch (e) {
err = String(e);
}
} }
onMount(init); onMount(init);
onDestroy(async () => { onDestroy(async () => {
if (subId) await cmd('unsubscribe_room', { subId }).catch(() => {}); if (subId) await cmd("unsubscribe_room", { subId }).catch(() => {});
if (unlisten) unlisten(); 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}
@@ -239,9 +335,11 @@
bind:fUser bind:fUser
onSignin={signin} onSignin={signin}
onSignup={signup} onSignup={signup}
onToggleMode={() => { authMode = authMode === 'signin' ? 'signup' : 'signin'; err = ''; }} onToggleMode={() => {
authMode = authMode === "signin" ? "signup" : "signin";
err = "";
}}
/> />
{:else} {:else}
<div class="app"> <div class="app">
<Sidebar <Sidebar
@@ -285,18 +383,24 @@
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) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(html, body) { :global(html, body) {
width: 100%; height: 100%; overflow: hidden; width: 100%;
height: 100%;
overflow: hidden;
background: #09090b; background: #09090b;
font-family: 'Martian Mono', 'Courier New', monospace; font-family: "Martian Mono", "Courier New", monospace;
font-size: 13px; font-size: 13px;
color: #ddd8d0; color: #ddd8d0;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -322,11 +426,19 @@
} }
.app { .app {
display: flex; height: 100vh; width: 100%; display: flex;
height: 100vh;
width: 100%;
animation: rise 0.2s ease; animation: rise 0.2s ease;
} }
@keyframes rise { @keyframes rise {
from { opacity: 0; transform: translateY(10px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
</style> </style>

2
surreal/connect-database.sh Executable file
View 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