diff --git a/client/angular.json b/client/angular.json index 7d7e545..48515e1 100644 --- a/client/angular.json +++ b/client/angular.json @@ -53,6 +53,9 @@ ], "styles": [ "src/styles.scss" + ], + "scripts": [ + "src/jsonview.js" ] }, "configurations": { diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts index 6b19fd8..399fb4c 100644 --- a/client/src/app/app.routes.ts +++ b/client/src/app/app.routes.ts @@ -9,6 +9,10 @@ export const routes: Routes = [ path: '', component: HomePageComponent, }, + { + path: 'chat', + component: ChatPageComponent, + }, { path: 'chat/:peerId', component: ChatPageComponent, diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 972026a..73591cd 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -42,35 +42,45 @@ } @for (connectedPeer of session.peers(); track connectedPeer.id) { - +
+ + +
} @@ -121,6 +131,10 @@ /> } + @if (isIncomingJsonFileEntry(entry)) { + + } +
{{ entry.fileName }}
@if (entry.fileSize) { diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index 1e5e5cd..6bf832a 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -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 { diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index f693ea7..891c47a 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -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 { + 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); } diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index a8a45b8..4068cea 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -1060,6 +1060,51 @@ export class ChatSessionService { } } + async deleteConversation(peerId: string): Promise { + 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(); diff --git a/client/src/app/home-page.component.html b/client/src/app/home-page.component.html index 6254448..994b3a8 100644 --- a/client/src/app/home-page.component.html +++ b/client/src/app/home-page.component.html @@ -25,7 +25,7 @@
{{ user.displayName }}
{{ user.username }}
{{ session.status() }}
- @if (session.isApprovalAdmin()) { diff --git a/client/src/app/home-page.component.ts b/client/src/app/home-page.component.ts index ddcab09..b6adb83 100644 --- a/client/src/app/home-page.component.ts +++ b/client/src/app/home-page.component.ts @@ -80,20 +80,17 @@ export class HomePageComponent { this.accessKeyLabel = ''; } - canOpenChatUi(): boolean { - return this.session.peers().length > 0; - } - async openChatUi(): Promise { 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 { diff --git a/client/src/app/json-file-viewer.component.scss b/client/src/app/json-file-viewer.component.scss new file mode 100644 index 0000000..e99b909 --- /dev/null +++ b/client/src/app/json-file-viewer.component.scss @@ -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; +} diff --git a/client/src/app/json-file-viewer.component.ts b/client/src/app/json-file-viewer.component.ts new file mode 100644 index 0000000..8292e22 --- /dev/null +++ b/client/src/app/json-file-viewer.component.ts @@ -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: ` +
+
+ @if (errorMessage) { +

{{ errorMessage }}

+ } +
+ `, + styleUrl: './json-file-viewer.component.scss', +}) +export class JsonFileViewerComponent implements AfterViewInit, OnChanges, OnDestroy { + @Input({ required: true }) entry!: ChatEntry; + @ViewChild('host') private readonly host?: ElementRef; + + 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 { + 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; + } +} diff --git a/client/src/jsonview.js b/client/src/jsonview.js new file mode 100644 index 0000000..d6cef39 --- /dev/null +++ b/client/src/jsonview.js @@ -0,0 +1 @@ +!function(e,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.jsonview=n():e.jsonview=n()}(self,(()=>(()=>{"use strict";var e={425:(e,n,t)=>{t.d(n,{Z:()=>s});var r=t(650),o=t.n(r),i=t(196),a=t.n(i)()(o());a.push([e.id,'.json-container{font-family:"Open Sans";font-size:16px;background-color:#fff;color:gray;box-sizing:border-box}.json-container .line{margin:4px 0;display:flex;justify-content:flex-start}.json-container .caret-icon{width:18px;text-align:center;cursor:pointer}.json-container .empty-icon{width:18px}.json-container .json-type{margin-right:4px;margin-left:4px}.json-container .json-key{color:#444;margin-right:4px;margin-left:4px}.json-container .json-index{margin-right:4px;margin-left:4px}.json-container .json-value{margin-left:8px}.json-container .json-number{color:#f9ae58}.json-container .json-boolean{color:#ec5f66}.json-container .json-string{color:#86b25c}.json-container .json-size{margin-right:4px;margin-left:4px}.json-container .hidden{display:none}.json-container .fas{display:inline-block;border-style:solid;width:0;height:0}.json-container .fa-caret-down{border-width:6px 5px 0 5px;border-color:gray rgba(0,0,0,0)}.json-container .fa-caret-right{border-width:5px 0 5px 6px;border-color:rgba(0,0,0,0) rgba(0,0,0,0) rgba(0,0,0,0) gray}',""]);const s=a},196:e=>{e.exports=function(e){var n=[];return n.toString=function(){return this.map((function(n){var t="",r=void 0!==n[5];return n[4]&&(t+="@supports (".concat(n[4],") {")),n[2]&&(t+="@media ".concat(n[2]," {")),r&&(t+="@layer".concat(n[5].length>0?" ".concat(n[5]):""," {")),t+=e(n),r&&(t+="}"),n[2]&&(t+="}"),n[4]&&(t+="}"),t})).join("")},n.i=function(e,t,r,o,i){"string"==typeof e&&(e=[[null,e,void 0]]);var a={};if(r)for(var s=0;s0?" ".concat(d[5]):""," {").concat(d[1],"}")),d[5]=i),t&&(d[2]?(d[1]="@media ".concat(d[2]," {").concat(d[1],"}"),d[2]=t):d[2]=t),o&&(d[4]?(d[1]="@supports (".concat(d[4],") {").concat(d[1],"}"),d[4]=o):d[4]="".concat(o)),n.push(d))}},n}},650:e=>{e.exports=function(e){return e[1]}},62:e=>{var n=[];function t(e){for(var t=-1,r=0;r{var n={};e.exports=function(e,t){var r=function(e){if(void 0===n[e]){var t=document.querySelector(e);if(window.HTMLIFrameElement&&t instanceof window.HTMLIFrameElement)try{t=t.contentDocument.head}catch(e){t=null}n[e]=t}return n[e]}(e);if(!r)throw new Error("Couldn't find a style target. This probably means that the value for the 'insert' parameter is invalid.");r.appendChild(t)}},911:e=>{e.exports=function(e){var n=document.createElement("style");return e.setAttributes(n,e.attributes),e.insert(n,e.options),n}},107:(e,n,t)=>{e.exports=function(e){var n=t.nc;n&&e.setAttribute("nonce",n)}},552:e=>{e.exports=function(e){if("undefined"==typeof document)return{update:function(){},remove:function(){}};var n=e.insertStyleElement(e);return{update:function(t){!function(e,n,t){var r="";t.supports&&(r+="@supports (".concat(t.supports,") {")),t.media&&(r+="@media ".concat(t.media," {"));var o=void 0!==t.layer;o&&(r+="@layer".concat(t.layer.length>0?" ".concat(t.layer):""," {")),r+=t.css,o&&(r+="}"),t.media&&(r+="}"),t.supports&&(r+="}");var i=t.sourceMap;i&&"undefined"!=typeof btoa&&(r+="\n/*# sourceMappingURL=data:application/json;base64,".concat(btoa(unescape(encodeURIComponent(JSON.stringify(i))))," */")),n.styleTagTransform(r,e,n.options)}(n,e,t)},remove:function(){!function(e){if(null===e.parentNode)return!1;e.parentNode.removeChild(e)}(n)}}}},227:e=>{e.exports=function(e,n){if(n.styleSheet)n.styleSheet.cssText=e;else{for(;n.firstChild;)n.removeChild(n.firstChild);n.appendChild(document.createTextNode(e))}}}},n={};function t(r){var o=n[r];if(void 0!==o)return o.exports;var i=n[r]={id:r,exports:{}};return e[r](i,i.exports,t),i.exports}t.n=e=>{var n=e&&e.__esModule?()=>e.default:()=>e;return t.d(n,{a:n}),n},t.d=(e,n)=>{for(var r in n)t.o(n,r)&&!t.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:n[r]})},t.o=(e,n)=>Object.prototype.hasOwnProperty.call(e,n),t.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.nc=void 0;var r={};return(()=>{t.r(r),t.d(r,{collapse:()=>A,create:()=>S,default:()=>H,destroy:()=>L,expand:()=>D,render:()=>w,renderJSON:()=>k,toggleNode:()=>N,traverse:()=>C});var e=t(62),n=t.n(e),o=t(552),i=t.n(o),a=t(566),s=t.n(a),c=t(107),l=t.n(c),d=t(911),u=t.n(d),p=t(227),f=t.n(p),v=t(425),y={};function h(e){return Array.isArray(e)?"array":null===e?"null":typeof e}function m(e){return document.createElement(e)}y.styleTagTransform=f(),y.setAttributes=l(),y.insert=s().bind(null,"head"),y.domAPI=i(),y.insertStyleElement=u(),n()(v.Z,y),v.Z&&v.Z.locals&&v.Z.locals;const g={HIDDEN:"hidden",CARET_ICON:"caret-icon",CARET_RIGHT:"fa-caret-right",CARET_DOWN:"fa-caret-down",ICON:"fas"};function x(e){e.children.forEach((e=>{e.el.classList.add(g.HIDDEN),e.isExpanded&&x(e)}))}function j(e){e.children.forEach((e=>{e.el.classList.remove(g.HIDDEN),e.isExpanded&&j(e)}))}function b(e){if(e.children.length>0){const n=e.el.querySelector("."+g.ICON);n&&n.classList.replace(g.CARET_RIGHT,g.CARET_DOWN)}}function E(e){if(e.children.length>0){const n=e.el.querySelector("."+g.ICON);n&&n.classList.replace(g.CARET_DOWN,g.CARET_RIGHT)}}function N(e){e.isExpanded?(e.isExpanded=!1,E(e),x(e)):(e.isExpanded=!0,b(e),j(e))}function C(e,n){n(e),e.children.length>0&&e.children.forEach((e=>{C(e,n)}))}function T(e={}){let n=e.hasOwnProperty("value")?e.value:null;return(e=>"object"===h(e)&&0===Object.keys(e).length)(n)&&(n="{}"),{key:e.key||null,parent:e.parent||null,value:n,isExpanded:e.isExpanded||!1,type:e.type||null,children:e.children||[],el:e.el||null,depth:e.depth||0,dispose:null}}function I(e,n){if("object"==typeof e)for(let t in e){const r=T({value:e[t],key:t,depth:n.depth+1,type:h(e[t]),parent:n});n.children.push(r),I(e[t],r)}}function O(e){return"string"==typeof e?JSON.parse(e):e}function S(e){const n=O(e),t=T({value:n,key:h(n),type:h(n)});return I(n,t),t}function k(e,n){const t=S(O(e));return w(t,n),t}function w(e,n){const t=function(){const e=m("div");return e.className="json-container",e}();C(e,(function(e){e.el=function(e){let n=m("div");const t=e=>{const n=e.children.length;return"array"===e.type?`[${n}]`:"object"===e.type?`{${n}}`:null};if(e.children.length>0){n.innerHTML=function(e={}){const{key:n,size:t}=e;return`\n
\n
\n
${n}
\n
${t}
\n
\n `}({key:e.key,size:t(e)});const r=n.querySelector("."+g.CARET_ICON);e.dispose=function(e,n,t){return e.addEventListener(n,t),()=>e.removeEventListener(n,t)}(r,"click",(()=>N(e)))}else n.innerHTML=function(e={}){const{key:n,value:t,type:r}=e;return`\n
\n
\n
${n}
\n
:
\n
${t}
\n
\n `}({key:e.key,value:e.value,type:"{}"===e.value?"object":typeof e.value});const r=n.children[0];return null!==e.parent&&r.classList.add(g.HIDDEN),r.style="margin-left: "+18*e.depth+"px;",r}(e),t.appendChild(e.el)})),n.appendChild(t)}function D(e){C(e,(function(e){e.el.classList.remove(g.HIDDEN),e.isExpanded=!0,b(e)}))}function A(e){C(e,(function(n){n.isExpanded=!1,n.depth>e.depth&&n.el.classList.add(g.HIDDEN),E(n)}))}function L(e){var n;C(e,(e=>{e.dispose&&e.dispose()})),(n=e.el.parentNode).parentNode.removeChild(n)}const H={toggleNode:N,render:w,create:S,renderJSON:k,expand:D,collapse:A,traverse:C,destroy:L}})(),r})())); \ No newline at end of file