From d4a953b9437d01078447b3059cb82073ad0fa822 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 20 Sep 2024 02:25:27 +0200 Subject: [PATCH] add UI to display connection statuses, fix bug when agent is leaving --- ui/src/agent-connection-status.ts | 175 +++++++++++++++++ ui/src/room-view.ts | 313 ++++++++++++++++++++++++++++-- weave.dev.config.ts | 12 +- 3 files changed, 479 insertions(+), 21 deletions(-) create mode 100644 ui/src/agent-connection-status.ts diff --git a/ui/src/agent-connection-status.ts b/ui/src/agent-connection-status.ts new file mode 100644 index 0000000..5bb8bff --- /dev/null +++ b/ui/src/agent-connection-status.ts @@ -0,0 +1,175 @@ +import { consume } from '@lit/context'; +import { hashProperty, sharedStyles } from '@holochain-open-dev/elements'; +import { css, html, LitElement, PropertyValueMap } from 'lit'; +import { property, customElement } from 'lit/decorators.js'; +import { AgentPubKey, encodeHashToBase64 } from '@holochain/client'; +import { localized, msg } from '@lit/localize'; +import { StoreSubscriber } from '@holochain-open-dev/stores'; + +import '@holochain-open-dev/elements/dist/elements/display-error.js'; +import '@holochain-open-dev/elements/dist/elements/holo-identicon.js'; +import '@shoelace-style/shoelace/dist/components/avatar/avatar.js'; +import '@shoelace-style/shoelace/dist/components/skeleton/skeleton.js'; +import '@shoelace-style/shoelace/dist/components/tooltip/tooltip.js'; + +import { + profilesStoreContext, + ProfilesStore, + Profile, +} from '@holochain-open-dev/profiles'; +import { EntryRecord } from '@holochain-open-dev/utils'; +import { ConnectionStatus } from './room-view'; + +@localized() +@customElement('agent-connection-status') +export class AgentConnectionStatus extends LitElement { + /** Public properties */ + + /** + * REQUIRED. The public key identifying the agent whose profile is going to be shown. + */ + @property(hashProperty('agent-pub-key')) + agentPubKey!: AgentPubKey; + + /** + * Size of the avatar image in pixels. + */ + @property({ type: Number }) + size = 40; + + @property() + connectionStatus: ConnectionStatus | undefined; + + /** Dependencies */ + + /** + * Profiles store for this element, not required if you embed this element inside a + */ + @consume({ context: profilesStoreContext, subscribe: true }) + @property() + store!: ProfilesStore; + + /** + * @internal + */ + private _agentProfile = new StoreSubscriber( + this, + () => this.store.profiles.get(this.agentPubKey), + () => [this.agentPubKey, this.store] + ); + + async willUpdate( + changedProperties: PropertyValueMap | Map + ) { + if (changedProperties.has('agentPubKey')) { + this.requestUpdate(); + } + } + + renderIdenticon() { + return html` + `; + } + + /** + * @internal + */ + timeout: any; + + statusToText(status?: ConnectionStatus) { + if (!status) return 'disconnected'; + switch (status.type) { + case 'Connected': + return 'connected'; + case 'Disconnected': + return 'disconnected'; + case 'AwaitingInit': + return 'waiting for init request...'; + case 'InitSent': + return `waiting for init accept${status.attemptCount && status.attemptCount > 1 ? `(attempt #${status.attemptCount})` : ''}...`; + case 'AcceptSent': + return `waiting for SDP exchange${status.attemptCount && status.attemptCount > 1 ? `(attempt #${status.attemptCount})` : ''}...`; + case 'SdpExchange': + return 'exchanging SDP data...'; + default: + return 'unknown status type'; + } + } + + renderProfile(profile: EntryRecord | undefined) { + return html` +
+ + ${profile && profile.entry.fields.avatar + ? html` + ${profile.entry.nickname}'s avatar + ` + : html` + + + `} + +
+ ${profile ? profile.entry.nickname : 'Unknown'} + ${this.statusToText(this.connectionStatus)} +
+
+ `; + } + + render() { + switch (this._agentProfile.value.status) { + case 'pending': + return html``; + case 'complete': + return this.renderProfile(this._agentProfile.value.value); + case 'error': + return html` + + `; + default: + return html``; + } + } + + static styles = [sharedStyles, css``]; +} diff --git a/ui/src/room-view.ts b/ui/src/room-view.ts index bdb08e5..364b08d 100644 --- a/ui/src/room-view.ts +++ b/ui/src/room-view.ts @@ -11,6 +11,7 @@ import { StoreSubscriber, lazyLoadAndPoll } from '@holochain-open-dev/stores'; import SimplePeer from 'simple-peer'; import { v4 as uuidv4 } from 'uuid'; import { + mdiAccount, mdiFullscreen, mdiFullscreenExit, mdiLock, @@ -38,6 +39,7 @@ import './avatar-with-nickname'; import { Attachment, RoomInfo, weaveClientContext } from './types'; import { RoomStore } from './room-store'; import './attachment-element'; +import './agent-connection-status'; const ICE_CONFIG = [ { urls: 'stun:global.stun.twilio.com:3478' }, @@ -94,6 +96,49 @@ type PendingAccept = { peer: SimplePeer.Instance; }; +/** + * Connection status with a peer + */ +export type ConnectionStatus = + | { + /** + * No WebRTC connection or freshly disconnected + */ + type: 'Disconnected'; + } + | { + /** + * Waiting for an init of a peer whose pubkey is alphabetically higher than ours + */ + type: 'AwaitingInit'; + } + | { + /** + * Waiting for an Accept of a peer whose pubkey is alphabetically lower than ours + */ + type: 'InitSent'; + attemptCount?: number; + } + | { + /** + * Waiting for SDP exchange to start + */ + type: 'AcceptSent'; + attemptCount?: number; + } + | { + /** + * SDP exchange is ongoing + */ + type: 'SdpExchange'; + } + | { + /** + * WebRTC connection is established + */ + type: 'Connected'; + }; + @localized() @customElement('room-view') export class RoomView extends LitElement { @@ -155,6 +200,49 @@ export class RoomView extends LitElement { @state() _screenShareStream: MediaStream | undefined | null; + updateConnectionStatus(pubKey: AgentPubKeyB64, status: ConnectionStatus) { + if (status.type === 'InitSent') { + const currentStatus = this._connectionStatuses[pubKey]; + if (currentStatus && currentStatus.type === 'InitSent') { + // increase number of attempts by 1 + this._connectionStatuses[pubKey] = { + type: 'InitSent', + attemptCount: currentStatus.attemptCount + ? currentStatus.attemptCount + 1 + : 1, + }; + } else { + this._connectionStatuses[pubKey] = { + type: 'InitSent', + attemptCount: 1, + }; + } + return; + } + if (status.type === 'AcceptSent') { + const currentStatus = this._connectionStatuses[pubKey]; + if (currentStatus && currentStatus.type === 'AcceptSent') { + // increase number of attempts by 1 + this._connectionStatuses[pubKey] = { + type: 'AcceptSent', + attemptCount: currentStatus.attemptCount + ? currentStatus.attemptCount + 1 + : 1, + }; + } else { + this._connectionStatuses[pubKey] = { + type: 'AcceptSent', + attemptCount: 1, + }; + } + return; + } + this._connectionStatuses[pubKey] = status; + } + + @state() + _connectionStatuses: Record = {}; + /** * Connections where the Init/Accept handshake succeeded */ @@ -229,6 +317,9 @@ export class RoomView extends LitElement { @state() _showAttachmentsPanel = false; + @state() + _panelMode: 'attachments' | 'people' = 'attachments'; + @state() _unsubscribe: (() => void) | undefined; @@ -251,6 +342,7 @@ export class RoomView extends LitElement { connectionId: string, initiator: boolean ): SimplePeer.Instance { + const pubKey64 = encodeHashToBase64(connectingAgent); const options: SimplePeer.Options = { initiator, config: { @@ -357,7 +449,6 @@ export class RoomView extends LitElement { }); peer.on('connect', async () => { console.log('#### CONNECTED'); - const pubKey64 = encodeHashToBase64(connectingAgent); const pendingInits = this._pendingInits; delete pendingInits[pubKey64]; this._pendingInits = pendingInits; @@ -374,6 +465,8 @@ export class RoomView extends LitElement { openConnections[pubKey64] = relevantConnection; this._openConnections = openConnections; + this.updateConnectionStatus(pubKey64, { type: 'Connected' }); + this.requestUpdate(); await this._joinAudio.play(); }); @@ -383,12 +476,19 @@ export class RoomView extends LitElement { peer.destroy(); const openConnections = this._openConnections; - const relevantConnection = openConnections[encodeHashToBase64(connectingAgent)]; - if (this._maximizedVideo === relevantConnection.connectionId) { + const relevantConnection = + openConnections[encodeHashToBase64(connectingAgent)]; + if ( + relevantConnection && + this._maximizedVideo === relevantConnection.connectionId + ) { this._maximizedVideo = undefined; } delete openConnections[encodeHashToBase64(connectingAgent)]; this._openConnections = openConnections; + + this.updateConnectionStatus(pubKey64, { type: 'Disconnected' }); + this.requestUpdate(); await this._leaveAudio.play(); }); @@ -397,9 +497,19 @@ export class RoomView extends LitElement { peer.destroy(); const openConnections = this._openConnections; + const relevantConnection = + openConnections[encodeHashToBase64(connectingAgent)]; + if ( + relevantConnection && + this._maximizedVideo === relevantConnection.connectionId + ) { + this._maximizedVideo = undefined; + } delete openConnections[encodeHashToBase64(connectingAgent)]; this._openConnections = openConnections; + this.updateConnectionStatus(pubKey64, { type: 'Disconnected' }); + this.requestUpdate(); }); return peer; @@ -845,6 +955,7 @@ export class RoomView extends LitElement { connection_id: newConnectionId, to_agent: signal.from_agent, }); + this.updateConnectionStatus(pubkeyB64, { type: 'InitSent' }); } else { console.log( `#--# SENDING INIT REQUEST NUMBER ${pendingInits.length + 1}.` @@ -860,6 +971,7 @@ export class RoomView extends LitElement { connection_id: newConnectionId, to_agent: signal.from_agent, }); + this.updateConnectionStatus(pubkeyB64, { type: 'InitSent' }); } } } @@ -961,6 +1073,7 @@ export class RoomView extends LitElement { connection_id: signal.connection_id, to_agent: signal.from_agent, }); + this.updateConnectionStatus(pubKey64, { type: 'AcceptSent' }); } /** @@ -1039,6 +1152,9 @@ export class RoomView extends LitElement { const pendingInits = this._pendingInits; delete pendingInits[pubKey64]; this._pendingInits = pendingInits; + + this.updateConnectionStatus(pubKey64, { type: 'SdpExchange' }); + this.requestUpdate(); // reload rendered video containers } } @@ -1099,6 +1215,8 @@ export class RoomView extends LitElement { console.log('## Got SDP Data: ', signal.data); const pubkeyB64 = encodeHashToBase64(signal.from_agent); + this.updateConnectionStatus(pubkeyB64, { type: 'SdpExchange' }); + /** * Normal video/audio connections */ @@ -1339,6 +1457,9 @@ export class RoomView extends LitElement { this._allAttachments.value.status === 'complete' ? this._allAttachments.value.value.length : undefined; + const numPeople = Object.values(this._connectionStatuses).filter( + status => !!status && status.type !== 'Disconnected' + ).length; return html`
+
${numPeople}
+
`; } @@ -1425,6 +1551,70 @@ export class RoomView extends LitElement { } } + renderConnectionStatuses() { + if (this._allAgents.value.status === 'complete') { + const presentAgents = this._allAgents.value.value + .map(agent => encodeHashToBase64(agent)) + .filter(pubkeyB64 => { + const status = this._connectionStatuses[pubkeyB64]; + return !!status && status.type !== 'Disconnected'; + }) + .sort((key_a, key_b) => key_a.localeCompare(key_b)); + const absentAgents = this._allAgents.value.value + .map(agent => encodeHashToBase64(agent)) + .filter(pubkeyB64 => { + const status = this._connectionStatuses[pubkeyB64]; + return !status || status.type === 'Disconnected'; + }) + .sort((key_a, key_b) => key_a.localeCompare(key_b)); + return html` +
+
+
Present
+
+
+ ${presentAgents.length > 0 + ? repeat( + presentAgents, + pubkey => pubkey, + pubkey => html` + + ` + ) + : html`look into the mirror - there's only you...`} + ${absentAgents.length > 0 + ? html` +
+
Absent
+
+
+ ${repeat( + absentAgents, + pubkey => pubkey, + pubkey => html` + + ` + )} + ` + : html``} +
+ `; + } + return html`Loading profiles...`; + } + renderAttachmentPanel() { return html`
-
+
this.addAttachment()} - @keypress=${async (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - await this.addAttachment(); - } + @click=${() => { + this._panelMode = 'attachments'; + }} + @keypress=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') + this._panelMode = 'attachments'; }} > - + Add Attachment +
+ + attachments +
+
+
{ + this._panelMode = 'people'; + }} + @keypress=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') + this._panelMode = 'people'; + }} + > +
+ + people +
- ${this.renderAttachments()}
+ ${this._panelMode === 'people' + ? this.renderConnectionStatuses() + : html` +
+
this.addAttachment()} + @keypress=${async (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + await this.addAttachment(); + } + }} + > + + Add Attachment +
+ ${this.renderAttachments()} +
+ `}
`; } @@ -1867,6 +2105,35 @@ export class RoomView extends LitElement { /* background: #6f7599; */ } + .sidepanel-tabs { + width: 100%; + align-items: center; + margin-top: 10px; + /* #ffffff80 */ + } + + .sidepanel-tab { + width: 50%; + height: 40px; + /* background: #ffffff10; */ + background: linear-gradient(#6f7599c4, #6f759900); + cursor: pointer; + font-size: 24px; + color: #0d1543; + font-weight: 600; + padding-top: 4px; + } + + .sidepanel-tab:hover { + /* background: #ffffff80; */ + background: linear-gradient(#c6d2ff87, #6f759900); + } + + .tab-selected { + /* background: #ffffff80; */ + background: linear-gradient(#c6d2ff87, #6f759900); + } + .attachments-list { justify-content: flex-start; align-items: flex-start; @@ -1923,6 +2190,22 @@ export class RoomView extends LitElement { color: white; } + .divider { + height: 1px; + border: 0; + width: 380px; + background: #0d1543; + margin: 0 0 5px 0; + } + + .connectivity-title { + font-style: italic; + font-weight: bold; + font-size: 16px; + margin-bottom: -3px; + color: #0d1543; + } + .room-name { position: absolute; bottom: 5px; diff --git a/weave.dev.config.ts b/weave.dev.config.ts index 14e1e82..f3959b2 100644 --- a/weave.dev.config.ts +++ b/weave.dev.config.ts @@ -54,12 +54,12 @@ export default defineConfig({ // registeringAgent: 1, // joiningAgents: [2], // }, - { - name: 'KanDo', - instanceName: 'KanDo', - registeringAgent: 1, - joiningAgents: [2], - }, + // { + // name: 'KanDo', + // instanceName: 'KanDo', + // registeringAgent: 1, + // joiningAgents: [2], + // }, ], }, ],