diff --git a/pica-mumble-web/.dockerignore b/pica-mumble-web/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..8d3188d51af5570cae953804a4db830dc67f7799 --- /dev/null +++ b/pica-mumble-web/.dockerignore @@ -0,0 +1,3 @@ +README.md +docker-compose.yml +secrets/ diff --git a/pica-mumble-web/Dockerfile b/pica-mumble-web/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..4640db03748866b90d1d70569a0fd7f06f47f84d --- /dev/null +++ b/pica-mumble-web/Dockerfile @@ -0,0 +1,33 @@ +FROM alpine:3.12 + +RUN echo http://nl.alpinelinux.org/alpine/edge/testing >> /etc/apk/repositories && \ + apk add --update --no-cache nodejs npm tini websockify git && \ + git clone --branch features/fabien/framasoft-theme https://framagit.org/fabien4vo/mumble-web.git /home/node && \ + adduser -D -g 1001 -u 1001 -h /home/node node && \ + mkdir -p /home/node && \ + mkdir -p /home/node/.npm-global && \ + mkdir -p /home/node/app && \ + chown -R node: /home/node + +USER node + +ENV PATH=/home/node/.npm-global/bin:$PATH +ENV NPM_CONFIG_PREFIX=/home/node/.npm-global + +RUN cd /home/node && \ + npm install && \ + npm run build + +USER root + +RUN apk del gcc git + +USER node + +COPY index.html /home/node/app/index.html + +EXPOSE 8080 +ENV MUMBLE_SERVER=murmur:64738 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD websockify --ssl-target --web=/home/node/dist 8080 "$MUMBLE_SERVER" diff --git a/pica-mumble-web/clair-whitelist.yml b/pica-mumble-web/clair-whitelist.yml new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/pica-mumble-web/docker-compose.yml b/pica-mumble-web/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..d2aa8e74bb1bc48c8df4ce8e79e5813deddb7314 --- /dev/null +++ b/pica-mumble-web/docker-compose.yml @@ -0,0 +1,22 @@ +version: "2.4" +networks: + docker_default: + external: true + name: "docker_default" + +services: + mumble-web: + image: registry.picasoft.net/pica-mumble-web:1.3.0 + container_name: mumble-web + environment: + MUMBLE_SERVER: "murmur:64738" + networks: + - docker_default + volumes: + - /DATA/docker/volumes/murmur/mumble-web/config.js:/home/node/dist/config.local.js + labels: + - "traefik.frontend.rule=Host:voice.picasoft.net" + - "traefik.port=8080" + - "traefik.frontend.passHostHeader=true" + - "traefik.enable=true" + restart: unless-stopped diff --git a/pica-mumble-web/index.html b/pica-mumble-web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..70409982e1268cf4aa89d4f7b25f1b852a22399f --- /dev/null +++ b/pica-mumble-web/index.html @@ -0,0 +1,652 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <!-- Favicon as generated by realfavicongenerator.net (slightly modified for webpack) --> + <link rel="apple-touch-icon" sizes="180x180" href="favicon/apple-touch-icon.png"> + <link rel="icon" type="image/png" href="favicon/favicon-32x32.png" sizes="32x32"> + <link rel="icon" type="image/png" href="favicon/favicon-16x16.png" sizes="16x16"> + <link rel="manifest" href="favicon/manifest.json"> + <link rel="mask-icon" href="favicon/safari-pinned-tab.svg" color="#5bbad5"> + <link rel="shortcut icon" href="favicon/favicon.ico"> + <meta name="apple-mobile-web-app-title" content="Mumble"> + <meta name="application-name" content="Mumble"> + <meta name="msapplication-config" content="${require('./favicon/browserconfig.xml')}"> + <meta name="theme-color" content="#ffffff"> + + <script src="config.js"></script> + <script src="config.local.js"></script> + <script src="theme.js"></script> + <script src="matrix.js"></script> + </head> + <body> + <div class="loading-container" data-bind="css: { loaded: true }"> + <div class="loading-circle" data-bind="css: { loaded: true }"><div></div><div><div></div></div></div> + </div> + <div id="container" style="display: none" data-bind="visible: true, + css: { minimal: minimalView }"> + + <div class="dialog-overlay" data-bind="visible: connectDialog.visible() && config.defaults.easyMode"> + <div class="startup-dialog dialog"> + <div class="dialog-header" data-bind="text: connected() ? 'Connecté' : 'Préparation'"></div> + <div style="text-align: center">Bienvenue sur le Mumble de Picasoft !</div> + <div style="text-align: center">Ceci est l'interface web, elle permet d'utiliser le service, mais offre beaucoup moins de fonctionnalités que le client lourd. Vous pouvez consulter la <a href="https://voice.picasoft.net/doc">Documentation</a> pour plus d'informations.</div> + <form data-bind="submit: connectDialog.connect"> + <!-- ko if: !connected() --> + <fieldset> + <legend>Rejoindre le salon <span data-bind="text: connectDialog.channelName() == '' ? 'principal' : connectDialog.channelName()"></span></legend> + <label data-bind="visible: !connectDialog.loading()"> + Quel est votre nom ? + <input type="text" data-bind="textInput: connectDialog.username" required autofocus> + </label> + <div data-bind="visible: connectDialog.loading()"> + Connexion en tant que <span data-bind="text: connectDialog.username"></span>, + merci de patienter ... + <div class="small"><div class="loading-container"> + <div class="loading-circle"><div></div><div><div></div></div></div> + </div></div> + </div> + <input class="dialog-submit" type="submit" data-bind="{ + value: !connected() && !connectDialog.loading() ? 'Participer' : 'Connexion...', + disable: !connectDialog.username() || connectDialog.loading() + }"> + </fieldset> + <!-- /ko --> + <!-- ko if: connected() --> + <fieldset> + Bonjour <span data-bind="text: thisUser().name()"></span>, + vous voilà connecté sur le salon <span data-bind="text: thisUser().channel().name()"></span>. + <!-- ko if: thisUser().channel() --> + <p>Il y a <span data-bind="text: thisUser().channel().users().length"></span> participant·e·s à ce salon</p> + <!-- /ko --> + <ul data-bind="foreach: thisUser().channel().users()" style="text-align: left"> + <li> + <span data-bind="{text: name, style: {fontWeight: name() == $parent.thisUser().name() ? 'bold': 'normal'}}"></span> + <div data-bind="class: talking" class="talking"><div><div data-bind="text: talking"></div><div></div></div></div> + </li> + </ul> + <button class="dialog-submit" data-bind="click: connectDialog.hide">Interface complète</button> + </fieldset> + <!-- /ko --> + </form> + </div> + </div> + + <!-- ko with: connectDialog --> + <div class="connect-dialog dialog" data-bind="visible: visible() && !joinOnly() && !config.defaults.easyMode"> + <div class="dialog-header"> + Connect to Server + </div> + <form data-bind="submit: connect"> + <table> + <tr data-bind="if: $root.config.connectDialog.address"> + <td>Address</td> + <td><input id="address" type="text" data-bind="value: address" required></td> + </tr> + <tr data-bind="if: $root.config.connectDialog.port"> + <td>Port</td> + <td><input id="port" type="text" data-bind="value: port" required></td> + </tr> + <tr data-bind="if: $root.config.connectDialog.username"> + <td>Username</td> + <td><input id="username" type="text" data-bind="value: username" required></td> + </tr> + <tr data-bind="if: $root.config.connectDialog.password"> + <td>Password</td> + <td><input id="password" type="password" data-bind="value: password"></td> + </tr> + <tr data-bind="if: $root.config.connectDialog.token"> + <td>Tokens</td> + <td> + <input type="text" data-bind='value: tokenToAdd, valueUpdate: "afterkeydown"'> + </td> + </tr> + <tr data-bind="if: $root.config.connectDialog.token"> + <td></td> + <td> + <button class="dialog-submit" type="button" data-bind="enable: selectedTokens().length > 0, click: removeSelectedTokens()">Remove</button> + <button class="dialog-submit" type="button" data-bind="enable: tokenToAdd().length > 0, click: addToken()">Add</button> + </td> + </tr> + <tr data-bind="if: $root.config.connectDialog.token, visible: tokens().length > 0"> + <td></td> + <td><select id="token" multiple="multiple" height="5" data-bind="options:tokens, selectedOptions:selectedTokens"></select></td> + </tr> + <tr data-bind="if: $root.config.connectDialog.channelName"> + <td>Channel</td> + <td><input id="channelName" type="text" data-bind="value: channelName"></td> + </tr> + </table> + <div class="dialog-footer"> + <input class="dialog-close" type="button" data-bind="click: hide" value="Cancel"> + <input class="dialog-submit" type="submit" value="Connect"> + </div> + </form> + </div> + <!-- /ko --> + <!-- ko with: connectDialog --> + <div class="join-dialog dialog" data-bind="visible: visible() && joinOnly()"> + <div class="dialog-header"> + Mumble Voice Conference + </div> + <form data-bind="submit: connect"> + <input class="dialog-submit" type="submit" value="Join Conference"> + </form> + </div> + <!-- /ko --> + <!-- ko with: connectErrorDialog --> + <div class="connect-dialog error-dialog dialog" data-bind="visible: visible()"> + <div class="dialog-header"> + Failed to connect + </div> + <form data-bind="submit: connect"> + <table> + <tr> + <td colspan=2> + <!-- ko if: type() == 0 || type() == 8 --> + The connection has been refused. + <!-- /ko --> + <!-- ko if: type() == 1 --> + The server uses an incompatible version. + <!-- /ko --> + <!-- ko if: type() == 2 --> + Your user name was rejected. Maybe try a different one? + <!-- /ko --> + <!-- ko if: type() == 3 --> + The given password is incorrect. + The user name you have chosen requires a special one. + <!-- /ko --> + <!-- ko if: type() == 4 --> + The given password is incorrect. + <!-- /ko --> + <!-- ko if: type() == 5 --> + The user name you have chosen is already in use. + <!-- /ko --> + <!-- ko if: type() == 6 --> + The server is full. + <!-- /ko --> + <!-- ko if: type() == 7 --> + The server requires you to provide a client certificate + which is not supported by this web application. + <!-- /ko --> + <br> + The server reports: + <br> + "<span class="connect-error-reason" data-bind="text: reason"></span>" + </td> + </tr> + <tr data-bind="if: type() == 2 || type() == 3 || type() == 5"> + <td>Username</td> + <td><input id="username" type="text" data-bind="value: username" required></td> + </tr> + <tr data-bind="if: type() == 3 || type() == 4"> + <td>Password</td> + <td><input id="password" type="password" data-bind="value: password" required></td> + </tr> + </table> + <div class="dialog-footer"> + <input class="dialog-close" type="button" value="Cancel" + data-bind="click: hide, visible: !joinOnly()"> + <input class="dialog-submit" type="submit" value="Retry"> + </div> + </form> + </div> + <!-- /ko --> + <!-- ko with: connectionInfo --> + <div class="connection-info-dialog dialog" data-bind="visible: visible"> + <div class="dialog-header"> + Connection Information + </div> + <div class="dialog-content"> + <h3>Version</h3> + <!-- ko with: serverVersion --> + Protocol + <span data-bind="text: major + '.' + minor + '.' + patch"></span>. + <br> + <br> + <span data-bind="text: release"></span> + <br> + <span data-bind="text: os"></span> + <span data-bind="text: osVersion"></span> + <br> + <!-- /ko --> + <!-- ko if: !serverVersion() --> + Unknown + <!-- /ko --> + + <h3>Control channel</h3> + <span data-bind="text: latencyMs().toFixed(2)"></span> ms average latency + (<span data-bind="text: latencyDeviation().toFixed(2)"></span> deviation) + <br> + <br> + Remote host <span data-bind="text: remoteHost"></span> + (port <span data-bind="text: remotePort"></span>) + <br> + + <h3>Audio bandwidth</h3> + Maximum <span data-bind="text: (maxBitrate()/1000).toFixed(1)"></span> kbits/s + (<span data-bind="text: (maxBandwidth()/1000).toFixed(1)"></span> kbits/s with overhead) + <br> + Current <span data-bind="text: (currentBitrate()/1000).toFixed(1)"></span> kbits/s + (<span data-bind="text: (currentBandwidth()/1000).toFixed(1)"></span> kbits/s with overhead) + <br> + Codec: <span data-bind="text: codec"></span> + </div> + <div class="dialog-footer"> + <input class="dialog-close" type="button" data-bind="click: hide" value="OK"> + </div> + </div> + <!-- /ko --> + <!-- ko with: settingsDialog --> + <div class="settings-dialog dialog" data-bind="visible: $data"> + <div class="dialog-header"> + Settings + </div> + <form data-bind="submit: $root.applySettings"> + <table> + <tr> + <td>Transmission</td> + <td> + <select data-bind='value: voiceMode'> + <option value="cont">Continuous</option> + <option value="vad">Voice Activity</option> + <option value="ptt">Push To Talk</option> + </td> + </tr> + <tr data-bind="visible: voiceMode() == 'vad'"> + <td colspan="2"> + <div class="mic-volume-container"> + <div class="mic-volume" data-bind="style: { + width: testVadLevel()*100 + '%', + background: testVadActive() ? 'green' : 'red' + }"></div> + </div> + <input type="range" min="0" max="1" step="0.01" + data-bind="value: vadLevel"> + </td> + </tr> + <tr data-bind="visible: voiceMode() == 'ptt'"> + <td>PTT Key</td> + <td> + <input type="button" data-bind="value: pttKeyDisplay, click: recordPttKey"> + </td> + </tr> + <tr> + <td>Audio Quality</td> + <td><span data-bind="text: (audioBitrate()/1000).toFixed(1)"></span> kbit/s</td> + </tr> + <tr> + <td colspan="2"> + <input type="range" min="8000" max="96000" step="8" + data-bind="value: audioBitrate, valueUpdate: 'input'"> + </td> + </tr> + <tr> + <td>Audio per packet</td> + <td><span data-bind="text: msPerPacket"></span> ms</td> + </tr> + <tr> + <td colspan="2"> + <input type="range" min="10" max="60" step="10" + data-bind="value: msPerPacket, valueUpdate: 'input'"> + </td> + </tr> + <tr> + <td colspan="2" class="bandwidth-info"> + <span data-bind="text: (totalBandwidth()/1000).toFixed(1)"></span> + kbit/s + (Audio + <span data-bind="text: (audioBitrate()/1000).toFixed(1)"></span>, + Position + <span data-bind="text: (positionBandwidth()/1000).toFixed(1)"></span>, + Overhead + <span data-bind="text: (overheadBandwidth()/1000).toFixed(1)"></span>) + </td> + </tr> + <tr> + <td>Show Avatars</td> + <td> + <select data-bind='value: showAvatars'> + <option value="always">Always</option> + <option value="own_channel">Same Channel</option> + <option value="linked_channel">Linked Channels</option> + <option value="minimal_only">Minimal View</option> + <option value="never">Never</option> + </td> + </tr> + <tr> + <td colspan="2"> + <input type="checkbox" data-bind="checked: userCountInChannelName"> + Show user count after channel name + </td> + </tr> + </table> + <div class="dialog-footer"> + <input class="dialog-close" type="button" data-bind="click: $root.closeSettings" value="Cancel"> + <input class="dialog-submit" type="submit" value="Apply"> + </div> + </form> + </div> + <!-- /ko --> + <img class="avatar-view" data-bind="visible: avatarView, attr: { src: avatarView }, + click: function () { avatarView(null) }"></img> + <!-- ko with: userContextMenu --> + <ul class="context-menu" data-bind="if: target, + style: { left: posX() + 'px', + top: posY() + 'px' }"> + + <!-- ko with: target --> + <li data-bind="css: { disabled: !canChangeMute() }"> + Mute + </li> + <li data-bind="css: { disabled: !canChangeDeafen() }"> + Deafen + </li> + <li data-bind="css: { disabled: !canChangePrioritySpeaker() }"> + Priority Speaker + </li> + + <li data-bind="css: { disabled: !canLocalMute() }"> + Local Mute + </li> + <li data-bind="css: { disabled: !canIgnoreMessages() }"> + Ignore Messages + </li> + + <li data-bind="css: { disabled: !canChangeComment() }, visible: comment"> + View Comment + </li> + <!-- ko if: $data === $root.thisUser() --> + <li data-bind="css: { disabled: !canChangeComment() }, visible: true"> + Change Comment + </li> + <!-- /ko --> + <!-- ko if: $data !== $root.thisUser() --> + <li data-bind="css: { disabled: !canChangeComment() }, visible: comment"> + Reset Comment + </li> + <!-- /ko --> + + <li data-bind="css: { disabled: !canChangeAvatar() }, visible: texture, + click: viewAvatar"> + View Avatar + </li> + <!-- ko if: $data === $root.thisUser() --> + <li data-bind="css: { disabled: !canChangeAvatar() }, visible: true, + click: changeAvatar"> + Change Avatar + </li> + <!-- /ko --> + <li data-bind="css: { disabled: !canChangeAvatar() }, visible: texture, + click: removeAvatar"> + Reset Avatar + </li> + + <li data-bind="css: { disabled: true }, visible: true"> + Send Message + </li> + <li data-bind="css: { disabled: true }, visible: true"> + Information + </li> + + <li data-bind="visible: $data === $root.thisUser(), + css: { checked: selfMute }, + click: toggleMute"> + Self Mute + </li> + <li data-bind="visible: $data === $root.thisUser(), + css: { checked: selfDeaf }, + click: toggleDeaf"> + Self Deafen + </li> + + <!-- ko if: $data !== $root.thisUser() --> + <li data-bind="css: { disabled: true }, visible: true"> + Add Friend + </li> + <li data-bind="css: { disabled: true }, visible: false"> + Remove Friend + </li> + <!-- /ko --> + + <!-- /ko --> + </ul> + <!-- /ko --> + <!-- ko with: channelContextMenu --> + <ul class="context-menu" data-bind="if: target, + style: { left: posX() + 'px', + top: posY() + 'px' }"> + + <!-- ko with: target --> + <li data-bind="visible: users.indexOf($root.thisUser()) === -1, + css: { disabled: !canJoin() }, + click: $root.requestMove.bind($root, $root.thisUser())"> + Join Channel + </li> + <li data-bind="css: { disabled: !canAdd() }"> + Add + </li> + <li data-bind="css: { disabled: !canEdit() }"> + Edit + </li> + <li data-bind="css: { disabled: !canRemove() }"> + Remove + </li> + + <li data-bind="css: { disabled: !canLink() }"> + Link + </li> + <li data-bind="css: { disabled: !canUnlink() }"> + Unlink + </li> + <li data-bind="css: { disabled: !canUnlink() }"> + Unlink All + </li> + + <li data-bind="css: { disabled: true }"> + Copy Mumble URL + </li> + <li data-bind="css: { disabled: true }"> + Copy Mumble-Web URL + </li> + <li data-bind="css: { disabled: !canSendMessage() }"> + Send Message + </li> + + <!-- /ko --> + </ul> + <!-- /ko --> + <script type="text/html" id="user-tag"> + <span class="user-tag" data-bind="text: name"></span> + </script> + <script type="text/html" id="channel-tag"> + <span class="channel-tag" data-bind="text: name"></span> + </script> + <div class="toolbar" data-bind="css: { 'toolbar-horizontal': toolbarHorizontal(), + 'toolbar-vertical': !toolbarHorizontal() }"> + <img class="handle-horizontal" src="/svg/handle_horizontal.svg" + data-bind="click: toggleToolbarOrientation"> + <img class="handle-vertical" src="/svg/handle_vertical.svg" + data-bind="click: toggleToolbarOrientation"> + <img class="tb-connect" data-bind="visible: !connectDialog.joinOnly(), + click: connectDialog.show" + rel="connect" src="/svg/applications-internet.svg"> + <img class="tb-information" rel="information" src="/svg/information_icon.svg" + data-bind="click: connectionInfo.show, + css: { disabled: !thisUser() }"> + <div class="divider"></div> + <img class="tb-mute" data-bind="visible: !selfMute(), + click: function () { requestMute(thisUser()) }" + rel="mute" src="/svg/audio-input-microphone.svg"> + <img class="tb-unmute tb-active" data-bind="visible: selfMute, + click: function () { requestUnmute(thisUser()) }" + rel="unmute" src="/svg/audio-input-microphone-muted.svg"> + <img class="tb-deaf" data-bind="visible: !selfDeaf(), + click: function () { requestDeaf(thisUser()) }" + rel="deaf" src="/svg/audio-output.svg"> + <img class="tb-undeaf tb-active" data-bind="visible: selfDeaf, + click: function () { requestUndeaf(thisUser()) }" + rel="undeaf" src="/svg/audio-output-deafened.svg"> + <img class="tb-record" data-bind="click: function(){}" + rel="record" src="/svg/media-record.svg"> + <div class="divider"></div> + <img class="tb-comment" data-bind="click: commentDialog.show" + rel="comment" src="/svg/toolbar-comment.svg"> + <div class="divider"></div> + <img class="tb-settings" data-bind="click: openSettings" + rel="settings" src="/svg/config_basic.svg"> + <div class="divider"></div> + <img class="tb-sourcecode" data-bind="click: openSourceCode" + rel="Source Code" src="/svg/source-code.svg"> + </div> + <div class="chat"> + <script type="text/html" id="log-generic"> + <span data-bind="text: value"></span> + </script> + <script type="text/html" id="log-welcome-message"> + Welcome message: <span data-bind="html: message"></span> + </script> + <script type="text/html" id="log-chat-message"> + <span data-bind="visible: channel"> + (Channel) + </span> + <span data-bind="template: { name: 'user-tag', data: user }"></span>: + <span class="message-content" data-bind="html: message"></span> + </script> + <script type="text/html" id="log-chat-message-self"> + To + <span data-bind="template: { if: $data.channel, name: 'channel-tag', data: $data.channel }"> + </span><span data-bind="template: { if: $data.user, name: 'user-tag', data: $data.user }"> + </span>: + <span class="message-content" data-bind="html: message"></span> + </script> + <script type="text/html" id="log-disconnect"> + </script> + <div class="log" data-bind="foreach: { + data: log, + afterRender: function (e) { + [].forEach.call(e[1].getElementsByTagName('a'), function(e){e.target = '_blank'}) + } + }"> + <div class="log-entry"> + <span class="log-timestamp" data-bind="text: $root.getTimeString()"></span> + <!-- ko template: { data: $data, name: function(l) { return 'log-' + l.type; } } --> + <!-- /ko --> + </div> + </div> + <form data-bind="submit: submitMessageBox"> + <input id="message-box" type="text" data-bind=" + attr: { placeholder: messageBoxHint }, textInput: messageBox"> + </form> + </div> + <script type="text/html" id="channel"> + <div class="channel" data-bind=" + click: $root.select, + event: { + contextmenu: openContextMenu, + dblclick: $root.requestMove.bind($root, $root.thisUser()) + }, + css: { + selected: $root.selected() === $data, + currentChannel: users.indexOf($root.thisUser()) !== -1 + }"> + <div class="channel-status"> + <img class="channel-description" data-bind="visible: description" + alt="description" src="/svg/comment.svg"> + </div> + <div data-bind="if: description"> + <div class="channel-description tooltip" data-bind="html: description"></div> + </div> + <img class="channel-icon" src="/svg/channel.svg" + data-bind="visible: !linked() && $root.thisUser().channel() !== $data"> + <img class="channel-icon-active" src="/svg/channel_active.svg" + data-bind="visible: $root.thisUser().channel() === $data"> + <img class="channel-icon-linked" src="/svg/channel_linked.svg" + data-bind="visible: linked() && $root.thisUser().channel() !== $data"> + <div class="channel-name"> + <span data-bind="text: name"></span> + <!-- ko if: $root.settings.userCountInChannelName() && userCount() !== 0 --> + (<span data-bind="text: userCount()"></span>) + <!-- /ko --> + </div> + </div> + <!-- ko if: expanded --> + <!-- ko foreach: users --> + <div class="user-wrapper"> + <div class="user-tree"></div> + <div class="user" data-bind=" + click: $root.select, + event: { + contextmenu: openContextMenu + }, + css: { + thisClient: $root.thisUser() === $data, + selected: $root.selected() === $data + }"> + <div class="user-status" data-bind="attr: { title: state }"> + <img class="user-comment" data-bind="visible: comment" + alt="comment" src="/svg/comment.svg"> + <img class="user-server-mute" data-bind="visible: mute" + alt="server mute" src="/svg/muted_server.svg"> + <img class="user-suppress-mute" data-bind="visible: suppress" + alt="suppressed" src="/svg/muted_suppressed.svg"> + <img class="user-self-mute" data-bind="visible: selfMute" + alt="self mute" src="/svg/muted_self.svg"> + <img class="user-server-deaf" data-bind="visible: deaf" + alt="server deaf" src="/svg/deafened_server.svg"> + <img class="user-self-deaf" data-bind="visible: selfDeaf" + alt="self deaf" src="/svg/deafened_self.svg"> + <img class="user-authenticated" data-bind="visible: uid" + alt="authenticated" src="/svg/authenticated.svg"> + </div> + <div data-bind="if: comment"> + <div class="user-comment tooltip" data-bind="html: comment"></div> + </div> + <!-- ko if: show_avatar() --> + <img class="user-avatar" alt="avatar" + data-bind="attr: { src: texture }, + css: { 'user-avatar-talk-off': talking() == 'off', + 'user-avatar-talk-on': talking() == 'on', + 'user-avatar-talk-whisper': talking() == 'whisper', + 'user-avatar-talk-shout': talking() == 'shout' }"> + <!-- /ko --> + <!-- ko ifnot: show_avatar() --> + <img class="user-talk user-talk-off" data-bind="visible: talking() == 'off'" + alt="talk off" src="/svg/talking_off.svg"> + <img class="user-talk user-talk-on" data-bind="visible: talking() == 'on'" + alt="talk on" src="/svg/talking_on.svg"> + <img class="user-talk user-talk-whisper" data-bind="visible: talking() == 'whisper'" + alt="whisper" src="/svg/talking_whisper.svg"> + <img class="user-talk user-talk-shout" data-bind="visible: talking() == 'shout'" + alt="shout" src="/svg/talking_alt.svg"> + <!-- /ko --> + <div class="user-name" data-bind="text: name"></div> + </div> + </div> + <!-- /ko --> + <!-- ko foreach: channels --> + <div class="channel-wrapper"> + <!-- ko ifnot: users().length || channels().length --> + <div class="channel-tree"></div> + <!-- /ko --> + <div class="branch" data-bind="if: users().length || channels().length"> + <img class="branch-open" src="/svg/branch_open.svg" + data-bind="click: expanded.bind($data, false), visible: expanded()"> + <img class="branch-closed" src="/svg/branch_closed.svg" + data-bind="click: expanded.bind($data, true), visible: !expanded()"> + </div> + <div class="channel-sub" data-bind="template: {name: 'channel', data: $data}"></div> + </div> + <!-- /ko --> + <!-- /ko --> + </script> + <div class="channel-root-container" data-bind="if: root, visible: !minimalView()"> + <div class="channel-root" data-bind="template: {name: 'channel', data: root}"></div> + </div> + <div class="channel-root-container" data-bind="if: thisUser, visible: minimalView()"> + <div class="channel-root" data-bind="template: {name: 'channel', data: thisUser().channel}"></div> + </div> + </div> + </body> + <script src="index.js"></script> +</html>