998 lines
		
	
	
	
		
			30 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			998 lines
		
	
	
	
		
			30 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/*
 | 
						|
 * v0.5.1 generated at Thu May 22 07:30:49 PM CEST 2025
 | 
						|
 * https://xrfragment.org
 | 
						|
 * SPDX-License-Identifier: AGPL-3.0-or-later
 | 
						|
 */
 | 
						|
(function(){
 | 
						|
// this orchestrates multiplayer events from the scene graph
 | 
						|
 | 
						|
window.network = (opts) => new Proxy({
 | 
						|
 | 
						|
  connected: false,
 | 
						|
  pos: '',
 | 
						|
  posName: '',
 | 
						|
  meetinglink: "",
 | 
						|
  peers: {},
 | 
						|
  plugin: {},
 | 
						|
  opts,
 | 
						|
 | 
						|
  init(){
 | 
						|
    document.addEventListener('network.disconnect', () => this.connected = false )
 | 
						|
    document.addEventListener('network.connected',  () => this.connected = true  )
 | 
						|
    setTimeout( () => window.frontend.emit('network.init'), 100 )
 | 
						|
    return this
 | 
						|
  },
 | 
						|
 | 
						|
  connect(opts){
 | 
						|
    window.frontend.emit(`network.${this.connected?'disconnect':'connect'}`,opts)
 | 
						|
  },
 | 
						|
 | 
						|
  add(peerid,data){
 | 
						|
    data = {lastUpdated: new Date().getTime(), id: peerid, ...data }
 | 
						|
    this.peers[peerid] = data 
 | 
						|
    window.frontend.emit(`network.peer.add`,{peer})
 | 
						|
  },
 | 
						|
 | 
						|
  remove(peerid,data){
 | 
						|
    delete this.peers[peerid]
 | 
						|
    window.frontend.emit(`network.peer.remove`,{peer})
 | 
						|
  },
 | 
						|
 | 
						|
  send(opts){
 | 
						|
    window.frontend.emit('network.send',opts)
 | 
						|
  },
 | 
						|
 | 
						|
  receive(opts){
 | 
						|
 | 
						|
  },
 | 
						|
 | 
						|
  getMeetingFromUrl(url){
 | 
						|
    let hash = url.replace(/.*#/,'')
 | 
						|
    let parts = hash.split("&")
 | 
						|
    let meeting = ''
 | 
						|
    parts.map( (p) => {
 | 
						|
      if( p.split("=")[0] == 'meet' ) meeting = p.split("=")[1]
 | 
						|
    })
 | 
						|
    return meeting
 | 
						|
  },
 | 
						|
 | 
						|
  randomRoom(){
 | 
						|
    var names = []
 | 
						|
    let add = (s) => s.length < 6 && !s.match(/[0-9$]/) && !s.match(/_/) ? names.push(s) : false
 | 
						|
    for ( var i in window             ) add(i)
 | 
						|
    for ( var i in Object.prototype   ) add(i)
 | 
						|
    for ( var i in Function.prototype ) add(i)
 | 
						|
    for ( var i in Array.prototype    ) add(i)
 | 
						|
    for ( var i in String.prototype   ) add(i) 
 | 
						|
    var a = names[Math.floor(Math.random() * names.length)];
 | 
						|
    var b = names[Math.floor(Math.random() * names.length)];
 | 
						|
    return String(`${a}-${b}-${String(Math.random()).substr(13)}`).toLowerCase()
 | 
						|
  }
 | 
						|
 | 
						|
},
 | 
						|
{ 
 | 
						|
  // auto-trigger events on changes 
 | 
						|
  get(data,k,receiver){ return data[k] },
 | 
						|
  set(data,k,v){
 | 
						|
    let from   = data[k]
 | 
						|
    data[k] = v
 | 
						|
  }
 | 
						|
})
 | 
						|
      
 | 
						|
document.addEventListener('frontend:ready', (e) => {
 | 
						|
  window.network = network(e.detail).init()
 | 
						|
  document.dispatchEvent( new CustomEvent("network:ready", e ) )
 | 
						|
})
 | 
						|
connectionsComponent = {
 | 
						|
 | 
						|
  html: `
 | 
						|
   <div id="connections">
 | 
						|
      <i class="gg-close-o" id="close" onclick="$connections.visible = false"></i>
 | 
						|
      <br>
 | 
						|
      <div class="tab-frame">
 | 
						|
        <input type="radio" name="tab" id="login" checked>
 | 
						|
        <label for="login">login</label>
 | 
						|
 | 
						|
        <input type="radio" name="tab" id="io">
 | 
						|
        <label for="io">devices</label>
 | 
						|
       
 | 
						|
        <input type="radio" name="tab" id="networks">
 | 
						|
        <label for="networks">advanced</label>
 | 
						|
          
 | 
						|
        <div class="tab">
 | 
						|
          <div id="settings"></div>
 | 
						|
          <table>
 | 
						|
            <tr>
 | 
						|
              <td></td>
 | 
						|
              <td>
 | 
						|
                <button id="connect" onclick="network.connect( $connections )">📡 Connect!</button>
 | 
						|
              </td>
 | 
						|
            </tr>
 | 
						|
        </table>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <div class="tab">
 | 
						|
          <div id="devices">
 | 
						|
            <a class="badge ruler">Webcam and/or Audio</a>
 | 
						|
            <table>
 | 
						|
              <tr>
 | 
						|
                <td>Video</td>
 | 
						|
                <td>
 | 
						|
                  <select id="videoInput"></select> 
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
              <tr>
 | 
						|
                <td>Mic</td>
 | 
						|
                <td>
 | 
						|
                  <select id="audioInput"></select> 
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
              <tr style="display:none"> <!-- not used (for now) -->
 | 
						|
                <td>Audio</td>
 | 
						|
                <td>
 | 
						|
                  <select id="audioOutput"></select> 
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
            </table>
 | 
						|
          </div>
 | 
						|
        </div>
 | 
						|
 | 
						|
        <div class="tab">
 | 
						|
          <div id="networking">
 | 
						|
            Networking a la carte:<br>
 | 
						|
            <table>
 | 
						|
              <tr>
 | 
						|
                <td>Webcam</td>
 | 
						|
                <td>
 | 
						|
                  <select id="webcam"></select>
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
              <tr>
 | 
						|
                <td>Chat</td>
 | 
						|
                <td>
 | 
						|
                  <select id="chatnetwork"></select>
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
              <tr>
 | 
						|
                <td>World sync</td>
 | 
						|
                <td>
 | 
						|
                  <select id="scene"></select>
 | 
						|
                </td>
 | 
						|
              </tr>
 | 
						|
            </table>
 | 
						|
          </div>
 | 
						|
        </div>
 | 
						|
      </div>
 | 
						|
    </div>
 | 
						|
  `,
 | 
						|
 | 
						|
  init: (el) => new Proxy({
 | 
						|
 | 
						|
    visible: true,
 | 
						|
 | 
						|
    webcam:       [{profile:{name:"No thanks"},config: () => document.createElement('div')}],
 | 
						|
    chatnetwork:  [{profile:{name:"No thanks"},config: () => document.createElement('div')}],
 | 
						|
    scene:        [{profile:{name:"No thanks"},config: () => document.createElement('div')}],
 | 
						|
 | 
						|
    selectedWebcam:     '',
 | 
						|
    selectedChatnetwork:'',
 | 
						|
    selectedScene:      '',
 | 
						|
 | 
						|
    $webcam:       $webcam      = el.querySelector("#webcam"),
 | 
						|
    $chatnetwork:  $chatnetwork = el.querySelector("#chatnetwork"),
 | 
						|
    $scene:        $scene       = el.querySelector("#scene"),
 | 
						|
    $settings:     $settings    = el.querySelector("#settings"),
 | 
						|
    $devices:      $devices     = el.querySelector("#devices"),
 | 
						|
    $connect:      $connect     = el.querySelector("#connect"),
 | 
						|
    $networking:   $networking  = el.querySelector("#networking"),
 | 
						|
 
 | 
						|
    $audioInput:   el.querySelector('select#audioInput'),
 | 
						|
    $audioOutput:  el.querySelector('select#audioOutput'),
 | 
						|
    $videoInput:   el.querySelector('select#videoInput'),
 | 
						|
    
 | 
						|
    install(opts){
 | 
						|
      this.opts  = opts;
 | 
						|
      (['change']).map( (e) => el.addEventListener(e, (ev) => this[e] && this[e](ev.target.id,ev) ) )
 | 
						|
      this.reactToNetwork()
 | 
						|
      $menu.buttons = ([
 | 
						|
        `<a class="btn" aria-label="button" aria-title="connect button" aria-description="use this to talk or chat with other people" id="meeting" onclick="$connections.show()"><i class="gg-user-add"></i> connect</a><br>`
 | 
						|
      ]).concat($menu.buttons)
 | 
						|
 | 
						|
      if( document.location.href.match(/meet=/) ) this.show()
 | 
						|
 | 
						|
      setTimeout( () => document.dispatchEvent( new CustomEvent("$connections:ready", {detail: opts}) ), 1 )
 | 
						|
    },
 | 
						|
 | 
						|
    toggle(){
 | 
						|
      $chat.visible = !$chat.visible 
 | 
						|
    },
 | 
						|
 | 
						|
    change(id,e){
 | 
						|
      if( id.match(/^(webcam|chatnetwork|scene)$/) ){
 | 
						|
        this.renderSettings() // trigger this when 'change' event fires on children dom elements
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    show(opts){
 | 
						|
      opts = opts || {}
 | 
						|
      if( opts.hide ){
 | 
						|
        if( el.parentElement ) el.parentElement.parentElement.style.display = 'none' // hide along with wrapper elements
 | 
						|
        if( !opts.showChat ) $chat.visible = false
 | 
						|
      }else{
 | 
						|
        $chat.visible = true
 | 
						|
        this.visible  = true
 | 
						|
        // hide networking settings if entering thru meetinglink
 | 
						|
        $networking.style.display = document.location.href.match(/meet=/) ? 'none' : 'block'
 | 
						|
        if( !network.connected ){
 | 
						|
            document.querySelector('body > .xrf').appendChild(el)
 | 
						|
            $chat.send({message:"", el, class:['ui']})
 | 
						|
            if( !network.meetinglink ){ // set default
 | 
						|
              $webcam.value      = opts.webcam      || 'Peer2Peer'
 | 
						|
              $chatnetwork.value = opts.chatnetwork || 'Peer2Peer'
 | 
						|
              $scene.value       = opts.scene       || 'Peer2Peer'
 | 
						|
            }
 | 
						|
            this.renderSettings()
 | 
						|
        }else{
 | 
						|
          $chat.send({message:"you are already connected, refresh page to create new connection",class:['info']})
 | 
						|
        }
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    update(){
 | 
						|
      this.selectedWebcam      = $webcam.value
 | 
						|
      this.selectedChatnetwork = $chatnetwork.value
 | 
						|
      this.selectedScene       = $scene.value
 | 
						|
    },
 | 
						|
 | 
						|
    forSelectedPluginsDo(cb){
 | 
						|
      // this function looks weird but it's handy to prevent the same plugins rendering duplicate configurations
 | 
						|
      let plugins = {}
 | 
						|
      let select = (name) => (o) => o.profile.name == name ? plugins[ o.profile.name ] = o : ''
 | 
						|
      this.webcam.find( select(this.selectedWebcam) )
 | 
						|
      this.chatnetwork.find( select(this.selectedChatnetwork) )
 | 
						|
      this.scene.find( select(this.selectedScene) )
 | 
						|
      for( let i in plugins ){
 | 
						|
        try{ cb(plugins[i]) }catch(e){ console.error(e) }
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    renderSettings(){
 | 
						|
      let opts = {webcam: $webcam.value, chatnetwork: $chatnetwork.value, scene: $scene.value }
 | 
						|
      this.update()
 | 
						|
      $settings.innerHTML = ''
 | 
						|
      this.forSelectedPluginsDo( (plugin) => $settings.appendChild( plugin.config({...opts,plugin}) ) )
 | 
						|
      this.renderInputs()
 | 
						|
    },
 | 
						|
 | 
						|
    renderInputs(){
 | 
						|
      if( !this.selectedWebcam || this.selectedWebcam == 'No thanks' ){
 | 
						|
        return this.$devices.style.display = 'none' 
 | 
						|
      }else this.$devices.style.display = ''
 | 
						|
 | 
						|
      navigator.mediaDevices.getUserMedia({
 | 
						|
        audio: true,
 | 
						|
        video: true
 | 
						|
      })
 | 
						|
      .then( () => {
 | 
						|
 | 
						|
        const selectors = [this.$audioInput, this.$audioOutput, this.$videoInput];
 | 
						|
 | 
						|
        const gotDevices = (deviceInfos) => {
 | 
						|
          // Handles being called several times to update labels. Preserve values.
 | 
						|
          const values = selectors.map(select => select.value);
 | 
						|
          selectors.forEach(select => {
 | 
						|
            while (select.firstChild) {
 | 
						|
              select.removeChild(select.firstChild);
 | 
						|
            }
 | 
						|
          });
 | 
						|
          for (let i = 0; i !== deviceInfos.length; ++i) {
 | 
						|
            const deviceInfo = deviceInfos[i];
 | 
						|
            const option = document.createElement('option');
 | 
						|
            option.value = deviceInfo.deviceId;
 | 
						|
            if (deviceInfo.kind === 'audioinput') {
 | 
						|
              option.text = deviceInfo.label || `microphone ${this.$audioInput.length + 1}`;
 | 
						|
              this.$audioInput.appendChild(option);
 | 
						|
            } else if (deviceInfo.kind === 'audiooutput') {
 | 
						|
              option.text = deviceInfo.label || `speaker ${this.$audioOutput.length + 1}`;
 | 
						|
              this.$audioOutput.appendChild(option);
 | 
						|
            } else if (deviceInfo.kind === 'videoinput') {
 | 
						|
              option.text = deviceInfo.label || `camera this.${this.$videoInput.length + 1}`;
 | 
						|
              this.$videoInput.appendChild(option);
 | 
						|
            } else {
 | 
						|
              console.log('Some other kind of source/device: ', deviceInfo);
 | 
						|
            }
 | 
						|
          }
 | 
						|
          // hide if there's nothing to choose
 | 
						|
          let totalDevices = this.$audioInput.options.length + this.$audioOutput.options.length + this.$videoInput.options.length
 | 
						|
          this.$devices.style.display = totalDevices > 3 ? 'block' : 'none'
 | 
						|
 | 
						|
          selectors.forEach((select, selectorIndex) => {
 | 
						|
            if (Array.prototype.slice.call(select.childNodes).some(n => n.value === values[selectorIndex])) {
 | 
						|
              select.value = values[selectorIndex];
 | 
						|
            }
 | 
						|
          });
 | 
						|
        }
 | 
						|
        // after getUserMedia we can enumerate
 | 
						|
        navigator.mediaDevices.enumerateDevices().then(gotDevices).catch(console.warn);
 | 
						|
      })
 | 
						|
    },
 | 
						|
 | 
						|
    reactToNetwork(){ // *TODO* move to network?
 | 
						|
 | 
						|
      document.addEventListener('network.connect',    () => {
 | 
						|
        this.show({hide:true, showChat: true})
 | 
						|
      })
 | 
						|
      document.addEventListener('network.disconnect',    () => {
 | 
						|
        this.connected = false 
 | 
						|
      })
 | 
						|
 | 
						|
    }
 | 
						|
 | 
						|
  },{
 | 
						|
 | 
						|
    get(data,k,v){ return data[k] },
 | 
						|
    set(data,k,v){ 
 | 
						|
      data[k] = v 
 | 
						|
      switch( k ){
 | 
						|
        case "visible":             el.style.display = v ? '' : 'none'; 
 | 
						|
                                    if( !v && el.parentNode && el.parentNode.parentNode ) el.parentNode.parentNode.remove() 
 | 
						|
                                    break;
 | 
						|
        case "webcam":              $webcam.innerHTML       = `<option>${data[k].map((p)=>p.profile.name).join('</option><option>')}</option>`; break;
 | 
						|
        case "chatnetwork":         $chatnetwork.innerHTML  = `<option>${data[k].map((p)=>p.profile.name).join('</option><option>')}</option>`; break;
 | 
						|
        case "scene":               $scene.innerHTML        = `<option>${data[k].map((p)=>p.profile.name).join('</option><option>')}</option>`; break;
 | 
						|
        case "selectedScene":       $scene.value       = v; data.renderSettings(); break;
 | 
						|
        case "selectedChatnetwork": $chatnetwork.value = v; data.renderSettings(); break;
 | 
						|
        case "selectedWebcam":      {
 | 
						|
                                        $webcam.value      = v; 
 | 
						|
                                        data.renderSettings(); 
 | 
						|
                                        $devices.style.display = v ? 'block' : 'none'
 | 
						|
                                        break;
 | 
						|
                                    }
 | 
						|
 | 
						|
      } 
 | 
						|
    }
 | 
						|
 | 
						|
  })
 | 
						|
}
 | 
						|
 | 
						|
// reactify component!
 | 
						|
document.addEventListener('$chat:ready', (opts) => {
 | 
						|
  opts = opts.detail
 | 
						|
  document.head.innerHTML += connectionsComponent.css 
 | 
						|
  window.$connections = document.createElement('div')
 | 
						|
  $connections.innerHTML = connectionsComponent.html
 | 
						|
  $connections = connectionsComponent.init($connections)
 | 
						|
  $connections.install(opts)
 | 
						|
})
 | 
						|
 | 
						|
// alpine component for displaying meetings
 | 
						|
 | 
						|
connectionsComponent.css = `
 | 
						|
    <style type="text/css">
 | 
						|
      button#connect{
 | 
						|
        height: 43px;
 | 
						|
        width:100%;
 | 
						|
        margin: 0px;
 | 
						|
      }
 | 
						|
      #messages .msg #connections{
 | 
						|
        position:relative;
 | 
						|
      }
 | 
						|
      .connecthide {
 | 
						|
        transform:translateY(-1000px);
 | 
						|
      }
 | 
						|
      #close{
 | 
						|
        display: block;
 | 
						|
        position: relative;
 | 
						|
        float: right;
 | 
						|
        top: 16px;
 | 
						|
      }
 | 
						|
      #messages .msg.ui  div.tab-frame > div.tab{ padding:25px 10px 5px 10px;}
 | 
						|
   </style>`
 | 
						|
chatComponent = {
 | 
						|
 | 
						|
  html: `
 | 
						|
    <div id="chat">
 | 
						|
     <div id="videos" style="pointer-events:none"></div>
 | 
						|
     <div id="messages" aria-live="assertive" role="log" aria-relevant="additions"></div>
 | 
						|
     <div id="chatfooter">
 | 
						|
       <div id="chatbar">
 | 
						|
           <input id="chatline" type="text" placeholder="chat here"></input>
 | 
						|
       </div>
 | 
						|
       <button id="chatsend" class="btn" aria-label="send message">
 | 
						|
          <i class="gg-chevron-right-o"></i>
 | 
						|
       </button>
 | 
						|
     </div>
 | 
						|
    </div>
 | 
						|
  `,
 | 
						|
 | 
						|
  init: (el) => new Proxy({
 | 
						|
 | 
						|
    scene:             null,
 | 
						|
    visible:           true,
 | 
						|
    messages:          [],
 | 
						|
    oneMessagePerUser: false,
 | 
						|
 | 
						|
    username: '',    // configured by 'network.connected' event
 | 
						|
 | 
						|
    $videos:         el.querySelector("#videos"),
 | 
						|
    $messages:       el.querySelector("#messages"),
 | 
						|
    $chatline:       el.querySelector("#chatline"),
 | 
						|
    $chatbar:        el.querySelector("#chatbar"),
 | 
						|
    $chatsend:       el.querySelector("#chatsend"),
 | 
						|
    
 | 
						|
    install(opts){
 | 
						|
      this.opts  = opts
 | 
						|
      this.scene = opts.scene 
 | 
						|
      this.$chatbar.style.display = 'none'
 | 
						|
      el.className = "xrf"
 | 
						|
      el.style.display = 'none' // start hidden 
 | 
						|
      document.body.appendChild( el )
 | 
						|
      document.dispatchEvent( new CustomEvent("$chat:ready", {detail: opts}) )
 | 
						|
      this.send({message:`Welcome to <b>${document.location.search.substr(1)}</b>, a 3D scene(file) which simply links to other ones.<br>You can start a solo offline exploration in XR right away.<br>Type /help below, or use the arrow- or WASD-keys on your keyboard, and mouse-drag to rotate.<br>`, class: ["info","guide","multiline"] })
 | 
						|
    },
 | 
						|
 | 
						|
    sendInput(value){
 | 
						|
      if( value[0] == '#' ) return xrf.navigator.to(value)
 | 
						|
      let event   = value.match(/^[!\/]/) ? "chat.command" : "network.send"
 | 
						|
      let message = value.replace(/^[!\/]/,'')
 | 
						|
      let raw     = {detail:{message:value, halt:false}}
 | 
						|
      document.dispatchEvent( new CustomEvent( event, {detail: {message}} ) )
 | 
						|
      document.dispatchEvent( new CustomEvent( "chat.input", raw ) )
 | 
						|
      if( event == "network.send" && !raw.detail.halt ) this.send({message: value })
 | 
						|
      this.$chatline.lastValue = value
 | 
						|
      this.$chatline.value = ''
 | 
						|
      if( window.innerHeight < 600 ) this.$chatline.blur()
 | 
						|
    },
 | 
						|
 | 
						|
    initListeners(){
 | 
						|
      let {$chatline} = this
 | 
						|
 | 
						|
      $chatline.addEventListener('click', (e) => this.inform() )
 | 
						|
 | 
						|
      $chatline.addEventListener('keydown', (e) => {
 | 
						|
        if (e.key == 'Enter' ){
 | 
						|
          this.sendInput($chatline.value)
 | 
						|
        }
 | 
						|
        if (e.key == 'ArrowUp' ){
 | 
						|
          $chatline.value = $chatline.lastValue || ''
 | 
						|
        }
 | 
						|
      })
 | 
						|
 | 
						|
      document.addEventListener('network.connect', (e) => {
 | 
						|
        this.visible = true
 | 
						|
        this.$chatbar.style.display = '' // show
 | 
						|
      })
 | 
						|
 | 
						|
      document.addEventListener('network.connected', (e) => {
 | 
						|
        if( e.detail.username ) this.username = e.detail.username
 | 
						|
      })
 | 
						|
 | 
						|
      document.addEventListener('chat.command', (e) => {
 | 
						|
        if( String(e.detail.message).trim() == 'help' ){
 | 
						|
          let detail = {message:`The following commands are available
 | 
						|
          <br><br>
 | 
						|
          <b class="badge">/help</b> shows this help screen
 | 
						|
          `}
 | 
						|
          document.dispatchEvent( new CustomEvent( 'chat.command.help', {detail}))
 | 
						|
          this.send({message: detail.message})
 | 
						|
        }
 | 
						|
      })
 | 
						|
 | 
						|
      this.$chatsend.addEventListener('click', (e) => {
 | 
						|
        this.sendInput($chatline.value)
 | 
						|
      })
 | 
						|
 | 
						|
    },
 | 
						|
 | 
						|
    inform(){
 | 
						|
      if( !this.inform.informed && (this.inform.informed = true) ){
 | 
						|
        window.notify("You can now type messages in the textfield below.")
 | 
						|
      }
 | 
						|
    },
 | 
						|
 | 
						|
    toggle(){
 | 
						|
      this.visible = !this.visible
 | 
						|
      if( this.visible && window.meeting.status == 'offline' ) window.meeting.start(this.opts)
 | 
						|
    },
 | 
						|
 | 
						|
    hyphenate(str){
 | 
						|
      return String(str).replace(/[^a-zA-Z0-9]/g,'-')
 | 
						|
    },
 | 
						|
 | 
						|
    // sending messages to the #messages div 
 | 
						|
    // every user can post maximum one msg at a time 
 | 
						|
    // it's more like a 'status' which is more friendly 
 | 
						|
    // for accessibility reasons 
 | 
						|
    // for a fullfledged chat/transcript see matrix clients
 | 
						|
    send(opts){
 | 
						|
      let {$messages} = this
 | 
						|
      opts = { linebreak:true, message:"", class:[], ...opts }
 | 
						|
      if( window.frontend && window.frontend.emit ) window.frontend.emit('$chat.send', opts )
 | 
						|
      opts.pos  = opts.pos || network.posName || network.pos
 | 
						|
      let div  = document.createElement('div')
 | 
						|
      let msg  = document.createElement('div')
 | 
						|
      let br   = document.createElement('br')
 | 
						|
      let nick = document.createElement('div')
 | 
						|
      msg.className = "msg"
 | 
						|
      let html = `${ opts.message || ''}${ opts.html ? opts.html(opts) : ''}`
 | 
						|
      if( $messages.last == html ) return
 | 
						|
      msg.innerHTML = html 
 | 
						|
      if( opts.el ) msg.appendChild(opts.el)
 | 
						|
      opts.id       = Math.random()
 | 
						|
      if( opts.class ){
 | 
						|
        msg.classList.add.apply(msg.classList, opts.class)
 | 
						|
        br.classList.add.apply(br.classList, opts.class)
 | 
						|
        div.classList.add.apply(div.classList, opts.class.concat(["envelope"]))
 | 
						|
      }
 | 
						|
      if( !msg.className.match(/(info|guide|ui)/) && !opts.from ){
 | 
						|
        let frag = xrf.URI.parse(document.location.hash).XRF
 | 
						|
        opts.from = 'you'
 | 
						|
        if( frag.pos ) opts.pos = frag.pos.string
 | 
						|
        msg.classList.add('self')
 | 
						|
      }
 | 
						|
      if( opts.from ){
 | 
						|
        nick.className = "user"
 | 
						|
        nick.innerText = opts.from+' '
 | 
						|
        div.appendChild(nick)
 | 
						|
        if( opts.pos ){
 | 
						|
          let a = document.createElement("a")
 | 
						|
          a.href = a.innerText = `#pos=${opts.pos}`
 | 
						|
          nick.appendChild(a)
 | 
						|
        }
 | 
						|
      }
 | 
						|
      div.appendChild(msg)
 | 
						|
      // force one message per user 
 | 
						|
      if( this.oneMessagePerUser && opts.from ){
 | 
						|
        div.id = this.hyphenate(opts.from)
 | 
						|
        let oldMsg = $messages.querySelector(`#${div.id}`)
 | 
						|
        if( oldMsg ) oldMsg.remove()
 | 
						|
      }
 | 
						|
      // remove after timeout
 | 
						|
      if( opts.timeout ) setTimeout( (div) => div.remove(), opts.timeout, div )
 | 
						|
      // finally add the message on top
 | 
						|
      $messages.appendChild(div)
 | 
						|
      if( opts.linebreak ) div.appendChild(br)
 | 
						|
      $messages.scrollTop = $messages.scrollHeight // scroll down
 | 
						|
      $messages.last = msg.innerHTML
 | 
						|
    },
 | 
						|
 | 
						|
    getChatLog(){
 | 
						|
        return ([...this.$messages.querySelectorAll('.envelope')])
 | 
						|
                .filter( (d) => !d.className.match(/(info|ui)/) ) 
 | 
						|
                .map( (d) => d.innerHTML )
 | 
						|
                .join('\n')
 | 
						|
    }
 | 
						|
 | 
						|
  },{
 | 
						|
 | 
						|
    get(me,k,v){ return me[k] },
 | 
						|
    set(me,k,v){ 
 | 
						|
      me[k] = v    
 | 
						|
      switch( k ){
 | 
						|
        case "visible":         { 
 | 
						|
                                  el.style.display = me.visible ? 'block' : 'none'
 | 
						|
                                  if( !el.inited && (el.inited = true) ) me.initListeners()
 | 
						|
                                  break;
 | 
						|
                                }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
  })
 | 
						|
}
 | 
						|
 | 
						|
// reactify component!
 | 
						|
document.addEventListener('$menu:ready', (opts) => {
 | 
						|
  opts = opts.detail
 | 
						|
  document.head.innerHTML += chatComponent.css 
 | 
						|
  window.$chat = document.createElement('div')
 | 
						|
  $chat.innerHTML = chatComponent.html
 | 
						|
  $chat = chatComponent.init($chat)
 | 
						|
  $chat.install(opts)
 | 
						|
  //$menu.buttons = ([`<a class="btn" aria-label="button" aria-description="toggle text" id="meeting" onclick="$chat.toggle()">📜 toggle text</a><br>`])
 | 
						|
  //                  .concat($menu.buttons)
 | 
						|
})
 | 
						|
 | 
						|
// alpine component for displaying meetings
 | 
						|
 | 
						|
chatComponent.css = `
 | 
						|
    <style type="text/css">
 | 
						|
     #videos{
 | 
						|
       display:grid-auto-columns;
 | 
						|
       grid-column-gap:5px;
 | 
						|
       margin-bottom:15px;
 | 
						|
       position: fixed;
 | 
						|
       top: 0;
 | 
						|
       left: 0;
 | 
						|
       bottom: 0;
 | 
						|
       right: 0;
 | 
						|
       margin: 15px;
 | 
						|
       z-index:1000;
 | 
						|
     }
 | 
						|
     #videos > video{
 | 
						|
       border-radius:7px;
 | 
						|
       display:inline-block;
 | 
						|
       background:black;
 | 
						|
       width:80px;
 | 
						|
       height:60px;
 | 
						|
       margin-right:5px;
 | 
						|
       margin-bottom:5px;
 | 
						|
       vertical-align:top;
 | 
						|
       pointer-events:all;
 | 
						|
     }
 | 
						|
     #videos > video:hover{
 | 
						|
       filter: brightness(1.8);
 | 
						|
       cursor:pointer;
 | 
						|
     }
 | 
						|
 | 
						|
     #chatbar,
 | 
						|
     button#chatsend{
 | 
						|
       z-index: 1500;
 | 
						|
       position: fixed;
 | 
						|
       bottom: 24px;
 | 
						|
       height: 34px;
 | 
						|
       left: 20px;
 | 
						|
       width: 48%;
 | 
						|
       background: white;
 | 
						|
       padding: 0px 0px 0px 15px; 
 | 
						|
       border-radius: 30px;
 | 
						|
       max-width: 500px;
 | 
						|
       box-sizing: border-box;
 | 
						|
       box-shadow: 0px 0px 5px 5px #0002;
 | 
						|
     }
 | 
						|
     button#chatsend{
 | 
						|
      line-height:0px;
 | 
						|
      display:none;
 | 
						|
      z-index: 1550;
 | 
						|
      color: white;
 | 
						|
      border: 0;
 | 
						|
      height: 35px;
 | 
						|
      background: var(--xrf-dark-gray);
 | 
						|
      font-weight: bold;
 | 
						|
      width: 20px;
 | 
						|
      max-width: 20px;
 | 
						|
      border-radius: 20px 0px 0px 20px;
 | 
						|
      overflow: hidden;
 | 
						|
      margin:0;
 | 
						|
     }
 | 
						|
     #chatbar input{
 | 
						|
       border:none;
 | 
						|
       width:90%;
 | 
						|
       box-sizing:border-box;
 | 
						|
       height: 24px;
 | 
						|
       font-size: var(--xrf-font-size-2);
 | 
						|
       max-width:unset;
 | 
						|
     }
 | 
						|
     #messages{
 | 
						|
       /*
 | 
						|
       display: flex;
 | 
						|
       flex-direction: column;
 | 
						|
       width: 100%;
 | 
						|
       max-width: 40%;
 | 
						|
       */
 | 
						|
       width:100%;
 | 
						|
       box-sizing:border-box;
 | 
						|
       align-items: flex-start;
 | 
						|
       position: absolute;
 | 
						|
       transition:1s;
 | 
						|
       top: 77px;
 | 
						|
       left: 0;
 | 
						|
       bottom: 49px;
 | 
						|
       padding: 20px;
 | 
						|
       overflow:hidden;
 | 
						|
       overflow-y: auto;
 | 
						|
       pointer-events:none;
 | 
						|
       transition:1s;
 | 
						|
       z-index: 100;
 | 
						|
     }
 | 
						|
     body.menu #messages{
 | 
						|
       top:50px;
 | 
						|
     }
 | 
						|
     #messages:hover {
 | 
						|
       pointer-events:all;
 | 
						|
     }
 | 
						|
     #messages *{
 | 
						|
       box-sizing:border-box;
 | 
						|
/*
 | 
						|
       pointer-events:none;
 | 
						|
       -webkit-user-select:none;
 | 
						|
       -moz-user-select:-moz-none;
 | 
						|
       -ms-user-select:none;
 | 
						|
       user-select:none;
 | 
						|
*/
 | 
						|
     }
 | 
						|
     #messages .msg{
 | 
						|
       transition:all 1s ease;
 | 
						|
       background: #fff;
 | 
						|
       display: inline-block;
 | 
						|
       padding: 1px 17px;
 | 
						|
       border-radius: 20px;
 | 
						|
       color: #000c;
 | 
						|
       margin-bottom: 10px;
 | 
						|
       line-height:23px;
 | 
						|
       line-height:33px;
 | 
						|
       cursor:grabbing;
 | 
						|
       border: 1px solid #0002;
 | 
						|
     }
 | 
						|
     #messages .msg *,
 | 
						|
     #messages .user *{
 | 
						|
       pointer-events:all;
 | 
						|
       -webkit-user-select:text;
 | 
						|
       -moz-user-select:-moz-text;
 | 
						|
       -ms-user-select:text;
 | 
						|
       user-select:text;
 | 
						|
    }
 | 
						|
 | 
						|
     #messages .msg.self{
 | 
						|
       border-radius: 20px;
 | 
						|
       background:var(--xrf-dark-gray);
 | 
						|
     }
 | 
						|
     #messages .msg.self,
 | 
						|
     #messages .msg.self div{
 | 
						|
       color:#FFF;
 | 
						|
     }
 | 
						|
     #messages .msg.info{
 | 
						|
       background: var(--xrf-white);
 | 
						|
       border-radius: 20px;
 | 
						|
       color: var(--xrf-dark-gray);
 | 
						|
       text-align: left;
 | 
						|
       line-height: 19px;
 | 
						|
     }
 | 
						|
     #messages .msg.info,
 | 
						|
     #messages .msg.info *{
 | 
						|
       font-size: var(--xrf-font-size-0);
 | 
						|
     }
 | 
						|
     #messages .msg a {
 | 
						|
       text-decoration:underline;
 | 
						|
       color: var(--xrf-light-xrf-secondary);
 | 
						|
       font-weight:bold;
 | 
						|
       transition:0.3s;
 | 
						|
     }
 | 
						|
     #messages .msg.info a,
 | 
						|
     #messages a.ruler{
 | 
						|
       color:#FFF;
 | 
						|
     }
 | 
						|
     #messages .msg a:hover{
 | 
						|
        color:#000;
 | 
						|
     }
 | 
						|
     #messages .msg.ui, 
 | 
						|
     #messages .msg.ui div{ 
 | 
						|
       background: white;
 | 
						|
       border:none;
 | 
						|
       color: #333;
 | 
						|
       border-radius: 20px;
 | 
						|
       margin:0;
 | 
						|
       padding:0px 5px 5px 5px;
 | 
						|
     }
 | 
						|
     #messages.guide, .guide{
 | 
						|
      display:unset;
 | 
						|
     }
 | 
						|
     #messages .guide, .guide{
 | 
						|
       display:none;
 | 
						|
     }
 | 
						|
     br.guide{
 | 
						|
       display:inline-block;
 | 
						|
     }
 | 
						|
     #messages .msg.info a:hover,
 | 
						|
     #messages button:hover{
 | 
						|
       filter: brightness(1.4);
 | 
						|
     }
 | 
						|
     #messages .msg.multiline {
 | 
						|
       padding: 2px 14px;
 | 
						|
     }
 | 
						|
     #messages button {
 | 
						|
       text-decoration:none;
 | 
						|
       margin: 0px 15px 10px 0px;
 | 
						|
       background: var(--xrf-primary);
 | 
						|
       font-family: var(--xrf-font-sans-serif);
 | 
						|
       color: #FFF;
 | 
						|
       border-radius: 7px;
 | 
						|
       padding: 11px 15px;
 | 
						|
       border: 0;
 | 
						|
       font-weight: bold;
 | 
						|
       box-shadow: 0px 0px 5px 5px #0002;
 | 
						|
       pointer-events:all;
 | 
						|
     }
 | 
						|
     #messages,#chatbar,#chatbar *, #messages *{
 | 
						|
     }
 | 
						|
 | 
						|
 | 
						|
    #messages button.emoticon,
 | 
						|
    #messages .btn.emoticon {
 | 
						|
      line-height:2px;
 | 
						|
      width: 20px;
 | 
						|
      display: inline-block;
 | 
						|
      padding: 0px 0px;
 | 
						|
      margin: 0;
 | 
						|
      vertical-align: middle;
 | 
						|
      background: none;
 | 
						|
      border: none;
 | 
						|
      min-width: 31px;
 | 
						|
      box-shadow:none;
 | 
						|
    }
 | 
						|
 | 
						|
    #messages button.emoticon:hover,
 | 
						|
    #messages .btn.emoticon:hover {
 | 
						|
      border: 1px solid #ccc !important;
 | 
						|
      background:#EEE;
 | 
						|
    }
 | 
						|
 | 
						|
    .nomargin{
 | 
						|
      margin:0;
 | 
						|
    }
 | 
						|
 | 
						|
    .envelope{
 | 
						|
      margin-right:15px;
 | 
						|
      width:100%;
 | 
						|
      max-width:40%;
 | 
						|
      margin-bottom:7px;
 | 
						|
    }
 | 
						|
 | 
						|
    .envelope,
 | 
						|
    .envelope * {
 | 
						|
      transition:1s;
 | 
						|
      pointer-events:none;
 | 
						|
    }
 | 
						|
    .envelope a,
 | 
						|
    .envelope button,
 | 
						|
    .envelope input,
 | 
						|
    .envelope textarea,
 | 
						|
    .envelope msg,
 | 
						|
    .envelope msg * {
 | 
						|
      pointer-events:all;
 | 
						|
    }
 | 
						|
 | 
						|
    .user{
 | 
						|
      margin-left:13px;
 | 
						|
      font-weight: bold;
 | 
						|
      color: var(--xrf-dark-gray);
 | 
						|
    }
 | 
						|
    .user, .user *{ 
 | 
						|
      font-size: var(--xrf-font-size-0);
 | 
						|
    }
 | 
						|
    .gg-chevron-right-o {
 | 
						|
      color:#FFF;
 | 
						|
      box-sizing: border-box;
 | 
						|
      position: relative;
 | 
						|
      display: block;
 | 
						|
      transform: scale(var(--ggs,1));
 | 
						|
      width: 22px;
 | 
						|
      height: 22px;
 | 
						|
      border: 2px solid;
 | 
						|
      border-radius: 100px
 | 
						|
    }
 | 
						|
 | 
						|
    .gg-chevron-right-o::after {
 | 
						|
      color:#FFF;
 | 
						|
      content: "";
 | 
						|
      display: block;
 | 
						|
      box-sizing: border-box;
 | 
						|
      position: absolute;
 | 
						|
      width: 6px;
 | 
						|
      height: 6px;
 | 
						|
      border-bottom: 2px solid;
 | 
						|
      border-right: 2px solid;
 | 
						|
      transform: rotate(-45deg);
 | 
						|
      left: 5px;
 | 
						|
      top: 6px
 | 
						|
    }
 | 
						|
   </style>`
 | 
						|
window.debug = (opts) => new Proxy({
 | 
						|
  opts,
 | 
						|
 | 
						|
  enabled: false,
 | 
						|
  $console: false,
 | 
						|
 | 
						|
 | 
						|
  toggle(){ this.enabled = !this.enabled },
 | 
						|
 | 
						|
  settings(){
 | 
						|
    this.toggle()
 | 
						|
  },
 | 
						|
 | 
						|
  init(){
 | 
						|
  },
 | 
						|
 | 
						|
  setupConsole(){
 | 
						|
    // add onscreen console
 | 
						|
    let $console = this.$console = document.createElement('pre')
 | 
						|
    $console.style.position = 'fixed'
 | 
						|
    $console.style.overflow = 'auto'
 | 
						|
    $console.style.top = $console.style.left = $console.style.bottom = $console.style.right = '0px'
 | 
						|
    $console.style.height = '98.5vh';
 | 
						|
    $console.style.width = '100%'
 | 
						|
    $console.style.pointerEvents = 'none'
 | 
						|
    $console.id = 'console'
 | 
						|
    document.body.appendChild($console)
 | 
						|
    const wrapper = (scope, fn, name) => {
 | 
						|
      return function(msg) {
 | 
						|
        $console.innerHTML += `[${name}] ${msg}<br>`;
 | 
						|
        if( name == 'err'){
 | 
						|
          let err = new Error()
 | 
						|
          String(err.stack).split("\n").slice(2).map( (l) => $console.innerHTML += ` └☑ ${l}\n` )
 | 
						|
        }
 | 
						|
        $console.scrollTop = $console.scrollHeight;
 | 
						|
        fn.call(scope,msg);
 | 
						|
      };
 | 
						|
    }
 | 
						|
    window.console.log   = wrapper(console, console.log, "log");
 | 
						|
    window.console.warn  = wrapper(console, console.warn, "wrn");
 | 
						|
    window.console.error = wrapper(console, console.error, "err");
 | 
						|
  }
 | 
						|
 | 
						|
},
 | 
						|
{ 
 | 
						|
  // auto-trigger events on changes 
 | 
						|
  get(data,k,receiver){ return data[k] },
 | 
						|
  set(data,k,v){ 
 | 
						|
    data[k] = v 
 | 
						|
    switch( k ){
 | 
						|
      case "enabled": {
 | 
						|
                        if( !data.$console ) data.setupConsole()
 | 
						|
                        $('#debug.btn').style.filter= v ? 'brightness(1.0)' : 'brightness(0.5)'
 | 
						|
                        data.$console.style.display = v ? 'block' : 'none'
 | 
						|
                        data.enabled = v
 | 
						|
                      }
 | 
						|
    }
 | 
						|
  }
 | 
						|
})
 | 
						|
 | 
						|
document.addEventListener('$menu:ready', (e) => {
 | 
						|
  try{
 | 
						|
    debug = debug(e.detail) 
 | 
						|
    debug.init()
 | 
						|
    document.dispatchEvent( new CustomEvent("debug:ready", e ) )
 | 
						|
    $menu.buttons = $menu.buttons.concat([`<a class="btn" style="background:var(--xrf-dark-gray);filter: brightness(0.5);" aria-label="button" aria-description="enable all debug features" id="debug" onclick="debug.enabled = !debug.enabled"><i class="gg-debug"></i>debug</a><br>`])
 | 
						|
  }catch(e){console.error(e)}
 | 
						|
})
 | 
						|
 | 
						|
document.querySelector('head').innerHTML += `
 | 
						|
  <style type="text/css"> 
 | 
						|
    .gg-debug {
 | 
						|
       box-sizing: border-box;
 | 
						|
       position: relative;
 | 
						|
       display: block;
 | 
						|
       transform: scale(var(--ggs,1));
 | 
						|
       width: 12px;
 | 
						|
       height: 18px;
 | 
						|
       border: 2px solid;
 | 
						|
       border-radius: 22px;
 | 
						|
       display: inline-block;
 | 
						|
       transform: translate(0px,4px);
 | 
						|
       margin-right: 21px;
 | 
						|
    }
 | 
						|
    .gg-debug::after,
 | 
						|
    .gg-debug::before {
 | 
						|
       content: "";
 | 
						|
       display: block;
 | 
						|
       box-sizing: border-box;
 | 
						|
       position: absolute
 | 
						|
    }
 | 
						|
    .gg-debug::before {
 | 
						|
       width: 8px;
 | 
						|
       height: 4px;
 | 
						|
       border: 2px solid;
 | 
						|
       top: -4px;
 | 
						|
       border-bottom-left-radius: 10px;
 | 
						|
       border-bottom-right-radius: 10px;
 | 
						|
       border-top: 0
 | 
						|
    }
 | 
						|
    .gg-debug::after {
 | 
						|
       background: currentColor;
 | 
						|
       width: 4px;
 | 
						|
       height: 2px;
 | 
						|
       border-radius: 5px;
 | 
						|
       top: 4px;
 | 
						|
       left: 2px;
 | 
						|
       box-shadow:
 | 
						|
          0 4px 0,
 | 
						|
          -6px -2px 0,
 | 
						|
          -6px 2px 0,
 | 
						|
          -6px 6px 0,
 | 
						|
          6px -2px 0,
 | 
						|
          6px 2px 0,
 | 
						|
          6px 6px 0
 | 
						|
    }
 | 
						|
  </style>
 | 
						|
`
 | 
						|
 | 
						|
}).apply({})
 |