Some changes codex made that I want to test how they work
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-brand">OXYDE</h1>
|
||||
<p class="auth-tagline">encrypted · realtime · distributed</p>
|
||||
<p class="auth-tagline">realtime · native · focused</p>
|
||||
|
||||
{#if err}
|
||||
<div class="err-banner">{err}</div>
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
messages: Message[];
|
||||
user: User | null;
|
||||
err: string;
|
||||
hasOlderMessages: boolean;
|
||||
isLoadingOlder: boolean;
|
||||
fMsg: string;
|
||||
replyTo: Message | null;
|
||||
onLoadOlderMessages: () => void;
|
||||
onSendMessage: () => void;
|
||||
onDeleteMessage: (msgId: string) => void;
|
||||
onEditMessage: (msgId: string, body: string) => void;
|
||||
onToggleReaction: (msgId: string, emoji: string) => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
}
|
||||
|
||||
@@ -19,14 +25,22 @@
|
||||
messages,
|
||||
user,
|
||||
err,
|
||||
hasOlderMessages,
|
||||
isLoadingOlder,
|
||||
fMsg = $bindable(),
|
||||
replyTo = $bindable(),
|
||||
onLoadOlderMessages,
|
||||
onSendMessage,
|
||||
onDeleteMessage,
|
||||
onEditMessage,
|
||||
onToggleReaction,
|
||||
onShowMenu,
|
||||
}: Props = $props();
|
||||
|
||||
let msgEl: HTMLElement;
|
||||
let inputEl: HTMLTextAreaElement;
|
||||
let editingId = $state<string | null>(null);
|
||||
let editBody = $state('');
|
||||
|
||||
function scrollBottom() {
|
||||
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
||||
@@ -42,11 +56,34 @@
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); }
|
||||
}
|
||||
|
||||
function roomLabel(room: Room | null): string {
|
||||
if (!room) return 'select a room';
|
||||
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
||||
return room.name ?? 'untitled';
|
||||
}
|
||||
|
||||
function isGrouped(i: number): boolean {
|
||||
if (i === 0) return false;
|
||||
if (messages[i].deleted || messages[i - 1].deleted) return false;
|
||||
return full(messages[i].author) === full(messages[i - 1].author);
|
||||
}
|
||||
|
||||
function beginEdit(msg: Message) {
|
||||
editingId = full(msg.id);
|
||||
editBody = msg.body;
|
||||
}
|
||||
|
||||
function submitEdit(msg: Message) {
|
||||
if (!editBody.trim()) return;
|
||||
onEditMessage(full(msg.id), editBody.trim());
|
||||
editingId = null;
|
||||
editBody = '';
|
||||
}
|
||||
|
||||
function quickReact(msg: Message) {
|
||||
onToggleReaction(full(msg.id), '+1');
|
||||
}
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
messages.length; // track length
|
||||
@@ -63,8 +100,8 @@
|
||||
|
||||
<!-- Channel header -->
|
||||
<header class="channel-header">
|
||||
<span class="ch-hash">#</span>
|
||||
<span class="ch-name">{activeRoom?.name ?? 'select a room'}</span>
|
||||
<span class="ch-hash">{activeRoom?.kind === 'direct' ? '@' : '#'}</span>
|
||||
<span class="ch-name">{roomLabel(activeRoom)}</span>
|
||||
{#if err}<span class="header-err">{err}</span>{/if}
|
||||
</header>
|
||||
|
||||
@@ -81,15 +118,24 @@
|
||||
<p>no messages yet — say hello</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if hasOlderMessages}
|
||||
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
|
||||
{isLoadingOlder ? 'loading...' : 'load older messages'}
|
||||
</button>
|
||||
{/if}
|
||||
{#each messages as msg, i (full(msg.id))}
|
||||
<div
|
||||
class="msg"
|
||||
class:grouped={isGrouped(i)}
|
||||
role="listitem"
|
||||
oncontextmenu={(e) => {
|
||||
const items: ContextMenuItem[] = [
|
||||
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
|
||||
{ label: 'Reply', action: () => replyTo = msg },
|
||||
{ label: 'React +1', action: () => onToggleReaction(full(msg.id), '+1') },
|
||||
];
|
||||
if (user && full(msg.author) === full(user.id)) {
|
||||
if (user && full(msg.author) === full(user.id) && !msg.deleted) {
|
||||
items.push({ label: 'Edit message', action: () => beginEdit(msg) });
|
||||
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
|
||||
}
|
||||
onShowMenu(e, items);
|
||||
@@ -99,26 +145,70 @@
|
||||
<div class="msg-header">
|
||||
<span
|
||||
class="msg-author"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
|
||||
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
|
||||
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
|
||||
]); }}
|
||||
>{msg.author_username ?? sid(msg.author)}</span>
|
||||
<span class="msg-time">{fmt(msg.created)}</span>
|
||||
{#if msg.updated}<span class="msg-time">edited</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.reply_to}
|
||||
<div class="reply-chip">replying to {sid(msg.reply_to)}</div>
|
||||
{/if}
|
||||
{#if !msg.deleted}
|
||||
<div class="msg-actions" aria-label="message actions">
|
||||
<button title="Reply" onclick={() => replyTo = msg}>reply</button>
|
||||
<button title="React" onclick={() => quickReact(msg)}>+1</button>
|
||||
{#if user && full(msg.author) === full(user.id)}
|
||||
<button title="Edit" onclick={() => beginEdit(msg)}>edit</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if msg.deleted}
|
||||
<p class="msg-body deleted">message deleted</p>
|
||||
{:else if editingId === full(msg.id)}
|
||||
<div class="edit-row">
|
||||
<textarea class="edit-input" bind:value={editBody} rows="2"></textarea>
|
||||
<button class="mini-btn" onclick={() => submitEdit(msg)}>save</button>
|
||||
<button class="mini-btn ghost" onclick={() => editingId = null}>cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="msg-body">{msg.body}</p>
|
||||
{/if}
|
||||
{#if msg.reactions?.length}
|
||||
<div class="reactions">
|
||||
{#each msg.reactions as reaction}
|
||||
<button
|
||||
class="reaction"
|
||||
class:mine={reaction.reacted_by_me}
|
||||
onclick={() => onToggleReaction(full(msg.id), reaction.emoji)}
|
||||
>
|
||||
{reaction.emoji} {reaction.count}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="msg-body">{msg.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input bar -->
|
||||
{#if replyTo}
|
||||
<div class="reply-bar">
|
||||
<span>replying to {replyTo.author_username ?? sid(replyTo.author)}</span>
|
||||
<button class="mini-btn ghost" onclick={() => replyTo = null}>cancel</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
class="msg-input"
|
||||
placeholder={activeRoom ? `message #${activeRoom.name}` : 'select a room first'}
|
||||
placeholder={activeRoom ? `message ${activeRoom.kind === 'direct' ? '@' : '#'}${roomLabel(activeRoom)}` : 'select a room first'}
|
||||
bind:value={fMsg}
|
||||
onkeydown={onKey}
|
||||
oninput={autoResize}
|
||||
@@ -162,6 +252,14 @@
|
||||
.messages::-webkit-scrollbar { width: 4px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; }
|
||||
.messages::-webkit-scrollbar-track { background: transparent; }
|
||||
.load-older {
|
||||
align-self: center; margin-bottom: 12px; padding: 6px 10px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--r); color: var(--text-2);
|
||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.load-older:hover { border-color: var(--accent); color: var(--text); }
|
||||
.load-older:disabled { opacity: 0.5; cursor: wait; }
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
@@ -175,6 +273,8 @@
|
||||
.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 {
|
||||
@@ -190,6 +290,51 @@
|
||||
animation: msgIn 0.14s ease;
|
||||
}
|
||||
.msg.grouped .msg-body { color: var(--text-2); }
|
||||
.msg-body.deleted { color: var(--muted); font-style: italic; }
|
||||
.msg-actions {
|
||||
float: right; display: flex; gap: 4px; margin-left: 8px;
|
||||
opacity: 0; pointer-events: none; transition: opacity 0.1s;
|
||||
}
|
||||
.msg-actions button {
|
||||
padding: 2px 5px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--muted); font-family: inherit; font-size: 9.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.msg-actions button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.reply-chip {
|
||||
display: inline-flex; margin: 2px 0 3px; padding: 3px 6px;
|
||||
border-left: 2px solid var(--accent); background: var(--surface);
|
||||
color: var(--muted); font-size: 10px;
|
||||
}
|
||||
.edit-row {
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.edit-input {
|
||||
flex: 1; resize: vertical; min-height: 44px; max-height: 120px;
|
||||
padding: 7px 9px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 12px;
|
||||
}
|
||||
.mini-btn {
|
||||
padding: 6px 8px; background: var(--accent); border: none;
|
||||
border-radius: var(--r); color: #fff; font-family: inherit;
|
||||
font-size: 10px; cursor: pointer;
|
||||
}
|
||||
.mini-btn.ghost {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
.reactions {
|
||||
display: flex; gap: 5px; margin-top: 4px; flex-wrap: wrap;
|
||||
}
|
||||
.reaction {
|
||||
padding: 2px 6px; background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text-2); font-family: inherit; font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.reaction.mine { border-color: var(--accent); color: var(--accent); }
|
||||
@keyframes msgIn {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -201,6 +346,11 @@
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.reply-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 8px 24px; border-top: 1px solid var(--border);
|
||||
color: var(--text-2); font-size: 11px; background: var(--surface);
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1; resize: none;
|
||||
padding: 9px 13px;
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
bind:this={menuEl}
|
||||
style="left:{x}px; top:{y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
role="menu"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { User, Room, ContextMenuItem } from '$lib/types';
|
||||
import { full } from '$lib/helpers';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { User, Room, ContextMenuItem, UserSearchResult } from '$lib/types';
|
||||
import { full, sid } from '$lib/helpers';
|
||||
|
||||
interface Props {
|
||||
user: User | null;
|
||||
@@ -9,12 +10,17 @@
|
||||
activeRoom: Room | null;
|
||||
showNewRoom: boolean;
|
||||
fRoom: string;
|
||||
fRoomKind: 'public' | 'private';
|
||||
unreadCounts: Record<string, number>;
|
||||
onSelectRoom: (room: Room) => void;
|
||||
onCreateRoom: () => void;
|
||||
onSignout: () => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
onUpdateProfile: (fields: { username?: string; avatar?: string }) => Promise<void>;
|
||||
onAddContact: (userId: string) => Promise<void>;
|
||||
onSearchUsers: (query: string) => Promise<UserSearchResult[]>;
|
||||
onStartDirectMessage: (userId: string) => Promise<void>;
|
||||
onInviteToRoom: (userId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -24,14 +30,74 @@
|
||||
activeRoom,
|
||||
showNewRoom = $bindable(),
|
||||
fRoom = $bindable(),
|
||||
fRoomKind = $bindable(),
|
||||
unreadCounts,
|
||||
onSelectRoom,
|
||||
onCreateRoom,
|
||||
onSignout,
|
||||
onShowMenu,
|
||||
onUpdateProfile,
|
||||
onAddContact,
|
||||
onSearchUsers,
|
||||
onStartDirectMessage,
|
||||
onInviteToRoom,
|
||||
}: Props = $props();
|
||||
|
||||
function roomLabel(room: Room): string {
|
||||
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message';
|
||||
return room.name ?? 'untitled';
|
||||
}
|
||||
|
||||
const minSidebarWidth = 228;
|
||||
const maxSidebarWidth = 440;
|
||||
let sidebarWidth = $state(282);
|
||||
let resizing = $state(false);
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(maxSidebarWidth, Math.max(minSidebarWidth, width));
|
||||
}
|
||||
|
||||
function setSidebarWidth(width: number) {
|
||||
sidebarWidth = clampSidebarWidth(width);
|
||||
localStorage.setItem('oxyde.sidebarWidth', String(sidebarWidth));
|
||||
}
|
||||
|
||||
function onResizeMove(e: PointerEvent) {
|
||||
if (!resizing) return;
|
||||
setSidebarWidth(e.clientX);
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
resizing = false;
|
||||
}
|
||||
|
||||
function startResize(e: PointerEvent) {
|
||||
resizing = true;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function onResizeKey(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
setSidebarWidth(sidebarWidth - 16);
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
setSidebarWidth(sidebarWidth + 16);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stored = Number(localStorage.getItem('oxyde.sidebarWidth'));
|
||||
if (Number.isFinite(stored)) sidebarWidth = clampSidebarWidth(stored);
|
||||
window.addEventListener('pointermove', onResizeMove);
|
||||
window.addEventListener('pointerup', stopResize);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('pointermove', onResizeMove);
|
||||
window.removeEventListener('pointerup', stopResize);
|
||||
});
|
||||
|
||||
// ── Profile edit ──────────────────────────────────────────────────────────
|
||||
let showEditProfile = $state(false);
|
||||
let fProfileUsername = $state('');
|
||||
@@ -58,21 +124,56 @@
|
||||
|
||||
// ── Add contact ───────────────────────────────────────────────────────────
|
||||
let showAddContact = $state(false);
|
||||
let fContactId = $state('');
|
||||
let fContactQuery = $state('');
|
||||
let searchResults = $state<UserSearchResult[]>([]);
|
||||
let searchBusy = $state(false);
|
||||
let contactErr = $state('');
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async function submitContact() {
|
||||
if (!fContactId.trim()) return;
|
||||
async function runUserSearch() {
|
||||
const query = fContactQuery.trim();
|
||||
searchResults = [];
|
||||
if (query.length < 2) return;
|
||||
contactErr = '';
|
||||
searchBusy = true;
|
||||
try {
|
||||
searchResults = await onSearchUsers(query);
|
||||
} catch (e) { contactErr = String(e); }
|
||||
finally { searchBusy = false; }
|
||||
}
|
||||
|
||||
function scheduleUserSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runUserSearch, 220);
|
||||
}
|
||||
|
||||
async function submitContact(userId: string) {
|
||||
contactErr = '';
|
||||
try {
|
||||
await onAddContact(fContactId.trim());
|
||||
fContactId = '';
|
||||
await onAddContact(userId);
|
||||
fContactQuery = '';
|
||||
searchResults = [];
|
||||
showAddContact = false;
|
||||
} catch (e) { contactErr = String(e); }
|
||||
}
|
||||
|
||||
async function startDm(userId: string) {
|
||||
contactErr = '';
|
||||
try {
|
||||
await onStartDirectMessage(userId);
|
||||
showAddContact = false;
|
||||
} catch (e) { contactErr = String(e); }
|
||||
}
|
||||
|
||||
async function invite(userId: string) {
|
||||
contactErr = '';
|
||||
try {
|
||||
await onInviteToRoom(userId);
|
||||
} catch (e) { contactErr = String(e); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
<aside class="sidebar" class:resizing style="width:{sidebarWidth}px; min-width:{sidebarWidth}px">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="sidebar-head">
|
||||
@@ -85,31 +186,62 @@
|
||||
|
||||
<!-- New room form -->
|
||||
{#if showNewRoom}
|
||||
<div class="new-room-form">
|
||||
<div class="panel-form">
|
||||
<div class="panel-title">new room</div>
|
||||
<input class="field-sm" placeholder="room name" bind:value={fRoom}
|
||||
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
|
||||
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
||||
<div class="form-row">
|
||||
<div class="segmented" aria-label="room visibility">
|
||||
<button class:active={fRoomKind === 'public'} onclick={() => fRoomKind = 'public'}>public</button>
|
||||
<button class:active={fRoomKind === 'private'} onclick={() => fRoomKind = 'private'}>private</button>
|
||||
</div>
|
||||
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rooms -->
|
||||
<div class="section-label">ROOMS</div>
|
||||
<nav class="room-list">
|
||||
{#each rooms as room (full(room.id))}
|
||||
{#each rooms.filter((room) => room.kind !== 'direct') as room (full(room.id))}
|
||||
<button
|
||||
class="room-item"
|
||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
||||
onclick={() => onSelectRoom(room)}
|
||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(room.name) }])}
|
||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
|
||||
>
|
||||
<span class="hash">#</span>
|
||||
<span class="room-name">{room.name}</span>
|
||||
<span class="hash">{room.kind === 'direct' ? '@' : '#'}</span>
|
||||
<span class="room-name">{roomLabel(room)}</span>
|
||||
{#if unreadCounts[sid(room.id)]}
|
||||
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="list-empty">no rooms — create one above</p>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Direct messages -->
|
||||
<div class="section-label">DIRECT</div>
|
||||
<nav class="dm-list">
|
||||
{#each rooms.filter((room) => room.kind === 'direct') as room (full(room.id))}
|
||||
<button
|
||||
class="room-item"
|
||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
||||
onclick={() => onSelectRoom(room)}
|
||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy name', action: () => navigator.clipboard.writeText(roomLabel(room)) }])}
|
||||
>
|
||||
<span class="hash">@</span>
|
||||
<span class="room-name">{roomLabel(room)}</span>
|
||||
{#if unreadCounts[sid(room.id)]}
|
||||
<span class="unread">{unreadCounts[sid(room.id)]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="list-empty">no direct messages</p>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div class="section-label-row">
|
||||
<span class="section-label">CONTACTS</span>
|
||||
@@ -118,19 +250,44 @@
|
||||
</button>
|
||||
</div>
|
||||
{#if showAddContact}
|
||||
<div class="new-room-form">
|
||||
<input class="field-sm" placeholder="user id" bind:value={fContactId}
|
||||
onkeydown={(e) => e.key === 'Enter' && submitContact()} />
|
||||
<button class="btn-xs" onclick={submitContact}>add</button>
|
||||
<div class="panel-form">
|
||||
<div class="panel-title">find people</div>
|
||||
<input class="field-sm" placeholder="search username" bind:value={fContactQuery}
|
||||
oninput={scheduleUserSearch}
|
||||
onkeydown={(e) => e.key === 'Enter' && runUserSearch()} />
|
||||
<div class="form-row">
|
||||
<span class="helper-text">2+ characters</span>
|
||||
<button class="btn-xs" onclick={runUserSearch} disabled={searchBusy}>
|
||||
{searchBusy ? '...' : 'find'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if contactErr}<p class="form-err" style="padding: 0 12px 6px">{contactErr}</p>{/if}
|
||||
{#if searchResults.length > 0}
|
||||
<div class="search-results">
|
||||
{#each searchResults as result (full(result.id))}
|
||||
<div class="search-result">
|
||||
<span class="avatar mini">{result.username[0]?.toUpperCase() ?? '?'}</span>
|
||||
<span class="contact-name">{result.username}</span>
|
||||
<div class="row-actions">
|
||||
{#if activeRoom && activeRoom.kind !== 'direct'}
|
||||
<button class="mini-action" title="Invite" onclick={() => invite(sid(result.id))}>invite</button>
|
||||
{/if}
|
||||
<button class="mini-action" title="Add contact" onclick={() => submitContact(sid(result.id))}>add</button>
|
||||
<button class="mini-action primary" title="Message" onclick={() => startDm(sid(result.id))}>msg</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if contacts.length > 0}
|
||||
<div class="contact-list">
|
||||
{#each contacts as c}
|
||||
{#each contacts as c (full(c.id))}
|
||||
<div class="contact-item">
|
||||
<span class="presence online"></span>
|
||||
<span class="contact-name">{c.username}</span>
|
||||
<button class="mini-action contact-action" onclick={() => startDm(sid(c.id))}>msg</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -166,16 +323,39 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="resize-handle"
|
||||
aria-label="Resize sidebar"
|
||||
title="Resize sidebar"
|
||||
onpointerdown={startResize}
|
||||
onkeydown={onResizeKey}
|
||||
></button>
|
||||
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 282px; min-width: 282px;
|
||||
position: relative;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar.resizing,
|
||||
.sidebar.resizing * { cursor: col-resize; user-select: none; }
|
||||
.resize-handle {
|
||||
position: absolute; top: 0; right: -3px; bottom: 0;
|
||||
width: 6px; cursor: col-resize; z-index: 4;
|
||||
padding: 0; border: 0; background: transparent;
|
||||
}
|
||||
.resize-handle::after {
|
||||
content: ''; position: absolute; top: 0; right: 2px; bottom: 0;
|
||||
width: 1px; background: transparent;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.resize-handle:hover::after,
|
||||
.resize-handle:focus-visible::after,
|
||||
.sidebar.resizing .resize-handle::after { background: var(--accent); }
|
||||
.sidebar-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 14px 14px;
|
||||
@@ -198,12 +378,16 @@
|
||||
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
.new-room-form {
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
padding: 10px 12px;
|
||||
.panel-form {
|
||||
display: flex; flex-direction: column; gap: 7px;
|
||||
padding: 10px 12px 11px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
.panel-title {
|
||||
font-size: 9px; letter-spacing: 0.14em;
|
||||
color: var(--muted); text-transform: uppercase;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -225,6 +409,30 @@
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.btn-xs:hover { opacity: 0.82; }
|
||||
.btn-xs:disabled { opacity: 0.45; cursor: wait; }
|
||||
.form-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 7px; min-width: 0;
|
||||
}
|
||||
.helper-text {
|
||||
color: var(--muted); font-size: 10px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.segmented {
|
||||
display: flex; min-width: 0;
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
overflow: hidden; background: var(--bg);
|
||||
}
|
||||
.segmented button {
|
||||
padding: 5px 8px; background: transparent; border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--muted); font-family: inherit; font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.segmented button:last-child { border-right: 0; }
|
||||
.segmented button.active {
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 14px 14px 5px;
|
||||
@@ -232,8 +440,10 @@
|
||||
color: var(--muted); font-weight: 500;
|
||||
}
|
||||
|
||||
.room-list { flex: 1; overflow-y: auto; padding: 3px 8px; }
|
||||
.room-list::-webkit-scrollbar { width: 0; }
|
||||
.room-list { flex: 1; min-height: 70px; overflow-y: auto; padding: 3px 8px; }
|
||||
.dm-list { max-height: 28%; overflow-y: auto; padding: 3px 8px; flex-shrink: 0; }
|
||||
.room-list::-webkit-scrollbar,
|
||||
.dm-list::-webkit-scrollbar { width: 0; }
|
||||
|
||||
.room-item {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
@@ -253,12 +463,42 @@
|
||||
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
|
||||
.room-item.active .hash { color: var(--accent); }
|
||||
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.unread {
|
||||
min-width: 18px; height: 18px; margin-left: auto;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--r); background: var(--accent); color: #fff;
|
||||
font-size: 10px; padding: 0 5px;
|
||||
}
|
||||
.list-empty { padding: 8px 7px; color: var(--muted); font-size: 10.5px; }
|
||||
|
||||
.contact-list { padding: 3px 8px; }
|
||||
.contact-list { padding: 3px 8px; max-height: 24%; overflow-y: auto; flex-shrink: 0; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 5px 7px; color: var(--muted); font-size: 12px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.contact-item:hover { background: var(--surface); color: var(--text-2); }
|
||||
.contact-action { margin-left: auto; }
|
||||
.search-results {
|
||||
padding: 4px 8px 8px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.search-result {
|
||||
display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center; gap: 7px;
|
||||
padding: 5px 4px; color: var(--text-2); font-size: 11px;
|
||||
}
|
||||
.row-actions { display: flex; gap: 4px; justify-content: flex-end; }
|
||||
.mini-action {
|
||||
padding: 3px 6px; background: transparent;
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--muted); font-family: inherit; font-size: 10px;
|
||||
cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
.mini-action:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mini-action.primary {
|
||||
background: var(--accent-soft); border-color: rgba(181, 98, 26, 0.28);
|
||||
color: var(--accent);
|
||||
}
|
||||
.presence {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
@@ -292,7 +532,6 @@
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
.form-row { display: flex; gap: 6px; }
|
||||
.btn-ghost {
|
||||
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
||||
}
|
||||
@@ -307,6 +546,7 @@
|
||||
background: var(--accent); border-radius: var(--r);
|
||||
color: #fff; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.avatar.mini { width: 20px; height: 20px; font-size: 9px; }
|
||||
.user-name {
|
||||
font-size: 12px; color: var(--text-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export interface User { id: any; username: string; email: string; avatar?: string; created: string; }
|
||||
export interface Room { id: any; name: string; created: string; }
|
||||
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; }
|
||||
export interface User { id: any; username: string; email?: string; avatar?: string; created?: string; }
|
||||
export interface Room { id: any; name?: string; kind?: 'public' | 'private' | 'direct'; direct_key?: string; created: string; updated?: string; created_by?: any; last_message?: Message; unread_count?: number; other_user?: User; }
|
||||
export interface RoomMember { id: any; room: any; user: any; role: 'owner' | 'member'; joined: string; last_read_at?: string; muted?: boolean; }
|
||||
export interface MessageReactionSummary { emoji: string; count: number; reacted_by_me: boolean; }
|
||||
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; updated?: string; deleted?: boolean; reply_to?: any; reactions?: MessageReactionSummary[]; }
|
||||
export interface UserSearchResult { id: any; username: string; avatar?: string; }
|
||||
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
||||
export interface ContextMenuItem { label: string; action: () => void; }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import ChatMain from '$lib/components/ChatMain.svelte';
|
||||
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||
import type { User, Room, Message, LiveEvent, ContextMenuItem } from '$lib/types';
|
||||
import type { User, Room, Message, LiveEvent, ContextMenuItem, UserSearchResult } from '$lib/types';
|
||||
import { sid, full, cmd } from '$lib/helpers';
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────
|
||||
@@ -16,6 +16,9 @@
|
||||
let contacts = $state<User[]>([]);
|
||||
let subId = $state<string | null>(null);
|
||||
let unlisten = $state<(() => void) | null>(null);
|
||||
let hasOlderMessages = $state(false);
|
||||
let isLoadingOlder = $state(false);
|
||||
let unreadCounts = $state<Record<string, number>>({});
|
||||
|
||||
let view = $state<'loading' | 'auth' | 'app'>('loading');
|
||||
let authMode = $state<'signin' | 'signup'>('signin');
|
||||
@@ -25,6 +28,8 @@
|
||||
let fEmail = $state(''); let fPass = $state('');
|
||||
let fUser = $state(''); let fMsg = $state('');
|
||||
let fRoom = $state('');
|
||||
let fRoomKind = $state<'public' | 'private'>('public');
|
||||
let replyTo = $state<Message | null>(null);
|
||||
|
||||
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
||||
|
||||
@@ -40,6 +45,7 @@
|
||||
view = 'app';
|
||||
await loadRooms();
|
||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
||||
requestNotificationPermission();
|
||||
} catch {
|
||||
view = 'auth';
|
||||
}
|
||||
@@ -53,6 +59,7 @@
|
||||
view = 'app';
|
||||
await loadRooms();
|
||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
||||
requestNotificationPermission();
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
@@ -62,14 +69,21 @@
|
||||
user = await cmd<User>('signup', { email: fEmail, username: fUser, password: fPass });
|
||||
view = 'app';
|
||||
await loadRooms();
|
||||
requestNotificationPermission();
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
function requestNotificationPermission() {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function signout() {
|
||||
await cmd('signout').catch(() => {});
|
||||
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
||||
if (unlisten){ unlisten(); unlisten = null; }
|
||||
user = null; rooms = []; messages = []; activeRoom = null;
|
||||
user = null; rooms = []; messages = []; activeRoom = null; unreadCounts = {};
|
||||
view = 'auth';
|
||||
}
|
||||
|
||||
@@ -84,23 +98,52 @@
|
||||
if (unlisten){ unlisten(); unlisten = null; }
|
||||
|
||||
activeRoom = room;
|
||||
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id) });
|
||||
replyTo = null;
|
||||
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id), limit: 50 });
|
||||
hasOlderMessages = messages.length === 50;
|
||||
unreadCounts = { ...unreadCounts, [sid(room.id)]: 0 };
|
||||
await cmd('mark_room_read', { roomId: sid(room.id) }).catch(() => {});
|
||||
|
||||
subId = await cmd<string>('subscribe_room', { roomId: sid(room.id) });
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
unlisten = await listen<LiveEvent>('chat:message', ({ payload }) => {
|
||||
const { action, data } = payload;
|
||||
const eventRoomId = sid(data.room);
|
||||
const currentRoomId = activeRoom ? sid(activeRoom.id) : '';
|
||||
if (eventRoomId !== currentRoomId) {
|
||||
unreadCounts = { ...unreadCounts, [eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1 };
|
||||
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
|
||||
new Notification(data.author_username ?? 'New message', { body: data.body || 'New message' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'Create') { messages = [...messages, data]; }
|
||||
else if (action === 'Delete') { messages = messages.filter(m => full(m.id) !== full(data.id)); }
|
||||
else if (action === 'Update') { messages = messages.map(m => full(m.id) === full(data.id) ? data : m); }
|
||||
cmd('mark_room_read', { roomId: currentRoomId }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
if (!activeRoom || isLoadingOlder || !hasOlderMessages || messages.length === 0) return;
|
||||
isLoadingOlder = true;
|
||||
try {
|
||||
const older = await cmd<Message[]>('get_messages', {
|
||||
roomId: sid(activeRoom.id),
|
||||
before: messages[0].created,
|
||||
limit: 50,
|
||||
});
|
||||
messages = [...older, ...messages];
|
||||
hasOlderMessages = older.length === 50;
|
||||
} catch (e) { err = String(e); }
|
||||
finally { isLoadingOlder = false; }
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
if (!fRoom.trim()) return;
|
||||
err = '';
|
||||
try {
|
||||
const r = await cmd<Room>('create_room', { name: fRoom.trim() });
|
||||
const r = await cmd<Room>('create_room', { name: fRoom.trim(), kind: fRoomKind });
|
||||
rooms = [r, ...rooms];
|
||||
fRoom = ''; showNewRoom = false;
|
||||
await selectRoom(r);
|
||||
@@ -112,8 +155,9 @@
|
||||
if (!fMsg.trim() || !activeRoom) return;
|
||||
err = '';
|
||||
try {
|
||||
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim() });
|
||||
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim(), replyTo: replyTo ? sid(replyTo.id) : null });
|
||||
fMsg = '';
|
||||
replyTo = null;
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
@@ -125,6 +169,27 @@
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
async function editMessage(msgId: string, body: string) {
|
||||
err = '';
|
||||
try {
|
||||
const updated = await cmd<Message>('edit_message', { messageId: msgId, body });
|
||||
messages = messages.map(m => full(m.id) === full(updated.id) ? updated : m);
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
async function toggleReaction(msgId: string, emoji: string) {
|
||||
err = '';
|
||||
try {
|
||||
await cmd('toggle_reaction', { messageId: msgId, emoji });
|
||||
if (activeRoom) {
|
||||
messages = await cmd<Message[]>('get_messages', {
|
||||
roomId: sid(activeRoom.id),
|
||||
limit: Math.max(50, Math.min(messages.length, 100)),
|
||||
});
|
||||
}
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
async function updateProfile(fields: { username?: string; avatar?: string }) {
|
||||
user = await cmd<User>('update_profile', fields);
|
||||
}
|
||||
@@ -134,6 +199,27 @@
|
||||
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
||||
}
|
||||
|
||||
async function searchUsers(query: string) {
|
||||
return await cmd<UserSearchResult[]>('search_users', { query });
|
||||
}
|
||||
|
||||
async function startDirectMessage(userId: string) {
|
||||
err = '';
|
||||
try {
|
||||
const room = await cmd<Room>('get_or_create_direct_room', { userId });
|
||||
if (!rooms.some(r => full(r.id) === full(room.id))) rooms = [room, ...rooms];
|
||||
await selectRoom(room);
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
async function inviteToActiveRoom(userId: string) {
|
||||
if (!activeRoom || activeRoom.kind === 'direct') return;
|
||||
err = '';
|
||||
try {
|
||||
await cmd('invite_to_room', { roomId: sid(activeRoom.id), userId });
|
||||
} catch (e) { err = String(e); }
|
||||
}
|
||||
|
||||
onMount(init);
|
||||
onDestroy(async () => {
|
||||
if (subId) await cmd('unsubscribe_room', { subId }).catch(() => {});
|
||||
@@ -165,21 +251,32 @@
|
||||
{activeRoom}
|
||||
bind:showNewRoom
|
||||
bind:fRoom
|
||||
bind:fRoomKind
|
||||
{unreadCounts}
|
||||
onSelectRoom={selectRoom}
|
||||
onCreateRoom={createRoom}
|
||||
onSignout={signout}
|
||||
onShowMenu={showMenu}
|
||||
onUpdateProfile={updateProfile}
|
||||
onAddContact={addContact}
|
||||
onSearchUsers={searchUsers}
|
||||
onStartDirectMessage={startDirectMessage}
|
||||
onInviteToRoom={inviteToActiveRoom}
|
||||
/>
|
||||
<ChatMain
|
||||
{activeRoom}
|
||||
{messages}
|
||||
{user}
|
||||
{err}
|
||||
{hasOlderMessages}
|
||||
{isLoadingOlder}
|
||||
bind:fMsg
|
||||
bind:replyTo
|
||||
onLoadOlderMessages={loadOlderMessages}
|
||||
onSendMessage={sendMessage}
|
||||
onDeleteMessage={deleteMessage}
|
||||
onEditMessage={editMessage}
|
||||
onToggleReaction={toggleReaction}
|
||||
onShowMenu={showMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user