Files
mumh5/src/ui/ChatLog.svelte
T
kibiandClaude Opus 5.5 6d29ee1485 Initial commit: mumh5, a modern Mumble client
Electron desktop app with a Svelte 5 interface for any Mumble server.

- Mumble protocol core: TLS, handshake, channels, users, text, plugin data,
  client-side pacing of Murmur's rate limits
- Voice: WebCodecs Opus over the TCP tunnel, voice activity, push to talk,
  always-on, devices, per-user volume and local mute
- Several servers at once, voice on one; server rail with icons and ordering
- Chat: channels, direct messages, side chat, file sharing through f0ckm,
  inline images without it, click-to-play YouTube
- Profiles with rich descriptions, registration, rename, nicknames,
  connection information and moderation menus
- Identity wizard, multiple identities, PKCS#12 import/export, desktop
  Mumble certificate import, certificate pinning and viewer
- Tray icon with voice state
- Unit, server and end-to-end tests against a real Murmur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:28:43 +02:00

64 lines
2.6 KiB
Svelte

<script lang="ts">
import { tick, type Snippet } from 'svelte';
import Icon from './Icon.svelte';
import Message from './Message.svelte';
import { session } from '../lib/session.svelte.ts';
// Message list for one chat (channel or DM), with drag and drop upload into that chat
let { key, empty }: { key: string; empty: Snippet } = $props();
const entries = $derived(session.logs[key] ?? []);
const title = $derived(session.viewTitle(key));
let list: HTMLDivElement;
let pinned = true; // stick to the bottom unless the user scrolled up
let dragging = $state(0);
$effect(() => {
void entries.length;
void key;
if (pinned) tick().then(() => list && (list.scrollTop = list.scrollHeight));
});
// Media loads after insertion and grows the list; keep the bottom pinned
function onload() { if (pinned) list.scrollTop = list.scrollHeight; }
function ondrop(e: DragEvent) {
e.preventDefault();
dragging = 0;
const files = [...(e.dataTransfer?.files ?? [])];
if (files.length && session.status === 'connected') session.uploadFiles(files, key);
}
const grouped = (i: number) => {
const a = entries[i - 1], b = entries[i];
return !!a && a.kind === 'text' && b.kind === 'text' && a.authorSession === b.authorSession && a.self === b.self && b.time - a.time < 5 * 60000;
};
</script>
<div class="wrap" role="log"
ondragenter={e => { if (e.dataTransfer?.types.includes('Files')) dragging++; }}
ondragleave={() => (dragging = Math.max(0, dragging - 1))}
ondragover={e => e.preventDefault()} {ondrop}>
<div class="list" bind:this={list} onscroll={() => (pinned = list.scrollHeight - list.scrollTop - list.clientHeight < 40)}
onloadcapture={onload} onloadedmetadatacapture={onload}>
{#if !entries.length}{@render empty()}{/if}
{#each entries as entry, i (entry.id)}
<Message {entry} grouped={grouped(i)} />
{/each}
<div class="end"></div>
</div>
{#if dragging}
<div class="drop"><div><Icon name="download" size={40} /><p>Drop to upload to {key.startsWith('dm:') ? '@' : '#'}{title}</p></div></div>
{/if}
</div>
<style>
.wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
.list { flex: 1; overflow-y: auto; overflow-x: hidden; padding-bottom: 12px; }
.end { height: 4px; }
.drop { position: absolute; inset: 0; z-index: 10; background: rgb(13 14 16 / 0.8); display: flex; align-items: center; justify-content: center; padding: 16px; pointer-events: none; }
.drop div { border: 2px dashed var(--accent); padding: 32px 48px; text-align: center; color: var(--text); }
.drop p { margin: 8px 0 0; font-weight: 600; }
</style>