Compare commits
3 Commits
v0.1.3
...
9337ea01f2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9337ea01f2 | |||
| 555e79b390 | |||
| 0ccb77be40 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oxyde",
|
"name": "oxyde",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"description": "A simple Tauri chat app, built with rust, vite, and surrealdb",
|
"description": "A simple Tauri chat app, built with rust, vite, and surrealdb",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
@@ -3824,7 +3824,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oxyde"
|
name = "oxyde"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "oxyde"
|
name = "oxyde"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "A simple Tauri chat app, built with rust, vite, and surrealdb"
|
description = "A simple Tauri chat app, built with rust, vite, and surrealdb"
|
||||||
authors = ["qdust41"]
|
authors = ["qdust41"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use tauri::{AppHandle, Emitter, State};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::{into_err, AppError};
|
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;
|
use crate::AppState;
|
||||||
|
|
||||||
const DEFAULT_PAGE_SIZE: i64 = 50;
|
const DEFAULT_PAGE_SIZE: i64 = 50;
|
||||||
@@ -163,6 +166,22 @@ async fn hydrate_reactions(
|
|||||||
Ok(())
|
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(
|
async fn hydrate_direct_rooms(
|
||||||
state: &State<'_, AppState>,
|
state: &State<'_, AppState>,
|
||||||
rooms: &mut [Room],
|
rooms: &mut [Room],
|
||||||
@@ -442,9 +461,12 @@ pub async fn send_message(
|
|||||||
.take(0)
|
.take(0)
|
||||||
.map_err(into_err)?;
|
.map_err(into_err)?;
|
||||||
|
|
||||||
result
|
let mut msg = result
|
||||||
.pop()
|
.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.
|
/// Return cached messages for a room without hitting the remote DB.
|
||||||
@@ -504,9 +526,15 @@ pub async fn get_messages(
|
|||||||
result.reverse();
|
result.reverse();
|
||||||
let user = current_user(&state).await?;
|
let user = current_user(&state).await?;
|
||||||
hydrate_reactions(&state, &user, &mut result).await?;
|
hydrate_reactions(&state, &user, &mut result).await?;
|
||||||
|
hydrate_replies(&state.db, &mut result).await?;
|
||||||
|
|
||||||
if before.is_none() {
|
if before.is_none() {
|
||||||
cache_put(&state.msg_cache, &state.cache_order, &room_id, result.clone());
|
cache_put(
|
||||||
|
&state.msg_cache,
|
||||||
|
&state.cache_order,
|
||||||
|
&room_id,
|
||||||
|
result.clone(),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let mut c = state.msg_cache.lock().unwrap();
|
let mut c = state.msg_cache.lock().unwrap();
|
||||||
if let Some(existing) = c.get_mut(&room_id) {
|
if let Some(existing) = c.get_mut(&room_id) {
|
||||||
@@ -641,7 +669,11 @@ pub async fn subscribe_room(
|
|||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
while let Some(Ok(notification)) = stream.next().await {
|
while let Some(Ok(notification)) = stream.next().await {
|
||||||
let action = format!("{:?}", notification.action);
|
let action = format!("{:?}", notification.action);
|
||||||
let data = notification.data.clone();
|
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 c = msg_cache.lock().unwrap();
|
||||||
@@ -673,7 +705,7 @@ pub async fn subscribe_room(
|
|||||||
"chat:message",
|
"chat:message",
|
||||||
&LiveMessageEvent {
|
&LiveMessageEvent {
|
||||||
action,
|
action,
|
||||||
data: ¬ification.data,
|
data: &data,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ pub struct RoomMember {
|
|||||||
pub muted: Option<bool>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
pub struct Message {
|
pub struct Message {
|
||||||
pub id: RecordId,
|
pub id: RecordId,
|
||||||
@@ -48,6 +55,7 @@ pub struct Message {
|
|||||||
pub updated: Option<Datetime>,
|
pub updated: Option<Datetime>,
|
||||||
pub deleted: Option<bool>,
|
pub deleted: Option<bool>,
|
||||||
pub reply_to: Option<RecordId>,
|
pub reply_to: Option<RecordId>,
|
||||||
|
pub replied_to_message: Option<MessageSnippet>,
|
||||||
pub reactions: Option<Vec<MessageReactionSummary>>,
|
pub reactions: Option<Vec<MessageReactionSummary>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "oxyde",
|
"productName": "oxyde",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"identifier": "com.jimweaver.oxyde",
|
"identifier": "com.jimweaver.oxyde",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Oxyde</title>
|
<title>Oxyde</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<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
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
authMode: 'signin' | 'signup';
|
authMode: "signin" | "signup";
|
||||||
err: string;
|
err: string;
|
||||||
fEmail: string;
|
fEmail: string;
|
||||||
fPass: string;
|
fPass: string;
|
||||||
@@ -31,11 +31,23 @@
|
|||||||
<div class="err-banner">{err}</div>
|
<div class="err-banner">{err}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if authMode === 'signin'}
|
{#if authMode === "signin"}
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
<input
|
||||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
class="field"
|
||||||
onkeydown={(e) => e.key === 'Enter' && onSignin()} autocomplete="current-password" />
|
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>
|
<button class="btn-primary" onclick={onSignin}>sign in</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-ghost" onclick={onToggleMode}>
|
<button class="btn-ghost" onclick={onToggleMode}>
|
||||||
@@ -43,11 +55,31 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="field-stack">
|
<div class="field-stack">
|
||||||
<input class="field" type="text" placeholder="username" bind:value={fUser} autocomplete="username" />
|
<input
|
||||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
class="field"
|
||||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
type="text"
|
||||||
onkeydown={(e) => e.key === 'Enter' && onSignup()} autocomplete="new-password" />
|
placeholder="username"
|
||||||
<button class="btn-primary" onclick={onSignup}>create account</button>
|
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>
|
</div>
|
||||||
<button class="btn-ghost" onclick={onToggleMode}>
|
<button class="btn-ghost" onclick={onToggleMode}>
|
||||||
← back to sign in
|
← back to sign in
|
||||||
@@ -58,62 +90,116 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.auth-wrap {
|
.auth-wrap {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex;
|
||||||
height: 100vh; background: var(--bg);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
animation: rise 0.28s ease;
|
animation: rise 0.28s 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.auth-card {
|
.auth-card {
|
||||||
width: 360px; padding: 52px 44px;
|
width: 360px;
|
||||||
|
padding: 52px 44px;
|
||||||
background: var(--sidebar-bg);
|
background: var(--sidebar-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r);
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
.auth-brand {
|
.auth-brand {
|
||||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
font-family: "Cormorant Garamond", Georgia, serif;
|
||||||
font-size: 52px; font-weight: 700;
|
font-size: 52px;
|
||||||
color: var(--accent); letter-spacing: 0.22em;
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.22em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.auth-tagline {
|
.auth-tagline {
|
||||||
text-align: center; color: var(--muted);
|
text-align: center;
|
||||||
font-size: 9.5px; letter-spacing: 0.15em;
|
color: var(--muted);
|
||||||
margin-top: 8px; margin-bottom: 36px;
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 36px;
|
||||||
}
|
}
|
||||||
.err-banner {
|
.err-banner {
|
||||||
padding: 10px 14px; margin-bottom: 18px;
|
padding: 10px 14px;
|
||||||
background: rgba(184, 48, 48, 0.10);
|
margin-bottom: 18px;
|
||||||
|
background: rgba(184, 48, 48, 0.1);
|
||||||
border: 1px solid rgba(184, 48, 48, 0.28);
|
border: 1px solid rgba(184, 48, 48, 0.28);
|
||||||
border-radius: var(--r);
|
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 {
|
.field {
|
||||||
width: 100%; padding: 10px 14px;
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border); border-radius: var(--r);
|
border: 1px solid var(--border);
|
||||||
color: var(--text); font-family: inherit; font-size: 12px;
|
border-radius: var(--r);
|
||||||
outline: none; transition: border-color 0.12s;
|
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 {
|
.btn-primary {
|
||||||
width: 100%; padding: 11px;
|
width: 100%;
|
||||||
background: var(--accent); border: none; border-radius: var(--r);
|
padding: 11px;
|
||||||
color: #fff; font-family: inherit; font-size: 12px;
|
background: var(--accent);
|
||||||
font-weight: 500; letter-spacing: 0.07em;
|
border: none;
|
||||||
cursor: pointer; transition: opacity 0.12s, transform 0.08s;
|
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 {
|
.btn-ghost {
|
||||||
display: block; width: 100%; text-align: center;
|
display: block;
|
||||||
padding: 9px; background: none; border: none;
|
width: 100%;
|
||||||
color: var(--muted); font-family: inherit; font-size: 11px;
|
text-align: center;
|
||||||
cursor: pointer; transition: color 0.12s;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { tick } from 'svelte';
|
import { tick } from "svelte";
|
||||||
import type { User, Room, Message, ContextMenuItem } from '$lib/types';
|
import type { User, Room, Message, ContextMenuItem } from "$lib/types";
|
||||||
import { full, sid, fmt } from '$lib/helpers';
|
import { full, sid, fmt } from "$lib/helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
activeRoom: Room | null;
|
activeRoom: Room | null;
|
||||||
@@ -40,26 +40,32 @@
|
|||||||
let msgEl: HTMLElement;
|
let msgEl: HTMLElement;
|
||||||
let inputEl: HTMLTextAreaElement;
|
let inputEl: HTMLTextAreaElement;
|
||||||
let editingId = $state<string | null>(null);
|
let editingId = $state<string | null>(null);
|
||||||
let editBody = $state('');
|
let editBody = $state("");
|
||||||
|
|
||||||
function scrollBottom() {
|
function scrollBottom() {
|
||||||
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
tick().then(() => {
|
||||||
|
if (msgEl) msgEl.scrollTop = msgEl.scrollHeight;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoResize() {
|
function autoResize() {
|
||||||
if (!inputEl) return;
|
if (!inputEl) return;
|
||||||
inputEl.style.height = 'auto';
|
inputEl.style.height = "auto";
|
||||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
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 {
|
function roomLabel(room: Room | null): string {
|
||||||
if (!room) return 'select a room';
|
if (!room) return "select a room";
|
||||||
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
if (room.kind === "direct")
|
||||||
return room.name ?? 'untitled';
|
return room.other_user?.username ?? room.name ?? "direct message";
|
||||||
|
return room.name ?? "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGrouped(i: number): boolean {
|
function isGrouped(i: number): boolean {
|
||||||
@@ -77,11 +83,11 @@
|
|||||||
if (!editBody.trim()) return;
|
if (!editBody.trim()) return;
|
||||||
onEditMessage(full(msg.id), editBody.trim());
|
onEditMessage(full(msg.id), editBody.trim());
|
||||||
editingId = null;
|
editingId = null;
|
||||||
editBody = '';
|
editBody = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function quickReact(msg: Message) {
|
function quickReact(msg: Message) {
|
||||||
onToggleReaction(full(msg.id), '+1');
|
onToggleReaction(full(msg.id), "+1");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom when messages change
|
// Scroll to bottom when messages change
|
||||||
@@ -92,15 +98,14 @@
|
|||||||
|
|
||||||
// Reset textarea height after message is cleared
|
// Reset textarea height after message is cleared
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (fMsg === '') autoResize();
|
if (fMsg === "") autoResize();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
|
|
||||||
<!-- Channel header -->
|
<!-- Channel header -->
|
||||||
<header class="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>
|
<span class="ch-name">{roomLabel(activeRoom)}</span>
|
||||||
{#if err}<span class="header-err">{err}</span>{/if}
|
{#if err}<span class="header-err">{err}</span>{/if}
|
||||||
</header>
|
</header>
|
||||||
@@ -119,8 +124,12 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if hasOlderMessages}
|
{#if hasOlderMessages}
|
||||||
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
|
<button
|
||||||
{isLoadingOlder ? 'loading...' : 'load older messages'}
|
class="load-older"
|
||||||
|
onclick={onLoadOlderMessages}
|
||||||
|
disabled={isLoadingOlder}
|
||||||
|
>
|
||||||
|
{isLoadingOlder ? "loading..." : "load older messages"}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each messages as msg, i (full(msg.id))}
|
{#each messages as msg, i (full(msg.id))}
|
||||||
@@ -130,13 +139,31 @@
|
|||||||
role="listitem"
|
role="listitem"
|
||||||
oncontextmenu={(e) => {
|
oncontextmenu={(e) => {
|
||||||
const items: ContextMenuItem[] = [
|
const items: ContextMenuItem[] = [
|
||||||
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
|
{
|
||||||
{ label: 'Reply', action: () => replyTo = msg },
|
label: "Copy message",
|
||||||
{ label: 'React +1', action: () => onToggleReaction(full(msg.id), '+1') },
|
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) {
|
if (
|
||||||
items.push({ label: 'Edit message', action: () => beginEdit(msg) });
|
user &&
|
||||||
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
|
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);
|
onShowMenu(e, items);
|
||||||
}}
|
}}
|
||||||
@@ -147,24 +174,57 @@
|
|||||||
class="msg-author"
|
class="msg-author"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
|
oncontextmenu={(e) => {
|
||||||
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
|
e.stopPropagation();
|
||||||
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
|
onShowMenu(e, [
|
||||||
]); }}
|
{
|
||||||
>{msg.author_username ?? sid(msg.author)}</span>
|
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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if msg.reply_to}
|
{#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}
|
||||||
{#if !msg.deleted}
|
{#if !msg.deleted}
|
||||||
<div class="msg-actions" aria-label="message actions">
|
<div class="msg-actions" aria-label="message actions">
|
||||||
<button title="Reply" onclick={() => replyTo = msg}>reply</button>
|
<button
|
||||||
<button title="React" onclick={() => quickReact(msg)}>+1</button>
|
title="Reply"
|
||||||
|
onclick={() => (replyTo = msg)}>reply</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
title="React"
|
||||||
|
onclick={() => quickReact(msg)}>+1</button
|
||||||
|
>
|
||||||
{#if user && full(msg.author) === full(user.id)}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -172,9 +232,20 @@
|
|||||||
<p class="msg-body deleted">message deleted</p>
|
<p class="msg-body deleted">message deleted</p>
|
||||||
{:else if editingId === full(msg.id)}
|
{:else if editingId === full(msg.id)}
|
||||||
<div class="edit-row">
|
<div class="edit-row">
|
||||||
<textarea class="edit-input" bind:value={editBody} rows="2"></textarea>
|
<textarea
|
||||||
<button class="mini-btn" onclick={() => submitEdit(msg)}>save</button>
|
class="edit-input"
|
||||||
<button class="mini-btn ghost" onclick={() => editingId = null}>cancel</button>
|
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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="msg-body">{msg.body}</p>
|
<p class="msg-body">{msg.body}</p>
|
||||||
@@ -185,9 +256,14 @@
|
|||||||
<button
|
<button
|
||||||
class="reaction"
|
class="reaction"
|
||||||
class:mine={reaction.reacted_by_me}
|
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>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -200,180 +276,356 @@
|
|||||||
<!-- Input bar -->
|
<!-- Input bar -->
|
||||||
{#if replyTo}
|
{#if replyTo}
|
||||||
<div class="reply-bar">
|
<div class="reply-bar">
|
||||||
<span>replying to {replyTo.author_username ?? sid(replyTo.author)}</span>
|
<span
|
||||||
<button class="mini-btn ghost" onclick={() => replyTo = null}>cancel</button>
|
>replying to {replyTo.author_username ??
|
||||||
|
sid(replyTo.author)}</span
|
||||||
|
>
|
||||||
|
<button class="mini-btn ghost" onclick={() => (replyTo = null)}
|
||||||
|
>cancel</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="input-bar">
|
<div class="input-bar">
|
||||||
<textarea
|
<textarea
|
||||||
bind:this={inputEl}
|
bind:this={inputEl}
|
||||||
class="msg-input"
|
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}
|
bind:value={fMsg}
|
||||||
onkeydown={onKey}
|
onkeydown={onKey}
|
||||||
oninput={autoResize}
|
oninput={autoResize}
|
||||||
disabled={!activeRoom}
|
disabled={!activeRoom}
|
||||||
rows="1"
|
rows="1"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button title="" class="send-btn" onclick={onSendMessage} disabled={!activeRoom || !fMsg.trim()}>
|
<button
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
title=""
|
||||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
class="send-btn"
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.main {
|
.main {
|
||||||
flex: 1; display: flex; flex-direction: column;
|
flex: 1;
|
||||||
overflow: hidden; background: var(--bg);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.channel-header {
|
.channel-header {
|
||||||
display: flex; align-items: center; gap: 9px;
|
display: flex;
|
||||||
padding: 0 24px; height: 50px;
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 50px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.ch-hash { font-size: 17px; color: var(--muted); }
|
.ch-hash {
|
||||||
.ch-name { font-size: 14px; font-weight: 500; color: var(--text); }
|
font-size: 17px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.ch-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
.header-err {
|
.header-err {
|
||||||
margin-left: auto; font-size: 10px; color: #d98080;
|
margin-left: auto;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
font-size: 10px;
|
||||||
|
color: #d98080;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages {
|
.messages {
|
||||||
flex: 1; overflow-y: auto;
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 20px 24px 8px;
|
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 {
|
.load-older {
|
||||||
align-self: center; margin-bottom: 12px; padding: 6px 10px;
|
align-self: center;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
margin-bottom: 12px;
|
||||||
border-radius: var(--r); color: var(--text-2);
|
padding: 6px 10px;
|
||||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
background: var(--surface);
|
||||||
}
|
border: 1px solid var(--border);
|
||||||
.load-older:hover { border-color: var(--accent); color: var(--text); }
|
border-radius: var(--r);
|
||||||
.load-older:disabled { opacity: 0.5; cursor: wait; }
|
color: var(--text-2);
|
||||||
|
font-family: inherit;
|
||||||
.empty-state {
|
font-size: 11px;
|
||||||
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;
|
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 {
|
.reply-chip {
|
||||||
display: inline-flex; margin: 2px 0 3px; padding: 3px 6px;
|
display: inline-flex;
|
||||||
border-left: 2px solid var(--accent); background: var(--surface);
|
margin: 2px 0 3px;
|
||||||
color: var(--muted); font-size: 10px;
|
padding: 3px 6px;
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.edit-row {
|
.edit-row {
|
||||||
display: flex; align-items: flex-end; gap: 6px;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
.edit-input {
|
.edit-input {
|
||||||
flex: 1; resize: vertical; min-height: 44px; max-height: 120px;
|
flex: 1;
|
||||||
padding: 7px 9px; background: var(--surface);
|
resize: vertical;
|
||||||
border: 1px solid var(--border); border-radius: var(--r);
|
min-height: 44px;
|
||||||
color: var(--text); font-family: inherit; font-size: 12px;
|
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 {
|
.mini-btn {
|
||||||
padding: 6px 8px; background: var(--accent); border: none;
|
padding: 6px 8px;
|
||||||
border-radius: var(--r); color: #fff; font-family: inherit;
|
background: var(--accent);
|
||||||
font-size: 10px; cursor: pointer;
|
border: none;
|
||||||
}
|
border-radius: var(--r);
|
||||||
.mini-btn.ghost {
|
color: #fff;
|
||||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
font-family: inherit;
|
||||||
}
|
font-size: 10px;
|
||||||
.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;
|
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 {
|
@keyframes msgIn {
|
||||||
from { opacity: 0; transform: translateY(3px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(3px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-bar {
|
.input-bar {
|
||||||
display: flex; align-items: flex-end; gap: 8px;
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
padding: 12px 24px 16px;
|
padding: 12px 24px 16px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.reply-bar {
|
.reply-bar {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
padding: 8px 24px; border-top: 1px solid var(--border);
|
align-items: center;
|
||||||
color: var(--text-2); font-size: 11px; background: var(--surface);
|
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 {
|
.msg-input {
|
||||||
flex: 1; resize: none;
|
flex: 1;
|
||||||
|
resize: none;
|
||||||
padding: 9px 13px;
|
padding: 9px 13px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border); border-radius: var(--r);
|
border: 1px solid var(--border);
|
||||||
color: var(--text); font-family: inherit; font-size: 13px;
|
border-radius: var(--r);
|
||||||
line-height: 1.55; outline: none;
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
outline: none;
|
||||||
transition: border-color 0.12s;
|
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 {
|
.send-btn {
|
||||||
width: 34px; height: 34px; flex-shrink: 0;
|
width: 34px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
height: 34px;
|
||||||
background: var(--accent); border: none; border-radius: var(--r);
|
flex-shrink: 0;
|
||||||
color: #fff; cursor: pointer;
|
display: flex;
|
||||||
transition: opacity 0.12s, transform 0.08s;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from "svelte";
|
||||||
import type { ContextMenuItem } from '$lib/types';
|
import type { ContextMenuItem } from "$lib/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -19,8 +19,10 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!menuEl) return;
|
if (!menuEl) return;
|
||||||
const rect = menuEl.getBoundingClientRect();
|
const rect = menuEl.getBoundingClientRect();
|
||||||
if (rect.right > window.innerWidth) menuEl.style.left = (x - rect.width) + 'px';
|
if (rect.right > window.innerWidth)
|
||||||
if (rect.bottom > window.innerHeight) menuEl.style.top = (y - rect.height) + 'px';
|
menuEl.style.left = x - rect.width + "px";
|
||||||
|
if (rect.bottom > window.innerHeight)
|
||||||
|
menuEl.style.top = y - rect.height + "px";
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -33,9 +35,16 @@
|
|||||||
closeTimer = setTimeout(onclose, 1200);
|
closeTimer = setTimeout(onclose, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWindowClick() { onclose(); }
|
function onWindowClick() {
|
||||||
function onWindowKey(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); }
|
onclose();
|
||||||
function onWindowContext(e: MouseEvent) { e.preventDefault(); onclose(); }
|
}
|
||||||
|
function onWindowKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onclose();
|
||||||
|
}
|
||||||
|
function onWindowContext(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window
|
<svelte:window
|
||||||
@@ -50,7 +59,10 @@
|
|||||||
style="left:{x}px; top:{y}px"
|
style="left:{x}px; top:{y}px"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
oncontextmenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
{#each items as item, i}
|
{#each items as item, i}
|
||||||
@@ -60,7 +72,7 @@
|
|||||||
class:copied={copiedIndex === i}
|
class:copied={copiedIndex === i}
|
||||||
onclick={() => handleItem(item, i)}
|
onclick={() => handleItem(item, i)}
|
||||||
>
|
>
|
||||||
{copiedIndex === i ? 'Copied!' : item.label}
|
{copiedIndex === i ? "Copied!" : item.label}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -76,13 +88,19 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r);
|
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;
|
z-index: 9999;
|
||||||
animation: rise 0.15s ease;
|
animation: rise 0.15s ease;
|
||||||
}
|
}
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ctx-item {
|
.ctx-item {
|
||||||
@@ -98,7 +116,10 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
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 {
|
.ctx-item:hover {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
|
|||||||
@@ -5,25 +5,42 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.loading {
|
.loading {
|
||||||
display: flex; flex-direction: column;
|
display: flex;
|
||||||
align-items: center; justify-content: center;
|
flex-direction: column;
|
||||||
height: 100vh; gap: 20px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
gap: 20px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
font-family: "Cormorant Garamond", Georgia, serif;
|
||||||
font-size: 32px; font-weight: 700;
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
letter-spacing: 0.22em;
|
letter-spacing: 0.22em;
|
||||||
animation: pulse 1.6s ease-in-out infinite;
|
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 {
|
.spinner {
|
||||||
width: 20px; height: 20px;
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
border: 1.5px solid var(--border);
|
border: 1.5px solid var(--border);
|
||||||
border-top-color: var(--accent);
|
border-top-color: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.75s linear infinite;
|
animation: spin 0.75s linear infinite;
|
||||||
}
|
}
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from "svelte";
|
||||||
import type { User, Room, ContextMenuItem, UserSearchResult } from '$lib/types';
|
import type {
|
||||||
import { full, sid } from '$lib/helpers';
|
User,
|
||||||
|
Room,
|
||||||
|
ContextMenuItem,
|
||||||
|
UserSearchResult,
|
||||||
|
} from "$lib/types";
|
||||||
|
import { full, sid } from "$lib/helpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -10,13 +15,16 @@
|
|||||||
activeRoom: Room | null;
|
activeRoom: Room | null;
|
||||||
showNewRoom: boolean;
|
showNewRoom: boolean;
|
||||||
fRoom: string;
|
fRoom: string;
|
||||||
fRoomKind: 'public' | 'private';
|
fRoomKind: "public" | "private";
|
||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
onSelectRoom: (room: Room) => void;
|
onSelectRoom: (room: Room) => void;
|
||||||
onCreateRoom: () => void;
|
onCreateRoom: () => void;
|
||||||
onSignout: () => void;
|
onSignout: () => void;
|
||||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => 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>;
|
onAddContact: (userId: string) => Promise<void>;
|
||||||
onSearchUsers: (query: string) => Promise<UserSearchResult[]>;
|
onSearchUsers: (query: string) => Promise<UserSearchResult[]>;
|
||||||
onStartDirectMessage: (userId: string) => Promise<void>;
|
onStartDirectMessage: (userId: string) => Promise<void>;
|
||||||
@@ -44,8 +52,9 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function roomLabel(room: Room): string {
|
function roomLabel(room: Room): string {
|
||||||
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
if (room.kind === "direct")
|
||||||
return room.name ?? 'untitled';
|
return room.other_user?.username ?? room.name ?? "direct message";
|
||||||
|
return room.name ?? "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
const minSidebarWidth = 228;
|
const minSidebarWidth = 228;
|
||||||
@@ -59,7 +68,7 @@
|
|||||||
|
|
||||||
function setSidebarWidth(width: number) {
|
function setSidebarWidth(width: number) {
|
||||||
sidebarWidth = clampSidebarWidth(width);
|
sidebarWidth = clampSidebarWidth(width);
|
||||||
localStorage.setItem('oxyde.sidebarWidth', String(sidebarWidth));
|
localStorage.setItem("oxyde.sidebarWidth", String(sidebarWidth));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onResizeMove(e: PointerEvent) {
|
function onResizeMove(e: PointerEvent) {
|
||||||
@@ -77,69 +86,74 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onResizeKey(e: KeyboardEvent) {
|
function onResizeKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'ArrowLeft') {
|
if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSidebarWidth(sidebarWidth - 16);
|
setSidebarWidth(sidebarWidth - 16);
|
||||||
} else if (e.key === 'ArrowRight') {
|
} else if (e.key === "ArrowRight") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSidebarWidth(sidebarWidth + 16);
|
setSidebarWidth(sidebarWidth + 16);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const stored = Number(localStorage.getItem('oxyde.sidebarWidth'));
|
const stored = Number(localStorage.getItem("oxyde.sidebarWidth"));
|
||||||
if (Number.isFinite(stored)) sidebarWidth = clampSidebarWidth(stored);
|
if (Number.isFinite(stored)) sidebarWidth = clampSidebarWidth(stored);
|
||||||
window.addEventListener('pointermove', onResizeMove);
|
window.addEventListener("pointermove", onResizeMove);
|
||||||
window.addEventListener('pointerup', stopResize);
|
window.addEventListener("pointerup", stopResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
window.removeEventListener('pointermove', onResizeMove);
|
window.removeEventListener("pointermove", onResizeMove);
|
||||||
window.removeEventListener('pointerup', stopResize);
|
window.removeEventListener("pointerup", stopResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Profile edit ──────────────────────────────────────────────────────────
|
// ── Profile edit ──────────────────────────────────────────────────────────
|
||||||
let showEditProfile = $state(false);
|
let showEditProfile = $state(false);
|
||||||
let fProfileUsername = $state('');
|
let fProfileUsername = $state("");
|
||||||
let fProfileAvatar = $state('');
|
let fProfileAvatar = $state("");
|
||||||
let profileErr = $state('');
|
let profileErr = $state("");
|
||||||
|
|
||||||
function openEditProfile() {
|
function openEditProfile() {
|
||||||
fProfileUsername = user?.username ?? '';
|
fProfileUsername = user?.username ?? "";
|
||||||
fProfileAvatar = user?.avatar ?? '';
|
fProfileAvatar = user?.avatar ?? "";
|
||||||
profileErr = '';
|
profileErr = "";
|
||||||
showEditProfile = true;
|
showEditProfile = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitProfile() {
|
async function submitProfile() {
|
||||||
profileErr = '';
|
profileErr = "";
|
||||||
try {
|
try {
|
||||||
await onUpdateProfile({
|
await onUpdateProfile({
|
||||||
username: fProfileUsername.trim() || undefined,
|
username: fProfileUsername.trim() || undefined,
|
||||||
avatar: fProfileAvatar.trim() || undefined,
|
avatar: fProfileAvatar.trim() || undefined,
|
||||||
});
|
});
|
||||||
showEditProfile = false;
|
showEditProfile = false;
|
||||||
} catch (e) { profileErr = String(e); }
|
} catch (e) {
|
||||||
|
profileErr = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add contact ───────────────────────────────────────────────────────────
|
// ── Add contact ───────────────────────────────────────────────────────────
|
||||||
let showAddContact = $state(false);
|
let showAddContact = $state(false);
|
||||||
let fContactQuery = $state('');
|
let fContactQuery = $state("");
|
||||||
let searchResults = $state<UserSearchResult[]>([]);
|
let searchResults = $state<UserSearchResult[]>([]);
|
||||||
let searchBusy = $state(false);
|
let searchBusy = $state(false);
|
||||||
let contactErr = $state('');
|
let contactErr = $state("");
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
async function runUserSearch() {
|
async function runUserSearch() {
|
||||||
const query = fContactQuery.trim();
|
const query = fContactQuery.trim();
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
if (query.length < 2) return;
|
if (query.length < 2) return;
|
||||||
contactErr = '';
|
contactErr = "";
|
||||||
searchBusy = true;
|
searchBusy = true;
|
||||||
try {
|
try {
|
||||||
searchResults = await onSearchUsers(query);
|
searchResults = await onSearchUsers(query);
|
||||||
} catch (e) { contactErr = String(e); }
|
} catch (e) {
|
||||||
finally { searchBusy = false; }
|
contactErr = String(e);
|
||||||
|
} finally {
|
||||||
|
searchBusy = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleUserSearch() {
|
function scheduleUserSearch() {
|
||||||
@@ -148,39 +162,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitContact(userId: string) {
|
async function submitContact(userId: string) {
|
||||||
contactErr = '';
|
contactErr = "";
|
||||||
try {
|
try {
|
||||||
await onAddContact(userId);
|
await onAddContact(userId);
|
||||||
fContactQuery = '';
|
fContactQuery = "";
|
||||||
searchResults = [];
|
searchResults = [];
|
||||||
showAddContact = false;
|
showAddContact = false;
|
||||||
} catch (e) { contactErr = String(e); }
|
} catch (e) {
|
||||||
|
contactErr = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDm(userId: string) {
|
async function startDm(userId: string) {
|
||||||
contactErr = '';
|
contactErr = "";
|
||||||
try {
|
try {
|
||||||
await onStartDirectMessage(userId);
|
await onStartDirectMessage(userId);
|
||||||
showAddContact = false;
|
showAddContact = false;
|
||||||
} catch (e) { contactErr = String(e); }
|
} catch (e) {
|
||||||
|
contactErr = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function invite(userId: string) {
|
async function invite(userId: string) {
|
||||||
contactErr = '';
|
contactErr = "";
|
||||||
try {
|
try {
|
||||||
await onInviteToRoom(userId);
|
await onInviteToRoom(userId);
|
||||||
} catch (e) { contactErr = String(e); }
|
} catch (e) {
|
||||||
|
contactErr = String(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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 -->
|
<!-- Header -->
|
||||||
<div class="sidebar-head">
|
<div class="sidebar-head">
|
||||||
<span class="sidebar-brand">OXYDE</span>
|
<span class="sidebar-brand">OXYDE</span>
|
||||||
<button class="icon-btn" title="New room"
|
<button
|
||||||
onclick={() => { showNewRoom = !showNewRoom; }}>
|
class="icon-btn"
|
||||||
{showNewRoom ? '×' : '+'}
|
title="New room"
|
||||||
|
onclick={() => {
|
||||||
|
showNewRoom = !showNewRoom;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showNewRoom ? "×" : "+"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -188,12 +216,22 @@
|
|||||||
{#if showNewRoom}
|
{#if showNewRoom}
|
||||||
<div class="panel-form">
|
<div class="panel-form">
|
||||||
<div class="panel-title">new room</div>
|
<div class="panel-title">new room</div>
|
||||||
<input class="field-sm" placeholder="room name" bind:value={fRoom}
|
<input
|
||||||
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
|
class="field-sm"
|
||||||
|
placeholder="room name"
|
||||||
|
bind:value={fRoom}
|
||||||
|
onkeydown={(e) => e.key === "Enter" && onCreateRoom()}
|
||||||
|
/>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="segmented" aria-label="room visibility">
|
<div class="segmented" aria-label="room visibility">
|
||||||
<button class:active={fRoomKind === 'public'} onclick={() => fRoomKind = 'public'}>public</button>
|
<button
|
||||||
<button class:active={fRoomKind === 'private'} onclick={() => fRoomKind = 'private'}>private</button>
|
class:active={fRoomKind === "public"}
|
||||||
|
onclick={() => (fRoomKind = "public")}>public</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class:active={fRoomKind === "private"}
|
||||||
|
onclick={() => (fRoomKind = "private")}>private</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,14 +241,22 @@
|
|||||||
<!-- Rooms -->
|
<!-- Rooms -->
|
||||||
<div class="section-label">ROOMS</div>
|
<div class="section-label">ROOMS</div>
|
||||||
<nav class="room-list">
|
<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
|
<button
|
||||||
class="room-item"
|
class="room-item"
|
||||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
class:active={activeRoom &&
|
||||||
|
full(room.id) === full(activeRoom.id)}
|
||||||
onclick={() => onSelectRoom(room)}
|
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>
|
<span class="room-name">{roomLabel(room)}</span>
|
||||||
{#if unreadCounts[sid(room.id)]}
|
{#if unreadCounts[sid(room.id)]}
|
||||||
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
||||||
@@ -224,12 +270,20 @@
|
|||||||
<!-- Direct messages -->
|
<!-- Direct messages -->
|
||||||
<div class="section-label">DIRECT</div>
|
<div class="section-label">DIRECT</div>
|
||||||
<nav class="dm-list">
|
<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
|
<button
|
||||||
class="room-item"
|
class="room-item"
|
||||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
class:active={activeRoom &&
|
||||||
|
full(room.id) === full(activeRoom.id)}
|
||||||
onclick={() => onSelectRoom(room)}
|
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="hash">@</span>
|
||||||
<span class="room-name">{roomLabel(room)}</span>
|
<span class="room-name">{roomLabel(room)}</span>
|
||||||
@@ -245,36 +299,69 @@
|
|||||||
<!-- Contacts -->
|
<!-- Contacts -->
|
||||||
<div class="section-label-row">
|
<div class="section-label-row">
|
||||||
<span class="section-label">CONTACTS</span>
|
<span class="section-label">CONTACTS</span>
|
||||||
<button class="icon-btn" title="Add contact" onclick={() => { showAddContact = !showAddContact; }}>
|
<button
|
||||||
{showAddContact ? '×' : '+'}
|
class="icon-btn"
|
||||||
|
title="Add contact"
|
||||||
|
onclick={() => {
|
||||||
|
showAddContact = !showAddContact;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showAddContact ? "×" : "+"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if showAddContact}
|
{#if showAddContact}
|
||||||
<div class="panel-form">
|
<div class="panel-form">
|
||||||
<div class="panel-title">find people</div>
|
<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}
|
oninput={scheduleUserSearch}
|
||||||
onkeydown={(e) => e.key === 'Enter' && runUserSearch()} />
|
onkeydown={(e) => e.key === "Enter" && runUserSearch()}
|
||||||
|
/>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<span class="helper-text">2+ characters</span>
|
<span class="helper-text">2+ characters</span>
|
||||||
<button class="btn-xs" onclick={runUserSearch} disabled={searchBusy}>
|
<button
|
||||||
{searchBusy ? '...' : 'find'}
|
class="btn-xs"
|
||||||
|
onclick={runUserSearch}
|
||||||
|
disabled={searchBusy}
|
||||||
|
>
|
||||||
|
{searchBusy ? "..." : "find"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{#if searchResults.length > 0}
|
||||||
<div class="search-results">
|
<div class="search-results">
|
||||||
{#each searchResults as result (full(result.id))}
|
{#each searchResults as result (full(result.id))}
|
||||||
<div class="search-result">
|
<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>
|
<span class="contact-name">{result.username}</span>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
{#if activeRoom && activeRoom.kind !== 'direct'}
|
{#if activeRoom && activeRoom.kind !== "direct"}
|
||||||
<button class="mini-action" title="Invite" onclick={() => invite(sid(result.id))}>invite</button>
|
<button
|
||||||
|
class="mini-action"
|
||||||
|
title="Invite"
|
||||||
|
onclick={() => invite(sid(result.id))}
|
||||||
|
>invite</button
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="mini-action" title="Add contact" onclick={() => submitContact(sid(result.id))}>add</button>
|
<button
|
||||||
<button class="mini-action primary" title="Message" onclick={() => startDm(sid(result.id))}>msg</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>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -287,7 +374,10 @@
|
|||||||
<div class="contact-item">
|
<div class="contact-item">
|
||||||
<span class="presence online"></span>
|
<span class="presence online"></span>
|
||||||
<span class="contact-name">{c.username}</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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -296,29 +386,53 @@
|
|||||||
<!-- User footer -->
|
<!-- User footer -->
|
||||||
{#if showEditProfile}
|
{#if showEditProfile}
|
||||||
<div class="edit-profile-form">
|
<div class="edit-profile-form">
|
||||||
<input class="field-sm" placeholder="username" bind:value={fProfileUsername}
|
<input
|
||||||
onkeydown={(e) => e.key === 'Enter' && submitProfile()}
|
class="field-sm"
|
||||||
onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} />
|
placeholder="username"
|
||||||
<input class="field-sm" placeholder="avatar url (optional)" bind:value={fProfileAvatar}
|
bind:value={fProfileUsername}
|
||||||
onkeydown={(e) => e.key === 'Enter' && submitProfile()}
|
onkeydown={(e) => e.key === "Enter" && submitProfile()}
|
||||||
onkeyup={(e) => e.key === 'Escape' && (showEditProfile = false)} />
|
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}
|
{#if profileErr}<p class="form-err">{profileErr}</p>{/if}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<button class="btn-xs" onclick={submitProfile}>save</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="user-footer">
|
<div class="user-footer">
|
||||||
<button class="user-pill" title="Edit profile" onclick={openEditProfile}>
|
<button
|
||||||
<span class="avatar">{user?.username?.[0]?.toUpperCase() ?? '?'}</span>
|
class="user-pill"
|
||||||
<span class="user-name">{user?.username ?? ''}</span>
|
title="Edit profile"
|
||||||
|
onclick={openEditProfile}
|
||||||
|
>
|
||||||
|
<span class="avatar"
|
||||||
|
>{user?.username?.[0]?.toUpperCase() ?? "?"}</span
|
||||||
|
>
|
||||||
|
<span class="user-name">{user?.username ?? ""}</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="icon-btn signout" title="Sign out" onclick={onSignout}>
|
<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">
|
<svg
|
||||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
width="13"
|
||||||
<polyline points="16 17 21 12 16 7"/>
|
height="13"
|
||||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,7 +444,6 @@
|
|||||||
onpointerdown={startResize}
|
onpointerdown={startResize}
|
||||||
onkeydown={onResizeKey}
|
onkeydown={onResizeKey}
|
||||||
></button>
|
></button>
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -338,217 +451,427 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
background: var(--sidebar-bg);
|
background: var(--sidebar-bg);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
display: flex; flex-direction: column;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar.resizing,
|
.sidebar.resizing,
|
||||||
.sidebar.resizing * { cursor: col-resize; user-select: none; }
|
.sidebar.resizing * {
|
||||||
|
cursor: col-resize;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
.resize-handle {
|
.resize-handle {
|
||||||
position: absolute; top: 0; right: -3px; bottom: 0;
|
position: absolute;
|
||||||
width: 6px; cursor: col-resize; z-index: 4;
|
top: 0;
|
||||||
padding: 0; border: 0; background: transparent;
|
right: -3px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 4;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
.resize-handle::after {
|
.resize-handle::after {
|
||||||
content: ''; position: absolute; top: 0; right: 2px; bottom: 0;
|
content: "";
|
||||||
width: 1px; background: transparent;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 2px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 1px;
|
||||||
|
background: transparent;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.resize-handle:hover::after,
|
.resize-handle:hover::after,
|
||||||
.resize-handle:focus-visible::after,
|
.resize-handle:focus-visible::after,
|
||||||
.sidebar.resizing .resize-handle::after { background: var(--accent); }
|
.sidebar.resizing .resize-handle::after {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
.sidebar-head {
|
.sidebar-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 16px 14px 14px;
|
padding: 16px 14px 14px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
.sidebar-brand {
|
.sidebar-brand {
|
||||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
font-family: "Cormorant Garamond", Georgia, serif;
|
||||||
font-size: 17px; font-weight: 700;
|
font-size: 17px;
|
||||||
color: var(--accent); letter-spacing: 0.2em;
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 0.2em;
|
||||||
}
|
}
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 22px; height: 22px;
|
width: 22px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
height: 22px;
|
||||||
background: none; border: 1px solid var(--border);
|
display: flex;
|
||||||
border-radius: var(--r); color: var(--muted);
|
align-items: center;
|
||||||
font-size: 15px; line-height: 1;
|
justify-content: center;
|
||||||
cursor: pointer; transition: border-color 0.12s, color 0.12s;
|
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;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
.icon-btn:hover {
|
||||||
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.icon-btn.signout:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.panel-form {
|
.panel-form {
|
||||||
display: flex; flex-direction: column; gap: 7px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
padding: 10px 12px 11px;
|
padding: 10px 12px 11px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
animation: rise 0.15s ease;
|
animation: rise 0.15s ease;
|
||||||
}
|
}
|
||||||
.panel-title {
|
.panel-title {
|
||||||
font-size: 9px; letter-spacing: 0.14em;
|
font-size: 9px;
|
||||||
color: var(--muted); text-transform: uppercase;
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.field-sm {
|
.field-sm {
|
||||||
flex: 1; padding: 6px 10px;
|
flex: 1;
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
padding: 6px 10px;
|
||||||
border-radius: var(--r); color: var(--text);
|
background: var(--bg);
|
||||||
font-family: inherit; font-size: 11px; outline: none;
|
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;
|
transition: border-color 0.12s;
|
||||||
}
|
}
|
||||||
.field-sm:focus { border-color: var(--accent); }
|
.field-sm:focus {
|
||||||
.field-sm::placeholder { color: var(--muted); }
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.field-sm::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
.btn-xs {
|
.btn-xs {
|
||||||
padding: 6px 10px; flex-shrink: 0;
|
padding: 6px 10px;
|
||||||
background: var(--accent); border: none;
|
flex-shrink: 0;
|
||||||
border-radius: var(--r); color: #fff;
|
background: var(--accent);
|
||||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
border: none;
|
||||||
|
border-radius: var(--r);
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
transition: opacity 0.12s;
|
transition: opacity 0.12s;
|
||||||
}
|
}
|
||||||
.btn-xs:hover { opacity: 0.82; }
|
.btn-xs:hover {
|
||||||
.btn-xs:disabled { opacity: 0.45; cursor: wait; }
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
.btn-xs:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
.form-row {
|
.form-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
gap: 7px; min-width: 0;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.helper-text {
|
.helper-text {
|
||||||
color: var(--muted); font-size: 10px;
|
color: var(--muted);
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.segmented {
|
.segmented {
|
||||||
display: flex; min-width: 0;
|
display: flex;
|
||||||
border: 1px solid var(--border); border-radius: var(--r);
|
min-width: 0;
|
||||||
overflow: hidden; background: var(--bg);
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
.segmented button {
|
.segmented button {
|
||||||
padding: 5px 8px; background: transparent; border: none;
|
padding: 5px 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
border-right: 1px solid var(--border);
|
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;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.segmented button:last-child { border-right: 0; }
|
.segmented button:last-child {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
.segmented button.active {
|
.segmented button.active {
|
||||||
background: var(--accent-soft); color: var(--accent);
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
padding: 14px 14px 5px;
|
padding: 14px 14px 5px;
|
||||||
font-size: 9px; letter-spacing: 0.14em;
|
font-size: 9px;
|
||||||
color: var(--muted); font-weight: 500;
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-list { flex: 1; min-height: 70px; overflow-y: auto; padding: 3px 8px; }
|
.room-list {
|
||||||
.dm-list { max-height: 28%; overflow-y: auto; padding: 3px 8px; flex-shrink: 0; }
|
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,
|
.room-list::-webkit-scrollbar,
|
||||||
.dm-list::-webkit-scrollbar { width: 0; }
|
.dm-list::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.room-item {
|
.room-item {
|
||||||
display: flex; align-items: center; gap: 5px;
|
display: flex;
|
||||||
width: 100%; padding: 5px 7px; margin-bottom: 1px;
|
align-items: center;
|
||||||
background: none; border: none;
|
gap: 5px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 7px;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
border-radius: 0 var(--r) var(--r) 0;
|
border-radius: 0 var(--r) var(--r) 0;
|
||||||
color: var(--muted); font-family: inherit; font-size: 13px;
|
color: var(--muted);
|
||||||
cursor: pointer; text-align: left; transition: all 0.1s;
|
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 {
|
.room-item.active {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
border-left-color: var(--accent);
|
border-left-color: var(--accent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
|
.hash {
|
||||||
.room-item.active .hash { color: var(--accent); }
|
color: var(--muted);
|
||||||
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
font-size: 14px;
|
||||||
.unread {
|
flex-shrink: 0;
|
||||||
min-width: 18px; height: 18px; margin-left: auto;
|
}
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
.room-item.active .hash {
|
||||||
border-radius: var(--r); background: var(--accent); color: #fff;
|
color: var(--accent);
|
||||||
font-size: 10px; padding: 0 5px;
|
}
|
||||||
|
.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 {
|
.contact-item {
|
||||||
display: flex; align-items: center; gap: 7px;
|
display: flex;
|
||||||
padding: 5px 7px; color: var(--muted); font-size: 12px;
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 5px 7px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
}
|
}
|
||||||
.contact-item:hover { background: var(--surface); color: var(--text-2); }
|
.contact-item:hover {
|
||||||
.contact-action { margin-left: auto; }
|
background: var(--surface);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.contact-action {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
.search-results {
|
.search-results {
|
||||||
padding: 4px 8px 8px;
|
padding: 4px 8px 8px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
.search-result {
|
.search-result {
|
||||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
|
display: grid;
|
||||||
align-items: center; gap: 7px;
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
padding: 5px 4px; color: var(--text-2); font-size: 11px;
|
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 {
|
.mini-action {
|
||||||
padding: 3px 6px; background: transparent;
|
padding: 3px 6px;
|
||||||
border: 1px solid var(--border); border-radius: var(--r);
|
background: transparent;
|
||||||
color: var(--muted); font-family: inherit; font-size: 10px;
|
border: 1px solid var(--border);
|
||||||
cursor: pointer; white-space: nowrap;
|
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 {
|
.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);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.presence {
|
.presence {
|
||||||
width: 6px; height: 6px; border-radius: 50%;
|
width: 6px;
|
||||||
background: var(--muted); flex-shrink: 0;
|
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 {
|
.user-footer {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--surface); margin-top: auto;
|
background: var(--surface);
|
||||||
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.user-pill {
|
.user-pill {
|
||||||
display: flex; align-items: center; gap: 8px; min-width: 0;
|
display: flex;
|
||||||
background: none; border: none; cursor: pointer; padding: 0;
|
align-items: center;
|
||||||
font-family: inherit; text-align: left;
|
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 {
|
.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;
|
padding: 14px 14px 5px 14px;
|
||||||
}
|
}
|
||||||
.section-label-row .section-label { padding: 0; }
|
.section-label-row .section-label {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-profile-form {
|
.edit-profile-form {
|
||||||
display: flex; flex-direction: column; gap: 6px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-top: 1px solid var(--border-subtle);
|
border-top: 1px solid var(--border-subtle);
|
||||||
animation: rise 0.15s ease;
|
animation: rise 0.15s ease;
|
||||||
}
|
}
|
||||||
.btn-ghost {
|
.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 {
|
.form-err {
|
||||||
font-size: 10px; color: var(--danger);
|
font-size: 10px;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
color: var(--danger);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 26px; height: 26px; flex-shrink: 0;
|
width: 26px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
height: 26px;
|
||||||
background: var(--accent); border-radius: var(--r);
|
flex-shrink: 0;
|
||||||
color: #fff; font-size: 11px; font-weight: 600;
|
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 {
|
.user-name {
|
||||||
font-size: 12px; color: var(--text-2);
|
font-size: 12px;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
color: var(--text-2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
// 3.x format: { table: "user", key: { String: "abc" } }
|
// 3.x format: { table: "user", key: { String: "abc" } }
|
||||||
// 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat)
|
// 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat)
|
||||||
export function sid(thing: any): string {
|
export function sid(thing: any): string {
|
||||||
if (!thing) return '';
|
if (!thing) return "";
|
||||||
if (typeof thing === 'string') {
|
if (typeof thing === "string") {
|
||||||
const i = thing.indexOf(':');
|
const i = thing.indexOf(":");
|
||||||
const id = i >= 0 ? thing.slice(i + 1) : thing;
|
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)
|
// 3.x: key field (may be nested variant or plain string)
|
||||||
const key = thing?.key ?? thing?.id;
|
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?.String) return key.String;
|
||||||
if (key?.Uuid) return key.Uuid;
|
if (key?.Uuid) return key.Uuid;
|
||||||
if (key?.Number !== undefined) return String(key.Number);
|
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
|
// Return canonical "table:id" string for equality checks
|
||||||
export function full(thing: any): string {
|
export function full(thing: any): string {
|
||||||
if (typeof thing === 'string') return thing;
|
if (typeof thing === "string") return thing;
|
||||||
const table = thing?.table ?? thing?.tb ?? '';
|
const table = thing?.table ?? thing?.tb ?? "";
|
||||||
return `${table}:${sid(thing)}`;
|
return `${table}:${sid(thing)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmt(ts: string): string {
|
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> {
|
export async function cmd<T>(
|
||||||
const { invoke } = await import('@tauri-apps/api/core');
|
name: string,
|
||||||
|
args?: Record<string, unknown>,
|
||||||
|
): Promise<T> {
|
||||||
|
const { invoke } = await import("@tauri-apps/api/core");
|
||||||
return invoke<T>(name, args);
|
return invoke<T>(name, args);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,65 @@
|
|||||||
export interface User { id: any; username: string; email?: string; avatar?: string; created?: string; }
|
export interface User {
|
||||||
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; }
|
id: any;
|
||||||
export interface RoomMember { id: any; room: any; user: any; role: 'owner' | 'member'; joined: string; last_read_at?: string; muted?: boolean; }
|
username: string;
|
||||||
export interface MessageReactionSummary { emoji: string; count: number; reacted_by_me: boolean; }
|
email?: string;
|
||||||
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[]; }
|
avatar?: string;
|
||||||
export interface UserSearchResult { id: any; username: string; avatar?: string; }
|
created?: string;
|
||||||
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
}
|
||||||
export interface ContextMenuItem { label: string; action: () => void; }
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,7 +154,9 @@
|
|||||||
replyTo = null;
|
replyTo = null;
|
||||||
|
|
||||||
if (previousSubId) {
|
if (previousSubId) {
|
||||||
await cmd("unsubscribe_room", { subId: previousSubId }).catch(() => {});
|
await cmd("unsubscribe_room", { subId: previousSubId }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (previousUnlisten) {
|
if (previousUnlisten) {
|
||||||
previousUnlisten();
|
previousUnlisten();
|
||||||
@@ -186,7 +188,9 @@
|
|||||||
}
|
}
|
||||||
subId = nextSubId;
|
subId = nextSubId;
|
||||||
const { listen } = await import("@tauri-apps/api/event");
|
const { listen } = await import("@tauri-apps/api/event");
|
||||||
const nextUnlisten = await listen<LiveEvent>("chat:message", ({ payload }) => {
|
const nextUnlisten = 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) : "";
|
||||||
@@ -200,27 +204,37 @@
|
|||||||
Notification.permission === "granted" &&
|
Notification.permission === "granted" &&
|
||||||
document.hidden
|
document.hidden
|
||||||
) {
|
) {
|
||||||
new Notification(data.author_username ?? "New message", {
|
new Notification(
|
||||||
|
data.author_username ?? "New message",
|
||||||
|
{
|
||||||
body: data.body || "New message",
|
body: data.body || "New message",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === "Create") {
|
if (action === "Create") {
|
||||||
messages = [...messages, data];
|
messages = [...messages, data];
|
||||||
} else if (action === "Delete") {
|
} 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") {
|
} else if (action === "Update") {
|
||||||
messages = messages.map((m) =>
|
messages = messages.map((m) =>
|
||||||
full(m.id) === full(data.id) ? data : 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)) {
|
if (!isCurrentRoomSelection(token, roomId)) {
|
||||||
nextUnlisten();
|
nextUnlisten();
|
||||||
if (subId === nextSubId) {
|
if (subId === nextSubId) {
|
||||||
await cmd("unsubscribe_room", { subId: nextSubId }).catch(() => {});
|
await cmd("unsubscribe_room", { subId: nextSubId }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
subId = null;
|
subId = null;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
Reference in New Issue
Block a user