json viewer

This commit is contained in:
2026-03-09 20:40:21 +01:00
parent ef03ef5039
commit 640d92d231
11 changed files with 280 additions and 40 deletions

View File

@@ -53,6 +53,9 @@
],
"styles": [
"src/styles.scss"
],
"scripts": [
"src/jsonview.js"
]
},
"configurations": {

View File

@@ -9,6 +9,10 @@ export const routes: Routes = [
path: '',
component: HomePageComponent,
},
{
path: 'chat',
component: ChatPageComponent,
},
{
path: 'chat/:peerId',
component: ChatPageComponent,

View File

@@ -42,35 +42,45 @@
}
@for (connectedPeer of session.peers(); track connectedPeer.id) {
<button
class="peer-tile text-start"
type="button"
[class.peer-tile-active]="connectedPeer.id === peerId()"
(click)="switchPeer(connectedPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span
class="status-led peer-tile-status"
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
[attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()">
<button
class="peer-tile-main text-start"
type="button"
(click)="switchPeer(connectedPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
<span></span>
</span>
}
</span>
<span
class="status-led peer-tile-status"
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
[attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
<button
class="peer-tile-delete"
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(connectedPeer.id, $event)"
>
🗑️
</button>
</article>
}
</div>
</aside>
@@ -121,6 +131,10 @@
/>
}
@if (isIncomingJsonFileEntry(entry)) {
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
}
<div>
<div class="fw-semibold">{{ entry.fileName }}</div>
@if (entry.fileSize) {

View File

@@ -106,8 +106,12 @@
}
.peer-tile {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
padding: 0.95rem 1rem;
padding: 0.8rem 0.85rem 0.8rem 1rem;
border: 1px solid var(--surface-border);
border-radius: 1rem;
color: inherit;
@@ -115,6 +119,30 @@
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
}
.peer-tile-main {
min-width: 0;
padding: 0;
border: 0;
color: inherit;
background: transparent;
}
.peer-tile-delete {
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 0;
border-radius: 999px;
background: transparent;
font-size: 1rem;
line-height: 1;
}
.peer-tile-delete:hover,
.peer-tile-delete:focus-visible {
background: var(--badge-background);
}
.peer-tile:hover,
.peer-tile:focus-visible,
.peer-tile-active {

View File

@@ -5,11 +5,12 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ChatSessionService } from './chat-session.service';
import { JsonFileViewerComponent } from './json-file-viewer.component';
import type { ChatEntry, ConnectionState } from './models';
@Component({
selector: 'app-chat-page',
imports: [CommonModule, FormsModule, RouterLink],
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent],
templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss',
})
@@ -120,10 +121,25 @@ export class ChatPageComponent {
await this.session.deleteMessage(entry);
}
async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation();
await this.session.deleteConversation(peerId);
}
isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
}
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
return (
entry.kind === 'file' &&
entry.direction === 'incoming' &&
!!entry.downloadUrl &&
!!entry.fileName &&
entry.fileName.toLowerCase().endsWith('.json')
);
}
isPeerTyping(peerId: string): boolean {
return this.session.typingPeerIds().includes(peerId);
}

View File

@@ -1060,6 +1060,51 @@ export class ChatSessionService {
}
}
async deleteConversation(peerId: string): Promise<void> {
const entries = this.messages().filter((entry) => entry.peerId === peerId);
for (const entry of entries) {
this.removeMessageById(entry.id);
}
this.clearUnreadPeer(peerId);
this.clearPeerTyping(peerId);
const currentUserId = this.currentUser()?.id;
if (!currentUserId) {
return;
}
try {
const conversationKey = this.conversationStorageKey(currentUserId, peerId);
await this.queueMessageStoreOperation(conversationKey, async () => {
const database = await this.openMessageDatabase();
if (!database) {
return;
}
const transaction = database.transaction(ChatSessionService.messageStoreName, 'readwrite');
const store = transaction.objectStore(ChatSessionService.messageStoreName);
const conversationIndex = store.index('conversationKeyCreatedAt');
const rows = await this.waitForRequest(
conversationIndex.getAll(
IDBKeyRange.bound([conversationKey, 0], [conversationKey, Number.MAX_SAFE_INTEGER]),
),
) as PersistedChatEntry[];
for (const row of rows) {
store.delete(row.storageKey);
}
await this.waitForTransaction(transaction);
});
} catch (error) {
console.warn('Could not delete chat conversation.', error);
}
}
private addSystemMessage(peerId: string, text: string): void {
const id = crypto.randomUUID();

View File

@@ -25,7 +25,7 @@
<div class="h4 mb-1">{{ user.displayName }}</div>
<div class="text-secondary mb-3">{{ user.username }}</div>
<div class="small status-pill mb-3">{{ session.status() }}</div>
<button class="btn btn-accent w-100 mb-2" type="button" [disabled]="!canOpenChatUi()" (click)="openChatUi()">
<button class="btn btn-accent w-100 mb-2" type="button" (click)="openChatUi()">
Open chat UI
</button>
@if (session.isApprovalAdmin()) {

View File

@@ -80,20 +80,17 @@ export class HomePageComponent {
this.accessKeyLabel = '';
}
canOpenChatUi(): boolean {
return this.session.peers().length > 0;
}
async openChatUi(): Promise<void> {
const peerId = this.session.activePeerId() ?? this.session.peers()[0]?.id;
if (!peerId) {
this.session.error.set('No connected peers are available yet.');
if (peerId) {
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
return;
}
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
this.session.error.set(null);
await this.router.navigate(['/chat']);
}
cycleTheme(): void {

View File

@@ -0,0 +1,31 @@
:host {
display: block;
max-width: 95%;
}
.json-viewer-shell {
width: 95%;
max-width: 95%;
min-width: 0;
overflow: hidden;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.06);
}
.json-viewer-host {
max-height: 18rem;
max-width: 100%;
overflow-x: auto;
overflow-y: auto;
padding: 0.75rem;
}
.json-viewer-error {
padding: 0 0.75rem 0.75rem;
color: inherit;
opacity: 0.7;
}
:host ::ng-deep .json-viewer-host .json-container {
min-width: max-content;
}

View File

@@ -0,0 +1,101 @@
import { CommonModule } from '@angular/common';
import { AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
import type { ChatEntry } from './models';
type JsonViewTree = object;
type JsonViewApi = {
renderJSON(value: unknown, container: HTMLElement): JsonViewTree;
expand?(tree: JsonViewTree): void;
destroy?(tree: JsonViewTree): void;
};
declare global {
interface Window {
jsonview?: JsonViewApi;
}
}
@Component({
selector: 'app-json-file-viewer',
imports: [CommonModule],
template: `
<div class="json-viewer-shell">
<div #host class="json-viewer-host"></div>
@if (errorMessage) {
<p class="json-viewer-error mb-0">{{ errorMessage }}</p>
}
</div>
`,
styleUrl: './json-file-viewer.component.scss',
})
export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input({ required: true }) entry!: ChatEntry;
@ViewChild('host') private readonly host?: ElementRef<HTMLDivElement>;
errorMessage: string | null = null;
private renderedTree: JsonViewTree | null = null;
private renderVersion = 0;
ngAfterViewInit(): void {
void this.renderJsonTree();
}
ngOnChanges(): void {
void this.renderJsonTree();
}
ngOnDestroy(): void {
this.destroyRenderedTree();
}
private async renderJsonTree(): Promise<void> {
const host = this.host?.nativeElement;
if (!host || !this.entry.downloadUrl) {
return;
}
const renderVersion = ++this.renderVersion;
const jsonview = window.jsonview;
this.destroyRenderedTree();
host.replaceChildren();
this.errorMessage = null;
if (!jsonview) {
this.errorMessage = 'JSON viewer unavailable.';
return;
}
try {
const response = await fetch(this.entry.downloadUrl);
const content = await response.text();
const parsed = JSON.parse(content);
if (renderVersion !== this.renderVersion) {
return;
}
const tree = jsonview.renderJSON(parsed, host);
jsonview.expand?.(tree);
this.renderedTree = tree;
} catch {
if (renderVersion !== this.renderVersion) {
return;
}
this.errorMessage = 'Could not render JSON preview.';
}
}
private destroyRenderedTree(): void {
if (!this.renderedTree) {
return;
}
window.jsonview?.destroy?.(this.renderedTree);
this.renderedTree = null;
}
}

1
client/src/jsonview.js Normal file

File diff suppressed because one or more lines are too long