Merge branch 'main' into bugfix/youtube-timestamp
This commit is contained in:
		
						commit
						e33e4196ab
					
				@ -3,6 +3,7 @@ import logging
 | 
			
		||||
import pathlib
 | 
			
		||||
import time
 | 
			
		||||
import uuid
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from snek import snode 
 | 
			
		||||
from snek.view.threads import ThreadsView
 | 
			
		||||
import json 
 | 
			
		||||
@ -96,6 +97,7 @@ class Application(BaseApplication):
 | 
			
		||||
        self.jinja2_env.add_extension(LinkifyExtension)
 | 
			
		||||
        self.jinja2_env.add_extension(PythonExtension)
 | 
			
		||||
        self.jinja2_env.add_extension(EmojiExtension)
 | 
			
		||||
        self.time_start = datetime.now()
 | 
			
		||||
        self.ssh_host = "0.0.0.0"
 | 
			
		||||
        self.ssh_port = 2242
 | 
			
		||||
        self.setup_router()
 | 
			
		||||
@ -112,7 +114,34 @@ class Application(BaseApplication):
 | 
			
		||||
        self.on_startup.append(self.start_user_availability_service)
 | 
			
		||||
        self.on_startup.append(self.start_ssh_server)
 | 
			
		||||
        self.on_startup.append(self.prepare_database)
 | 
			
		||||
        
 | 
			
		||||
       
 | 
			
		||||
    @property
 | 
			
		||||
    def uptime_seconds(self):
 | 
			
		||||
        return (datetime.now() - self.time_start).total_seconds()
 | 
			
		||||
 | 
			
		||||
    @property 
 | 
			
		||||
    def uptime(self):
 | 
			
		||||
        return self._format_uptime(self.uptime_seconds)
 | 
			
		||||
    
 | 
			
		||||
    def _format_uptime(self,seconds):
 | 
			
		||||
        seconds = int(seconds)
 | 
			
		||||
        days, seconds = divmod(seconds, 86400)
 | 
			
		||||
        hours, seconds = divmod(seconds, 3600)
 | 
			
		||||
        minutes, seconds = divmod(seconds, 60)
 | 
			
		||||
 | 
			
		||||
        parts = []
 | 
			
		||||
        if days > 0:
 | 
			
		||||
            parts.append(f"{days} day{'s' if days != 1 else ''}")
 | 
			
		||||
        if hours > 0:
 | 
			
		||||
            parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
 | 
			
		||||
        if minutes > 0:
 | 
			
		||||
            parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
 | 
			
		||||
        if seconds > 0 or not parts:
 | 
			
		||||
            parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
 | 
			
		||||
 | 
			
		||||
        return ", ".join(parts)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    async def start_user_availability_service(self, app):
 | 
			
		||||
        app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
 | 
			
		||||
    async def snode_sync(self, app):
 | 
			
		||||
 | 
			
		||||
@ -1,250 +1,261 @@
 | 
			
		||||
// Written by retoor@molodetz.nl
 | 
			
		||||
 | 
			
		||||
// This project implements a client-server communication system using WebSockets and REST APIs. 
 | 
			
		||||
// This project implements a client-server communication system using WebSockets and REST APIs.
 | 
			
		||||
// It features a chat system, a notification sound system, and interaction with server endpoints.
 | 
			
		||||
 | 
			
		||||
// No additional imports were used beyond standard JavaScript objects and constructors.
 | 
			
		||||
 | 
			
		||||
// MIT License
 | 
			
		||||
 | 
			
		||||
import { Schedule } from './schedule.js';
 | 
			
		||||
import { Schedule } from "./schedule.js";
 | 
			
		||||
import { EventHandler } from "./event-handler.js";
 | 
			
		||||
import { Socket } from "./socket.js";
 | 
			
		||||
 | 
			
		||||
export class RESTClient {
 | 
			
		||||
    debug = false;
 | 
			
		||||
  debug = false;
 | 
			
		||||
 | 
			
		||||
    async get(url, params = {}) {
 | 
			
		||||
        const encodedParams = new URLSearchParams(params);
 | 
			
		||||
        if (encodedParams) url += '?' + encodedParams;
 | 
			
		||||
        const response = await fetch(url, {
 | 
			
		||||
            method: 'GET',
 | 
			
		||||
            headers: {
 | 
			
		||||
                'Content-Type': 'application/json',
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
        const result = await response.json();
 | 
			
		||||
        if (this.debug) {
 | 
			
		||||
            console.debug({ url, params, result });
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
  async get(url, params = {}) {
 | 
			
		||||
    const encodedParams = new URLSearchParams(params);
 | 
			
		||||
    if (encodedParams) url += "?" + encodedParams;
 | 
			
		||||
    const response = await fetch(url, {
 | 
			
		||||
      method: "GET",
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const result = await response.json();
 | 
			
		||||
    if (this.debug) {
 | 
			
		||||
      console.debug({ url, params, result });
 | 
			
		||||
    }
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async post(url, data) {
 | 
			
		||||
        const response = await fetch(url, {
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
            headers: {
 | 
			
		||||
                'Content-Type': 'application/json',
 | 
			
		||||
            },
 | 
			
		||||
            body: JSON.stringify(data),
 | 
			
		||||
        });
 | 
			
		||||
  async post(url, data) {
 | 
			
		||||
    const response = await fetch(url, {
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify(data),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
        const result = await response.json();
 | 
			
		||||
        if (this.debug) {
 | 
			
		||||
            console.debug({ url, data, result });
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
    const result = await response.json();
 | 
			
		||||
    if (this.debug) {
 | 
			
		||||
      console.debug({ url, data, result });
 | 
			
		||||
    }
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class Chat extends EventHandler {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
 | 
			
		||||
        this._socket = null;
 | 
			
		||||
        this._waitConnect = null;
 | 
			
		||||
        this._promises = {};
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this._url =
 | 
			
		||||
      window.location.hostname === "localhost"
 | 
			
		||||
        ? "ws://localhost/chat.ws"
 | 
			
		||||
        : "wss://" + window.location.hostname + "/chat.ws";
 | 
			
		||||
    this._socket = null;
 | 
			
		||||
    this._waitConnect = null;
 | 
			
		||||
    this._promises = {};
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connect() {
 | 
			
		||||
    if (this._waitConnect) {
 | 
			
		||||
      return this._waitConnect;
 | 
			
		||||
    }
 | 
			
		||||
    return new Promise((resolve) => {
 | 
			
		||||
      this._waitConnect = resolve;
 | 
			
		||||
      console.debug("Connecting..");
 | 
			
		||||
        
 | 
			
		||||
      try {
 | 
			
		||||
        this._socket = new WebSocket(this._url);
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        console.warn(e);
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          this.ensureConnection();
 | 
			
		||||
        }, 1000);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    connect() {
 | 
			
		||||
        if (this._waitConnect) {
 | 
			
		||||
            return this._waitConnect;
 | 
			
		||||
        }
 | 
			
		||||
        return new Promise((resolve) => {
 | 
			
		||||
            this._waitConnect = resolve;
 | 
			
		||||
            console.debug("Connecting..");
 | 
			
		||||
      this._socket.onconnect = () => {
 | 
			
		||||
        this._connected();
 | 
			
		||||
        this._waitSocket();
 | 
			
		||||
      };
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                this._socket = new WebSocket(this._url);
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                console.warn(e);
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.ensureConnection();
 | 
			
		||||
                }, 1000);
 | 
			
		||||
            }
 | 
			
		||||
  generateUniqueId() {
 | 
			
		||||
    return "id-" + Math.random().toString(36).substr(2, 9);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
            this._socket.onconnect = () => {
 | 
			
		||||
                this._connected();
 | 
			
		||||
                this._waitSocket();
 | 
			
		||||
            };
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  call(method, ...args) {
 | 
			
		||||
    return new Promise((resolve, reject) => {
 | 
			
		||||
      try {
 | 
			
		||||
        const command = { method, args, message_id: this.generateUniqueId() };
 | 
			
		||||
        this._promises[command.message_id] = resolve;
 | 
			
		||||
        this._socket.send(JSON.stringify(command));
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        reject(e);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    generateUniqueId() {
 | 
			
		||||
        return 'id-' + Math.random().toString(36).substr(2, 9);
 | 
			
		||||
    }
 | 
			
		||||
  _connected() {
 | 
			
		||||
    this._socket.onmessage = (event) => {
 | 
			
		||||
      const message = JSON.parse(event.data);
 | 
			
		||||
      if (message.message_id && this._promises[message.message_id]) {
 | 
			
		||||
        this._promises[message.message_id](message);
 | 
			
		||||
        delete this._promises[message.message_id];
 | 
			
		||||
      } else {
 | 
			
		||||
        this.emit("message", message);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    this._socket.onclose = () => {
 | 
			
		||||
      this._waitSocket = null;
 | 
			
		||||
      this._socket = null;
 | 
			
		||||
      this.emit("close");
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    call(method, ...args) {
 | 
			
		||||
        return new Promise((resolve, reject) => {
 | 
			
		||||
            try {
 | 
			
		||||
                const command = { method, args, message_id: this.generateUniqueId() };
 | 
			
		||||
                this._promises[command.message_id] = resolve;
 | 
			
		||||
                this._socket.send(JSON.stringify(command));
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                reject(e);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _connected() {
 | 
			
		||||
        this._socket.onmessage = (event) => {
 | 
			
		||||
            const message = JSON.parse(event.data);
 | 
			
		||||
            if (message.message_id && this._promises[message.message_id]) {
 | 
			
		||||
                this._promises[message.message_id](message);
 | 
			
		||||
                delete this._promises[message.message_id];
 | 
			
		||||
            } else {
 | 
			
		||||
                this.emit("message", message);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        this._socket.onclose = () => {
 | 
			
		||||
            this._waitSocket = null;
 | 
			
		||||
            this._socket = null;
 | 
			
		||||
            this.emit('close');
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async privmsg(room, text) {
 | 
			
		||||
        await rest.post("/api/privmsg", {
 | 
			
		||||
            room,
 | 
			
		||||
            text,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  async privmsg(room, text) {
 | 
			
		||||
    await rest.post("/api/privmsg", {
 | 
			
		||||
      room,
 | 
			
		||||
      text,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class NotificationAudio {
 | 
			
		||||
    constructor(timeout = 500) {
 | 
			
		||||
        this.schedule = new Schedule(timeout);
 | 
			
		||||
    }
 | 
			
		||||
  constructor(timeout = 500) {
 | 
			
		||||
    this.schedule = new Schedule(timeout);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    sounds = {
 | 
			
		||||
        "message": "/audio/soundfx.d_beep3.mp3",
 | 
			
		||||
        "mention": "/audio/750607__deadrobotmusic__notification-sound-1.wav",
 | 
			
		||||
        "messageOtherChannel": "/audio/750608__deadrobotmusic__notification-sound-2.wav",
 | 
			
		||||
        "ping": "/audio/750609__deadrobotmusic__notification-sound-3.wav",
 | 
			
		||||
    }
 | 
			
		||||
  sounds = {
 | 
			
		||||
    message: "/audio/soundfx.d_beep3.mp3",
 | 
			
		||||
    mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
 | 
			
		||||
    messageOtherChannel:
 | 
			
		||||
      "/audio/750608__deadrobotmusic__notification-sound-2.wav",
 | 
			
		||||
    ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
    play(soundIndex = 0) {
 | 
			
		||||
        this.schedule.delay(() => {
 | 
			
		||||
            new Audio(this.sounds[soundIndex]).play()
 | 
			
		||||
                .then(() => {
 | 
			
		||||
                    console.debug("Gave sound notification");
 | 
			
		||||
                })
 | 
			
		||||
                .catch(error => {
 | 
			
		||||
                    console.error("Notification failed:", error);
 | 
			
		||||
                });
 | 
			
		||||
  play(soundIndex = 0) {
 | 
			
		||||
    this.schedule.delay(() => {
 | 
			
		||||
      new Audio(this.sounds[soundIndex])
 | 
			
		||||
        .play()
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          console.debug("Gave sound notification");
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error("Notification failed:", error);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class App extends EventHandler {
 | 
			
		||||
    rest = new RESTClient();
 | 
			
		||||
    ws = null;
 | 
			
		||||
    rpc = null;
 | 
			
		||||
    audio = null;
 | 
			
		||||
    user = {}; 
 | 
			
		||||
    typeLock = null;
 | 
			
		||||
    typeListener = null
 | 
			
		||||
    typeEventChannelUid = null
 | 
			
		||||
    async set_typing(channel_uid){
 | 
			
		||||
    	this.typeEventChannel_uid = channel_uid
 | 
			
		||||
  rest = new RESTClient();
 | 
			
		||||
  ws = null;
 | 
			
		||||
  rpc = null;
 | 
			
		||||
  audio = null;
 | 
			
		||||
  user = {};
 | 
			
		||||
  typeLock = null;
 | 
			
		||||
  typeListener = null;
 | 
			
		||||
  typeEventChannelUid = null;
 | 
			
		||||
  async set_typing(channel_uid) {
 | 
			
		||||
    this.typeEventChannel_uid = channel_uid;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async ping(...args) {
 | 
			
		||||
    if (this.is_pinging) return false;
 | 
			
		||||
    this.is_pinging = true;
 | 
			
		||||
    await this.rpc.ping(...args);
 | 
			
		||||
    this.is_pinging = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async forcePing(...arg) {
 | 
			
		||||
    await this.rpc.ping(...args);
 | 
			
		||||
  }
 | 
			
		||||
  starField = null
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.ws = new Socket();
 | 
			
		||||
    this.rpc = this.ws.client;
 | 
			
		||||
    this.audio = new NotificationAudio(500);
 | 
			
		||||
    this.is_pinging = false;
 | 
			
		||||
    this.ping_interval = setInterval(() => {
 | 
			
		||||
      this.ping("active");
 | 
			
		||||
    }, 15000);
 | 
			
		||||
    this.typeEventChannelUid = null;
 | 
			
		||||
    this.typeListener = setInterval(() => {
 | 
			
		||||
      if (this.typeEventChannelUid) {
 | 
			
		||||
        this.rpc.set_typing(this.typeEventChannelUid);
 | 
			
		||||
        this.typeEventChannelUid = null;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const me = this;
 | 
			
		||||
    this.ws.addEventListener("connected", (data) => {
 | 
			
		||||
      this.ping("online");
 | 
			
		||||
    });
 | 
			
		||||
    this.ws.addEventListener("reconnecting", (data) => {
 | 
			
		||||
        this.starField?.showNotify("Connecting..","#CC0000")
 | 
			
		||||
    })
 | 
			
		||||
    this.ws.addEventListener("channel-message", (data) => {
 | 
			
		||||
      me.emit("channel-message", data);
 | 
			
		||||
    });
 | 
			
		||||
    this.ws.addEventListener("event", (data) => {
 | 
			
		||||
      console.info("aaaa");
 | 
			
		||||
    });
 | 
			
		||||
    this.rpc.getUser(null).then((user) => {
 | 
			
		||||
      me.user = user;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  playSound(index) {
 | 
			
		||||
    this.audio.play(index);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  timeDescription(isoDate) {
 | 
			
		||||
    const date = new Date(isoDate);
 | 
			
		||||
    const hours = String(date.getHours()).padStart(2, "0");
 | 
			
		||||
    const minutes = String(date.getMinutes()).padStart(2, "0");
 | 
			
		||||
    let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
 | 
			
		||||
    return timeStr;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  timeAgo(date1, date2) {
 | 
			
		||||
    const diffMs = Math.abs(date2 - date1);
 | 
			
		||||
    const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
 | 
			
		||||
    const hours = Math.floor(
 | 
			
		||||
      (diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
 | 
			
		||||
    );
 | 
			
		||||
    const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
 | 
			
		||||
    const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
 | 
			
		||||
 | 
			
		||||
    if (days) {
 | 
			
		||||
      return `${days} ${days > 1 ? "days" : "day"} ago`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async ping(...args) {
 | 
			
		||||
        if (this.is_pinging) return false
 | 
			
		||||
        this.is_pinging = true
 | 
			
		||||
        await this.rpc.ping(...args);
 | 
			
		||||
        this.is_pinging = false
 | 
			
		||||
    if (hours) {
 | 
			
		||||
      return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async forcePing(...arg) {
 | 
			
		||||
        await this.rpc.ping(...args);
 | 
			
		||||
    if (minutes) {
 | 
			
		||||
      return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
 | 
			
		||||
    }
 | 
			
		||||
    return "just now";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.ws = new Socket();
 | 
			
		||||
        this.rpc = this.ws.client;
 | 
			
		||||
        this.audio = new NotificationAudio(500);
 | 
			
		||||
        this.is_pinging = false
 | 
			
		||||
        this.ping_interval = setInterval(() => {
 | 
			
		||||
            this.ping("active")
 | 
			
		||||
        }, 15000)
 | 
			
		||||
	this.typeEventChannelUid = null
 | 
			
		||||
	this.typeListener = setInterval(()=>{
 | 
			
		||||
		if(this.typeEventChannelUid){
 | 
			
		||||
			this.rpc.set_typing(this.typeEventChannelUid)
 | 
			
		||||
			this.typeEventChannelUid = null
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
        const me = this
 | 
			
		||||
        this.ws.addEventListener("connected", (data) => {
 | 
			
		||||
            this.ping("online")
 | 
			
		||||
        })
 | 
			
		||||
	
 | 
			
		||||
        this.ws.addEventListener("channel-message", (data) => {
 | 
			
		||||
            me.emit("channel-message", data);
 | 
			
		||||
        });
 | 
			
		||||
	this.ws.addEventListener("event",(data)=>{
 | 
			
		||||
		console.info("aaaa")	
 | 
			
		||||
	})
 | 
			
		||||
        this.rpc.getUser(null).then(user => {
 | 
			
		||||
            me.user = user;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    playSound(index) {
 | 
			
		||||
        this.audio.play(index);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    timeDescription(isoDate) {
 | 
			
		||||
        const date = new Date(isoDate);
 | 
			
		||||
        const hours = String(date.getHours()).padStart(2, "0");
 | 
			
		||||
        const minutes = String(date.getMinutes()).padStart(2, "0");
 | 
			
		||||
        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
 | 
			
		||||
        return timeStr;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    timeAgo(date1, date2) {
 | 
			
		||||
        const diffMs = Math.abs(date2 - date1);
 | 
			
		||||
        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
 | 
			
		||||
        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
 | 
			
		||||
        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
 | 
			
		||||
        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
 | 
			
		||||
 | 
			
		||||
        if (days) {
 | 
			
		||||
            return `${days} ${days > 1 ? 'days' : 'day'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        if (hours) {
 | 
			
		||||
            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        if (minutes) {
 | 
			
		||||
            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        return 'just now';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async benchMark(times = 100, message = "Benchmark Message") {
 | 
			
		||||
        const promises = [];
 | 
			
		||||
        const me = this;
 | 
			
		||||
        for (let i = 0; i < times; i++) {
 | 
			
		||||
            promises.push(this.rpc.getChannels().then(channels => {
 | 
			
		||||
                channels.forEach(channel => {
 | 
			
		||||
                    me.rpc.sendMessage(channel.uid, `${message} ${i}`);
 | 
			
		||||
                });
 | 
			
		||||
            }));
 | 
			
		||||
        }
 | 
			
		||||
  async benchMark(times = 100, message = "Benchmark Message") {
 | 
			
		||||
    const promises = [];
 | 
			
		||||
    const me = this;
 | 
			
		||||
    for (let i = 0; i < times; i++) {
 | 
			
		||||
      promises.push(
 | 
			
		||||
        this.rpc.getChannels().then((channels) => {
 | 
			
		||||
          channels.forEach((channel) => {
 | 
			
		||||
            me.rpc.sendMessage(channel.uid, `${message} ${i}`);
 | 
			
		||||
          });
 | 
			
		||||
        }),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const app = new App();
 | 
			
		||||
 | 
			
		||||
@ -1,235 +1,250 @@
 | 
			
		||||
 | 
			
		||||
import { app } from '../app.js';
 | 
			
		||||
import { app } from "../app.js";
 | 
			
		||||
 | 
			
		||||
class ChatInputComponent extends HTMLElement {
 | 
			
		||||
    autoCompletions = {
 | 
			
		||||
        'example 1': () => {
 | 
			
		||||
  autoCompletions = {
 | 
			
		||||
    "example 1": () => {},
 | 
			
		||||
    "example 2": () => {},
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
        },
 | 
			
		||||
        'example 2': () => {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.lastUpdateEvent = new Date();
 | 
			
		||||
    this.textarea = document.createElement("textarea");
 | 
			
		||||
    this._value = "";
 | 
			
		||||
    this.value = this.getAttribute("value") || "";
 | 
			
		||||
    this.previousValue = this.value;
 | 
			
		||||
    this.lastChange = new Date();
 | 
			
		||||
    this.changed = false;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get value() {
 | 
			
		||||
    return this._value;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set value(value) {
 | 
			
		||||
    this._value = value || "";
 | 
			
		||||
    this.textarea.value = this._value;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  resolveAutoComplete() {
 | 
			
		||||
    let count = 0;
 | 
			
		||||
    let value = null;
 | 
			
		||||
    Object.keys(this.autoCompletions).forEach((key) => {
 | 
			
		||||
      if (key.startsWith(this.value)) {
 | 
			
		||||
        count++;
 | 
			
		||||
        value = key;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    if (count == 1) return value;
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  isActive() {
 | 
			
		||||
    return document.activeElement === this.textarea;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  focus() {
 | 
			
		||||
    this.textarea.focus();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  async connectedCallback() {
 | 
			
		||||
    this.user = null 
 | 
			
		||||
    app.rpc.getUser(null).then((user) => {
 | 
			
		||||
        this.user=user
 | 
			
		||||
    })
 | 
			
		||||
    this.liveType = this.getAttribute("live-type") === "true";
 | 
			
		||||
    this.liveTypeInterval =
 | 
			
		||||
      parseInt(this.getAttribute("live-type-interval")) || 3;
 | 
			
		||||
    this.channelUid = this.getAttribute("channel");
 | 
			
		||||
    this.messageUid = null;
 | 
			
		||||
 | 
			
		||||
    this.classList.add("chat-input");
 | 
			
		||||
 | 
			
		||||
    this.textarea.setAttribute("placeholder", "Type a message...");
 | 
			
		||||
    this.textarea.setAttribute("rows", "2");
 | 
			
		||||
 | 
			
		||||
    this.appendChild(this.textarea);
 | 
			
		||||
 | 
			
		||||
    this.uploadButton = document.createElement("upload-button");
 | 
			
		||||
    this.uploadButton.setAttribute("channel", this.channelUid);
 | 
			
		||||
    this.uploadButton.addEventListener("upload", (e) => {
 | 
			
		||||
      this.dispatchEvent(new CustomEvent("upload", e));
 | 
			
		||||
    });
 | 
			
		||||
    this.uploadButton.addEventListener("uploaded", (e) => {
 | 
			
		||||
      this.dispatchEvent(new CustomEvent("uploaded", e));
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.appendChild(this.uploadButton);
 | 
			
		||||
 | 
			
		||||
    this.textarea.addEventListener("keyup", (e) => {
 | 
			
		||||
      if (e.key === "Enter" && !e.shiftKey) {
 | 
			
		||||
        this.value = "";
 | 
			
		||||
        e.target.value = "";
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      this.value = e.target.value;
 | 
			
		||||
      this.changed = true;
 | 
			
		||||
      this.update();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.textarea.addEventListener("keydown", (e) => {
 | 
			
		||||
      this.value = e.target.value;
 | 
			
		||||
      if (e.key === "Tab") {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
        let autoCompletion = this.resolveAutoComplete();
 | 
			
		||||
        if (autoCompletion) {
 | 
			
		||||
          e.target.value = autoCompletion;
 | 
			
		||||
          this.value = autoCompletion;
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
      }
 | 
			
		||||
      if (e.key === "Enter" && !e.shiftKey) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.lastUpdateEvent = new Date();
 | 
			
		||||
        this.textarea = document.createElement("textarea");
 | 
			
		||||
        this._value = "";
 | 
			
		||||
        this.value = this.getAttribute("value") || "";
 | 
			
		||||
        this.previousValue = this.value;
 | 
			
		||||
        this.lastChange = new Date();
 | 
			
		||||
        this.changed = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get value() {
 | 
			
		||||
        return this._value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    set value(value) {
 | 
			
		||||
        this._value = value || "";
 | 
			
		||||
        this.textarea.value = this._value;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    resolveAutoComplete() {
 | 
			
		||||
        let count = 0;
 | 
			
		||||
        let value = null;
 | 
			
		||||
        Object.keys(this.autoCompletions).forEach((key) => {
 | 
			
		||||
            if (key.startsWith(this.value)) {
 | 
			
		||||
                count++;
 | 
			
		||||
                value = key;
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        if (count == 1)
 | 
			
		||||
            return value;
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isActive() {
 | 
			
		||||
        return document.activeElement === this.textarea;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    focus() {
 | 
			
		||||
        this.textarea.focus();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async connectedCallback() {
 | 
			
		||||
        this.user = await app.rpc.getUser(null);
 | 
			
		||||
        this.liveType = this.getAttribute("live-type") === "true";
 | 
			
		||||
        this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3;
 | 
			
		||||
        this.channelUid = this.getAttribute("channel");
 | 
			
		||||
        const message = e.target.value;
 | 
			
		||||
        this.messageUid = null;
 | 
			
		||||
        this.value = "";
 | 
			
		||||
        this.previousValue = "";
 | 
			
		||||
 | 
			
		||||
        this.classList.add("chat-input");
 | 
			
		||||
        if (!message) {
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.textarea.setAttribute("placeholder", "Type a message...");
 | 
			
		||||
        this.textarea.setAttribute("rows", "2");
 | 
			
		||||
        let autoCompletion = this.autoCompletions[message];
 | 
			
		||||
        if (autoCompletion) {
 | 
			
		||||
          this.value = "";
 | 
			
		||||
          this.previousValue = "";
 | 
			
		||||
          e.target.value = "";
 | 
			
		||||
          autoCompletion();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.appendChild(this.textarea);
 | 
			
		||||
 | 
			
		||||
        this.uploadButton = document.createElement("upload-button");
 | 
			
		||||
        this.uploadButton.setAttribute("channel", this.channelUid);
 | 
			
		||||
        this.uploadButton.addEventListener("upload", (e) => {
 | 
			
		||||
            this.dispatchEvent(new CustomEvent("upload", e));
 | 
			
		||||
        });
 | 
			
		||||
        this.uploadButton.addEventListener("uploaded", (e) => {
 | 
			
		||||
            this.dispatchEvent(new CustomEvent("uploaded", e));
 | 
			
		||||
        e.target.value = "";
 | 
			
		||||
        this.value = "";
 | 
			
		||||
        this.messageUid = null;
 | 
			
		||||
        this.sendMessage(this.channelUid, message).then((uid) => {
 | 
			
		||||
          this.messageUid = uid;
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
        this.appendChild(this.uploadButton);
 | 
			
		||||
    this.changeInterval = setInterval(() => {
 | 
			
		||||
      if (!this.liveType) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (this.value !== this.previousValue) {
 | 
			
		||||
        if (
 | 
			
		||||
          this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
 | 
			
		||||
          this.liveTypeInterval
 | 
			
		||||
        ) {
 | 
			
		||||
          this.value = "";
 | 
			
		||||
          this.previousValue = "";
 | 
			
		||||
        }
 | 
			
		||||
        this.lastChange = new Date();
 | 
			
		||||
      }
 | 
			
		||||
      this.update();
 | 
			
		||||
    }, 300);
 | 
			
		||||
 | 
			
		||||
        this.textarea.addEventListener("keyup", (e) => {
 | 
			
		||||
            if(e.key === 'Enter' && !e.shiftKey) {
 | 
			
		||||
                this.value = ''
 | 
			
		||||
                e.target.value = '';
 | 
			
		||||
                return 
 | 
			
		||||
            }
 | 
			
		||||
            this.value = e.target.value;
 | 
			
		||||
            this.changed = true;
 | 
			
		||||
            this.update();
 | 
			
		||||
        });
 | 
			
		||||
    this.addEventListener("upload", (e) => {
 | 
			
		||||
      this.focus();
 | 
			
		||||
    });
 | 
			
		||||
    this.addEventListener("uploaded", function (e) {
 | 
			
		||||
      let message = "";
 | 
			
		||||
      e.detail.files.forEach((file) => {
 | 
			
		||||
        message += `[${file.name}](/channel/attachment/${file.relative_url})`;
 | 
			
		||||
      });
 | 
			
		||||
      app.rpc.sendMessage(this.channelUid, message);
 | 
			
		||||
    });
 | 
			
		||||
    setTimeout(()=>{
 | 
			
		||||
      this.focus();
 | 
			
		||||
    },1000)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
        this.textarea.addEventListener("keydown", (e) => {
 | 
			
		||||
            this.value = e.target.value;
 | 
			
		||||
            if (e.key === "Tab") {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
                let autoCompletion = this.resolveAutoComplete();
 | 
			
		||||
                if (autoCompletion) {
 | 
			
		||||
                    e.target.value = autoCompletion;
 | 
			
		||||
                    this.value = autoCompletion;
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            if (e.key === 'Enter' && !e.shiftKey) {
 | 
			
		||||
                e.preventDefault();
 | 
			
		||||
  trackSecondsBetweenEvents(event1Time, event2Time) {
 | 
			
		||||
    const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
 | 
			
		||||
    return millisecondsDifference / 1000;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
                const message = e.target.value;
 | 
			
		||||
                this.messageUid = null;
 | 
			
		||||
                this.value = '';
 | 
			
		||||
                this.previousValue = '';
 | 
			
		||||
 | 
			
		||||
                if (!message) {
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                let autoCompletion = this.autoCompletions[message];
 | 
			
		||||
                if (autoCompletion) {
 | 
			
		||||
                    this.value = '';
 | 
			
		||||
                    this.previousValue = '';
 | 
			
		||||
                    e.target.value = '';
 | 
			
		||||
                    autoCompletion();
 | 
			
		||||
                    return;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                e.target.value = '';
 | 
			
		||||
                this.value = '';
 | 
			
		||||
                this.messageUid = null;
 | 
			
		||||
                this.sendMessage(this.channelUid, message).then((uid) => {
 | 
			
		||||
                    this.messageUid = uid;
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.changeInterval = setInterval(() => {
 | 
			
		||||
            if (!this.liveType) {
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            if (this.value !== this.previousValue) {
 | 
			
		||||
                if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) {
 | 
			
		||||
                    this.value = '';
 | 
			
		||||
                    this.previousValue = '';
 | 
			
		||||
                }
 | 
			
		||||
                this.lastChange = new Date();
 | 
			
		||||
            }
 | 
			
		||||
            this.update();
 | 
			
		||||
        }, 300);
 | 
			
		||||
 | 
			
		||||
        this.addEventListener("upload", (e) => {
 | 
			
		||||
            this.focus();
 | 
			
		||||
        });
 | 
			
		||||
        this.addEventListener("uploaded", function (e) {
 | 
			
		||||
            let message = "";
 | 
			
		||||
            e.detail.files.forEach((file) => {
 | 
			
		||||
                message += `[${file.name}](/channel/attachment/${file.relative_url})`;
 | 
			
		||||
            });
 | 
			
		||||
            app.rpc.sendMessage(this.channelUid, message);
 | 
			
		||||
        });
 | 
			
		||||
  newMessage() {
 | 
			
		||||
    if (!this.messageUid) {
 | 
			
		||||
      this.messageUid = "?";
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    trackSecondsBetweenEvents(event1Time, event2Time) {
 | 
			
		||||
        const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
 | 
			
		||||
        return millisecondsDifference / 1000;
 | 
			
		||||
    this.sendMessage(this.channelUid, this.value).then((uid) => {
 | 
			
		||||
      this.messageUid = uid;
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateMessage() {
 | 
			
		||||
    if (this.value[0] == "/") {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    if (!this.messageUid) {
 | 
			
		||||
      this.newMessage();
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.messageUid === "?") {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      typeof app !== "undefined" &&
 | 
			
		||||
      app.rpc &&
 | 
			
		||||
      typeof app.rpc.updateMessageText === "function"
 | 
			
		||||
    ) {
 | 
			
		||||
      app.rpc.updateMessageText(this.messageUid, this.value);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateStatus() {
 | 
			
		||||
    if (this.liveType) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
 | 
			
		||||
      this.lastUpdateEvent = new Date();
 | 
			
		||||
      if (
 | 
			
		||||
        typeof app !== "undefined" &&
 | 
			
		||||
        app.rpc &&
 | 
			
		||||
        typeof app.rpc.set_typing === "function"
 | 
			
		||||
      ) {
 | 
			
		||||
        app.rpc.set_typing(this.channelUid, this.user.color);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  update() {
 | 
			
		||||
    const expired =
 | 
			
		||||
      this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
 | 
			
		||||
      this.liveTypeInterval;
 | 
			
		||||
    const changed = this.value !== this.previousValue;
 | 
			
		||||
 | 
			
		||||
    if (changed || expired) {
 | 
			
		||||
      this.lastChange = new Date();
 | 
			
		||||
      this.updateStatus();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    newMessage() {
 | 
			
		||||
        if (!this.messageUid) {
 | 
			
		||||
            this.messageUid = '?';
 | 
			
		||||
        }
 | 
			
		||||
    this.previousValue = this.value;
 | 
			
		||||
 | 
			
		||||
        this.sendMessage(this.channelUid, this.value).then((uid) => {
 | 
			
		||||
            this.messageUid = uid;
 | 
			
		||||
        });
 | 
			
		||||
    if (this.liveType && expired) {
 | 
			
		||||
      this.value = "";
 | 
			
		||||
      this.previousValue = "";
 | 
			
		||||
      this.messageUid = null;
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateMessage() {
 | 
			
		||||
        if (this.value[0] == "/") {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (!this.messageUid) {
 | 
			
		||||
            this.newMessage();
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.messageUid === '?') {
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        if (typeof app !== "undefined" && app.rpc && typeof app.rpc.updateMessageText === "function") {
 | 
			
		||||
            app.rpc.updateMessageText(this.messageUid, this.value);
 | 
			
		||||
        }
 | 
			
		||||
    if (changed) {
 | 
			
		||||
      if (this.liveType) {
 | 
			
		||||
        this.updateMessage();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    updateStatus() {
 | 
			
		||||
        if (this.liveType) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
 | 
			
		||||
            this.lastUpdateEvent = new Date();
 | 
			
		||||
            if (typeof app !== "undefined" && app.rpc && typeof app.rpc.set_typing === "function") {
 | 
			
		||||
                app.rpc.set_typing(this.channelUid,this.user.color);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    update() {
 | 
			
		||||
        const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval;
 | 
			
		||||
        const changed = (this.value !== this.previousValue);
 | 
			
		||||
 | 
			
		||||
        if (changed || expired) {
 | 
			
		||||
            this.lastChange = new Date();
 | 
			
		||||
            this.updateStatus();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.previousValue = this.value;
 | 
			
		||||
 | 
			
		||||
        if (this.liveType && expired) {
 | 
			
		||||
            this.value = "";
 | 
			
		||||
            this.previousValue = "";
 | 
			
		||||
            this.messageUid = null;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (changed) {
 | 
			
		||||
            if (this.liveType) {
 | 
			
		||||
                this.updateMessage();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async sendMessage(channelUid, value) {
 | 
			
		||||
        if (!value.trim()) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
        return await app.rpc.sendMessage(channelUid, value);
 | 
			
		||||
  async sendMessage(channelUid, value) {
 | 
			
		||||
    if (!value.trim()) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    return await app.rpc.sendMessage(channelUid, value);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('chat-input', ChatInputComponent);
 | 
			
		||||
customElements.define("chat-input", ChatInputComponent);
 | 
			
		||||
 | 
			
		||||
@ -6,77 +6,77 @@
 | 
			
		||||
 | 
			
		||||
// The MIT License (MIT)
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 | 
			
		||||
// 
 | 
			
		||||
//
 | 
			
		||||
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
 | 
			
		||||
// 
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 | 
			
		||||
 | 
			
		||||
class ChatWindowElement extends HTMLElement {
 | 
			
		||||
    receivedHistory = false;
 | 
			
		||||
    channel = null
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.component = document.createElement('section');
 | 
			
		||||
        this.app = app;
 | 
			
		||||
        this.shadowRoot.appendChild(this.component);
 | 
			
		||||
    }
 | 
			
		||||
  receivedHistory = false;
 | 
			
		||||
  channel = null;
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.component = document.createElement("section");
 | 
			
		||||
    this.app = app;
 | 
			
		||||
    this.shadowRoot.appendChild(this.component);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    get user() {
 | 
			
		||||
        return this.app.user;
 | 
			
		||||
    }
 | 
			
		||||
  get user() {
 | 
			
		||||
    return this.app.user;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async connectedCallback() {
 | 
			
		||||
        const link = document.createElement('link');
 | 
			
		||||
        link.rel = 'stylesheet';
 | 
			
		||||
        link.href = '/base.css';
 | 
			
		||||
        this.component.appendChild(link);
 | 
			
		||||
        this.component.classList.add("chat-area");
 | 
			
		||||
        
 | 
			
		||||
        this.container = document.createElement("section");
 | 
			
		||||
        this.container.classList.add("chat-area", "chat-window");
 | 
			
		||||
  async connectedCallback() {
 | 
			
		||||
    const link = document.createElement("link");
 | 
			
		||||
    link.rel = "stylesheet";
 | 
			
		||||
    link.href = "/base.css";
 | 
			
		||||
    this.component.appendChild(link);
 | 
			
		||||
    this.component.classList.add("chat-area");
 | 
			
		||||
 | 
			
		||||
        const chatHeader = document.createElement("div");
 | 
			
		||||
        chatHeader.classList.add("chat-header");
 | 
			
		||||
    this.container = document.createElement("section");
 | 
			
		||||
    this.container.classList.add("chat-area", "chat-window");
 | 
			
		||||
 | 
			
		||||
        const chatTitle = document.createElement('h2');
 | 
			
		||||
        chatTitle.classList.add("chat-title");
 | 
			
		||||
        chatTitle.classList.add("no-select");
 | 
			
		||||
        chatTitle.innerText = "Loading...";
 | 
			
		||||
        chatHeader.appendChild(chatTitle);
 | 
			
		||||
        this.container.appendChild(chatHeader);
 | 
			
		||||
    const chatHeader = document.createElement("div");
 | 
			
		||||
    chatHeader.classList.add("chat-header");
 | 
			
		||||
 | 
			
		||||
        const channels = await app.rpc.getChannels();
 | 
			
		||||
        const channel = channels[0];
 | 
			
		||||
        this.channel = channel;
 | 
			
		||||
        chatTitle.innerText = channel.name;
 | 
			
		||||
    const chatTitle = document.createElement("h2");
 | 
			
		||||
    chatTitle.classList.add("chat-title");
 | 
			
		||||
    chatTitle.classList.add("no-select");
 | 
			
		||||
    chatTitle.innerText = "Loading...";
 | 
			
		||||
    chatHeader.appendChild(chatTitle);
 | 
			
		||||
    this.container.appendChild(chatHeader);
 | 
			
		||||
 | 
			
		||||
        const channelElement = document.createElement('message-list');
 | 
			
		||||
        channelElement.setAttribute("channel", channel.uid);
 | 
			
		||||
        this.container.appendChild(channelElement);
 | 
			
		||||
    const channels = await app.rpc.getChannels();
 | 
			
		||||
    const channel = channels[0];
 | 
			
		||||
    this.channel = channel;
 | 
			
		||||
    chatTitle.innerText = channel.name;
 | 
			
		||||
 | 
			
		||||
        const chatInput = document.createElement('chat-input');
 | 
			
		||||
        chatInput.chatWindow = this;
 | 
			
		||||
        chatInput.addEventListener("submit", (e) => {
 | 
			
		||||
            app.rpc.sendMessage(channel.uid, e.detail);
 | 
			
		||||
        });
 | 
			
		||||
        this.container.appendChild(chatInput);
 | 
			
		||||
    const channelElement = document.createElement("message-list");
 | 
			
		||||
    channelElement.setAttribute("channel", channel.uid);
 | 
			
		||||
    this.container.appendChild(channelElement);
 | 
			
		||||
 | 
			
		||||
        this.component.appendChild(this.container);
 | 
			
		||||
    const chatInput = document.createElement("chat-input");
 | 
			
		||||
    chatInput.chatWindow = this;
 | 
			
		||||
    chatInput.addEventListener("submit", (e) => {
 | 
			
		||||
      app.rpc.sendMessage(channel.uid, e.detail);
 | 
			
		||||
    });
 | 
			
		||||
    this.container.appendChild(chatInput);
 | 
			
		||||
 | 
			
		||||
        const messages = await app.rpc.getMessages(channel.uid);
 | 
			
		||||
        messages.forEach(message => {
 | 
			
		||||
            if (!message['user_nick']) return;
 | 
			
		||||
            channelElement.addMessage(message);
 | 
			
		||||
        });
 | 
			
		||||
    this.component.appendChild(this.container);
 | 
			
		||||
 | 
			
		||||
        const me = this;
 | 
			
		||||
        channelElement.addEventListener("message", (message) => {
 | 
			
		||||
            if (me.user.uid !== message.detail.user_uid) app.playSound(0);
 | 
			
		||||
            
 | 
			
		||||
            message.detail.element.scrollIntoView({"block": "end"});
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    const messages = await app.rpc.getMessages(channel.uid);
 | 
			
		||||
    messages.forEach((message) => {
 | 
			
		||||
      if (!message["user_nick"]) return;
 | 
			
		||||
      channelElement.addMessage(message);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const me = this;
 | 
			
		||||
    channelElement.addEventListener("message", (message) => {
 | 
			
		||||
      if (me.user.uid !== message.detail.user_uid) app.playSound(0);
 | 
			
		||||
 | 
			
		||||
      message.detail.element.scrollIntoView({ block: "end" });
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('chat-window', ChatWindowElement);
 | 
			
		||||
customElements.define("chat-window", ChatWindowElement);
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										150
									
								
								src/snek/static/dumb-term.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								src/snek/static/dumb-term.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,150 @@
 | 
			
		||||
class DumbTerminal extends HTMLElement {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.shadowRoot.innerHTML = `
 | 
			
		||||
      <style>
 | 
			
		||||
        :host {
 | 
			
		||||
          --terminal-bg: #111;
 | 
			
		||||
          --terminal-fg: #0f0;
 | 
			
		||||
          --terminal-accent: #0ff;
 | 
			
		||||
          --terminal-font: monospace;
 | 
			
		||||
 | 
			
		||||
          display: block;
 | 
			
		||||
          background: var(--terminal-bg);
 | 
			
		||||
          color: var(--terminal-fg);
 | 
			
		||||
          font-family: var(--terminal-font);
 | 
			
		||||
          padding: 1rem;
 | 
			
		||||
          border-radius: 8px;
 | 
			
		||||
          overflow-y: auto;
 | 
			
		||||
          max-height: 500px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .output {
 | 
			
		||||
          white-space: pre-wrap;
 | 
			
		||||
          margin-bottom: 1em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .input-line {
 | 
			
		||||
          display: flex;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .prompt {
 | 
			
		||||
          color: var(--terminal-accent);
 | 
			
		||||
          margin-right: 0.5em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        input {
 | 
			
		||||
          background: transparent;
 | 
			
		||||
          border: none;
 | 
			
		||||
          color: var(--terminal-fg);
 | 
			
		||||
          outline: none;
 | 
			
		||||
          width: 100%;
 | 
			
		||||
          font-family: inherit;
 | 
			
		||||
          font-size: inherit;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        dialog {
 | 
			
		||||
          border: none;
 | 
			
		||||
          background: transparent;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .dialog-backdrop {
 | 
			
		||||
          background: rgba(0, 0, 0, 0.8);
 | 
			
		||||
          padding: 2rem;
 | 
			
		||||
        }
 | 
			
		||||
      </style>
 | 
			
		||||
 | 
			
		||||
      <div class="output" id="output"></div>
 | 
			
		||||
      <div class="input-line">
 | 
			
		||||
        <span class="prompt">></span>
 | 
			
		||||
        <input type="text" id="input" autocomplete="off" autofocus />
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    this.outputEl = this.shadowRoot.getElementById("output");
 | 
			
		||||
    this.inputEl = this.shadowRoot.getElementById("input");
 | 
			
		||||
 | 
			
		||||
    this.history = [];
 | 
			
		||||
    this.historyIndex = -1;
 | 
			
		||||
 | 
			
		||||
    this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onKeyDown(event) {
 | 
			
		||||
    const value = this.inputEl.value;
 | 
			
		||||
 | 
			
		||||
    switch (event.key) {
 | 
			
		||||
      case "Enter":
 | 
			
		||||
        this.executeCommand(value);
 | 
			
		||||
        this.history.push(value);
 | 
			
		||||
        this.historyIndex = this.history.length;
 | 
			
		||||
        this.inputEl.value = "";
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
      case "ArrowUp":
 | 
			
		||||
        if (this.historyIndex > 0) {
 | 
			
		||||
          this.historyIndex--;
 | 
			
		||||
          this.inputEl.value = this.history[this.historyIndex];
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
 | 
			
		||||
      case "ArrowDown":
 | 
			
		||||
        if (this.historyIndex < this.history.length - 1) {
 | 
			
		||||
          this.historyIndex++;
 | 
			
		||||
          this.inputEl.value = this.history[this.historyIndex];
 | 
			
		||||
        } else {
 | 
			
		||||
          this.historyIndex = this.history.length;
 | 
			
		||||
          this.inputEl.value = "";
 | 
			
		||||
        }
 | 
			
		||||
        break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  executeCommand(command) {
 | 
			
		||||
    const outputLine = document.createElement("div");
 | 
			
		||||
    outputLine.textContent = `> ${command}`;
 | 
			
		||||
    this.outputEl.appendChild(outputLine);
 | 
			
		||||
 | 
			
		||||
    const resultLine = document.createElement("div");
 | 
			
		||||
    resultLine.textContent = this.mockExecute(command);
 | 
			
		||||
    this.outputEl.appendChild(resultLine);
 | 
			
		||||
 | 
			
		||||
    this.outputEl.scrollTop = this.outputEl.scrollHeight;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  mockExecute(command) {
 | 
			
		||||
    switch (command.trim()) {
 | 
			
		||||
      case "help":
 | 
			
		||||
        return "Available commands: help, clear, date";
 | 
			
		||||
      case "date":
 | 
			
		||||
        return new Date().toString();
 | 
			
		||||
      case "clear":
 | 
			
		||||
        this.outputEl.innerHTML = "";
 | 
			
		||||
        return "";
 | 
			
		||||
      default:
 | 
			
		||||
        return `Unknown command: ${command}`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Static method to create a modal dialog with the terminal
 | 
			
		||||
   * @returns {HTMLDialogElement}
 | 
			
		||||
   */
 | 
			
		||||
  static createModal() {
 | 
			
		||||
    const dialog = document.createElement("dialog");
 | 
			
		||||
    dialog.innerHTML = `
 | 
			
		||||
      <div class="dialog-backdrop">
 | 
			
		||||
        <web-terminal></web-terminal>
 | 
			
		||||
      </div>
 | 
			
		||||
    `;
 | 
			
		||||
    document.body.appendChild(dialog);
 | 
			
		||||
    dialog.showModal();
 | 
			
		||||
    return dialog;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define("web-terminal", WebTerminal);
 | 
			
		||||
@ -1,16 +1,15 @@
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
export class EventHandler {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.subscribers = {};
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.subscribers = {};
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    addEventListener(type, handler) {
 | 
			
		||||
        if (!this.subscribers[type]) this.subscribers[type] = [];
 | 
			
		||||
        this.subscribers[type].push(handler);
 | 
			
		||||
    }
 | 
			
		||||
  addEventListener(type, handler) {
 | 
			
		||||
    if (!this.subscribers[type]) this.subscribers[type] = [];
 | 
			
		||||
    this.subscribers[type].push(handler);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    emit(type, ...data) {
 | 
			
		||||
        if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
  emit(type, ...data) {
 | 
			
		||||
    if (this.subscribers[type])
 | 
			
		||||
      this.subscribers[type].forEach((handler) => handler(...data));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,28 +2,25 @@
 | 
			
		||||
 | 
			
		||||
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// MIT License
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FancyButton extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.url = null;
 | 
			
		||||
        this.type = "button";
 | 
			
		||||
        this.value = null;
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.url = null;
 | 
			
		||||
    this.type = "button";
 | 
			
		||||
    this.value = null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        this.container = document.createElement('span');
 | 
			
		||||
        let size = this.getAttribute('size');
 | 
			
		||||
        console.info({ GG: size });
 | 
			
		||||
        size = size === 'auto' ? '1%' : '33%';
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.container = document.createElement("span");
 | 
			
		||||
    let size = this.getAttribute("size");
 | 
			
		||||
    console.info({ GG: size });
 | 
			
		||||
    size = size === "auto" ? "1%" : "33%";
 | 
			
		||||
 | 
			
		||||
        this.styleElement = document.createElement("style");
 | 
			
		||||
        this.styleElement.innerHTML = `
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.innerHTML = `
 | 
			
		||||
        :root {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            --width: 100%;
 | 
			
		||||
@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
 | 
			
		||||
        }
 | 
			
		||||
        `;
 | 
			
		||||
 | 
			
		||||
        this.container.appendChild(this.styleElement);
 | 
			
		||||
        this.buttonElement = document.createElement('button');
 | 
			
		||||
        this.container.appendChild(this.buttonElement);
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
    this.container.appendChild(this.styleElement);
 | 
			
		||||
    this.buttonElement = document.createElement("button");
 | 
			
		||||
    this.container.appendChild(this.buttonElement);
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
 | 
			
		||||
        this.url = this.getAttribute('url');
 | 
			
		||||
       
 | 
			
		||||
    this.url = this.getAttribute("url");
 | 
			
		||||
 | 
			
		||||
        this.value = this.getAttribute('value');
 | 
			
		||||
        this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
 | 
			
		||||
        this.buttonElement.addEventListener("click", () => {
 | 
			
		||||
            if(this.url == 'submit'){
 | 
			
		||||
                this.closest('form').submit()
 | 
			
		||||
                return
 | 
			
		||||
            }    
 | 
			
		||||
        
 | 
			
		||||
            if (this.url === "/back" || this.url === "/back/") {
 | 
			
		||||
                window.history.back();
 | 
			
		||||
            } else if (this.url) {
 | 
			
		||||
                window.location = this.url;
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    this.value = this.getAttribute("value");
 | 
			
		||||
    this.buttonElement.appendChild(
 | 
			
		||||
      document.createTextNode(this.getAttribute("text")),
 | 
			
		||||
    );
 | 
			
		||||
    this.buttonElement.addEventListener("click", () => {
 | 
			
		||||
      if (this.url == "submit") {
 | 
			
		||||
        this.closest("form").submit();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (this.url === "/back" || this.url === "/back/") {
 | 
			
		||||
        window.history.back();
 | 
			
		||||
      } else if (this.url) {
 | 
			
		||||
        window.location = this.url;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define("fancy-button", FancyButton);
 | 
			
		||||
 | 
			
		||||
@ -3,13 +3,13 @@ class FileBrowser extends HTMLElement {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.path = "";      // current virtual path ("" = ROOT)
 | 
			
		||||
    this.offset = 0;      // pagination offset
 | 
			
		||||
    this.limit = 40;      // items per request
 | 
			
		||||
    this.path = ""; // current virtual path ("" = ROOT)
 | 
			
		||||
    this.offset = 0; // pagination offset
 | 
			
		||||
    this.limit = 40; // items per request
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
      this.path = this.getAttribute("path") || "";
 | 
			
		||||
    this.path = this.getAttribute("path") || "";
 | 
			
		||||
    this.renderShell();
 | 
			
		||||
    this.load();
 | 
			
		||||
  }
 | 
			
		||||
@ -40,19 +40,30 @@ class FileBrowser extends HTMLElement {
 | 
			
		||||
        <button id="next">Next</button>
 | 
			
		||||
      </nav>
 | 
			
		||||
    `;
 | 
			
		||||
    this.shadowRoot.getElementById("up").addEventListener("click", () => this.goUp());
 | 
			
		||||
    this.shadowRoot
 | 
			
		||||
      .getElementById("up")
 | 
			
		||||
      .addEventListener("click", () => this.goUp());
 | 
			
		||||
    this.shadowRoot.getElementById("prev").addEventListener("click", () => {
 | 
			
		||||
      if (this.offset > 0) { this.offset -= this.limit; this.load(); }
 | 
			
		||||
      if (this.offset > 0) {
 | 
			
		||||
        this.offset -= this.limit;
 | 
			
		||||
        this.load();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    this.shadowRoot.getElementById("next").addEventListener("click", () => {
 | 
			
		||||
      this.offset += this.limit; this.load();
 | 
			
		||||
      this.offset += this.limit;
 | 
			
		||||
      this.load();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // ---------- Networking ----------------------------------------------
 | 
			
		||||
  async load() {
 | 
			
		||||
    const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`);
 | 
			
		||||
    if (!r.ok) { console.error(await r.text()); return; }
 | 
			
		||||
    const r = await fetch(
 | 
			
		||||
      `/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
 | 
			
		||||
    );
 | 
			
		||||
    if (!r.ok) {
 | 
			
		||||
      console.error(await r.text());
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const data = await r.json();
 | 
			
		||||
    this.renderTiles(data.items);
 | 
			
		||||
    this.updateNav(data.pagination);
 | 
			
		||||
@ -62,13 +73,17 @@ class FileBrowser extends HTMLElement {
 | 
			
		||||
  renderTiles(items) {
 | 
			
		||||
    const grid = this.shadowRoot.getElementById("grid");
 | 
			
		||||
    grid.innerHTML = "";
 | 
			
		||||
    items.forEach(item => {
 | 
			
		||||
    items.forEach((item) => {
 | 
			
		||||
      const tile = document.createElement("div");
 | 
			
		||||
      tile.className = "tile";
 | 
			
		||||
 | 
			
		||||
      if (item.type === "directory") {
 | 
			
		||||
        tile.innerHTML = `<div class="icon">📂</div><div>${item.name}</div>`;
 | 
			
		||||
        tile.addEventListener("click", () => { this.path = item.path; this.offset = 0; this.load(); });
 | 
			
		||||
        tile.addEventListener("click", () => {
 | 
			
		||||
          this.path = item.path;
 | 
			
		||||
          this.offset = 0;
 | 
			
		||||
          this.load();
 | 
			
		||||
        });
 | 
			
		||||
      } else {
 | 
			
		||||
        if (item.mimetype?.startsWith("image/")) {
 | 
			
		||||
          tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
 | 
			
		||||
@ -87,7 +102,7 @@ class FileBrowser extends HTMLElement {
 | 
			
		||||
    this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
 | 
			
		||||
    this.shadowRoot.getElementById("prev").disabled = offset === 0;
 | 
			
		||||
    this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
 | 
			
		||||
    this.shadowRoot.getElementById("up").disabled   = this.path === "";
 | 
			
		||||
    this.shadowRoot.getElementById("up").disabled = this.path === "";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  goUp() {
 | 
			
		||||
 | 
			
		||||
@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  set value(val) {
 | 
			
		||||
    val = val ?? '';
 | 
			
		||||
    val = val ?? "";
 | 
			
		||||
    this.inputElement.value = val;
 | 
			
		||||
    this.inputElement.setAttribute("value", val);
 | 
			
		||||
  }
 | 
			
		||||
@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({mode: 'open'});
 | 
			
		||||
    this.container = document.createElement('div');
 | 
			
		||||
    this.styleElement = document.createElement('style');
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.innerHTML = `
 | 
			
		||||
 | 
			
		||||
      h1 {
 | 
			
		||||
@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
 | 
			
		||||
 | 
			
		||||
    if (this.inputElement == null && this.field) {
 | 
			
		||||
      this.inputElement = document.createElement(this.field.tag);
 | 
			
		||||
      if (this.field.tag === 'button' && this.field.value === "submit") {
 | 
			
		||||
      if (this.field.tag === "button" && this.field.value === "submit") {
 | 
			
		||||
        this.action = this.field.value;
 | 
			
		||||
      }
 | 
			
		||||
      this.inputElement.name = this.field.name;
 | 
			
		||||
@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
 | 
			
		||||
 | 
			
		||||
      const me = this;
 | 
			
		||||
      this.inputElement.addEventListener("keyup", (e) => {
 | 
			
		||||
        if (e.key === 'Enter') {
 | 
			
		||||
          const event = new CustomEvent("change", {detail: me, bubbles: true});
 | 
			
		||||
        if (e.key === "Enter") {
 | 
			
		||||
          const event = new CustomEvent("change", {
 | 
			
		||||
            detail: me,
 | 
			
		||||
            bubbles: true,
 | 
			
		||||
          });
 | 
			
		||||
          me.dispatchEvent(event);
 | 
			
		||||
 | 
			
		||||
          me.dispatchEvent(new Event("submit"));
 | 
			
		||||
        } else if (me.field.value !== e.target.value) {
 | 
			
		||||
          const event = new CustomEvent("change", {detail: me, bubbles: true});
 | 
			
		||||
          const event = new CustomEvent("change", {
 | 
			
		||||
            detail: me,
 | 
			
		||||
            bubbles: true,
 | 
			
		||||
          });
 | 
			
		||||
          me.dispatchEvent(event);
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.inputElement.addEventListener("click", (e) => {
 | 
			
		||||
        const event = new CustomEvent("click", {detail: me, bubbles: true});
 | 
			
		||||
        const event = new CustomEvent("click", { detail: me, bubbles: true });
 | 
			
		||||
        me.dispatchEvent(event);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this.inputElement.addEventListener("blur", (e) => {
 | 
			
		||||
        const event = new CustomEvent("change", {detail: me, bubbles: true});
 | 
			
		||||
        me.dispatchEvent(event);
 | 
			
		||||
      }, true);
 | 
			
		||||
      this.inputElement.addEventListener(
 | 
			
		||||
        "blur",
 | 
			
		||||
        (e) => {
 | 
			
		||||
          const event = new CustomEvent("change", {
 | 
			
		||||
            detail: me,
 | 
			
		||||
            bubbles: true,
 | 
			
		||||
          });
 | 
			
		||||
          me.dispatchEvent(event);
 | 
			
		||||
        },
 | 
			
		||||
        true,
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      this.container.appendChild(this.inputElement);
 | 
			
		||||
    }
 | 
			
		||||
@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.inputElement.setAttribute("type", this.field.type ?? 'input');
 | 
			
		||||
    this.inputElement.setAttribute("name", this.field.name ?? '');
 | 
			
		||||
    this.inputElement.setAttribute("type", this.field.type ?? "input");
 | 
			
		||||
    this.inputElement.setAttribute("name", this.field.name ?? "");
 | 
			
		||||
 | 
			
		||||
    if (this.field.text != null) {
 | 
			
		||||
      this.inputElement.innerText = this.field.text;
 | 
			
		||||
@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
 | 
			
		||||
      this.inputElement.removeAttribute("required");
 | 
			
		||||
    }
 | 
			
		||||
    if (!this.footerElement) {
 | 
			
		||||
      this.footerElement = document.createElement('div');
 | 
			
		||||
      this.footerElement.style.clear = 'both';
 | 
			
		||||
      this.footerElement = document.createElement("div");
 | 
			
		||||
      this.footerElement.style.clear = "both";
 | 
			
		||||
      this.container.appendChild(this.footerElement);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('generic-field', GenericField);
 | 
			
		||||
customElements.define("generic-field", GenericField);
 | 
			
		||||
 | 
			
		||||
class GenericForm extends HTMLElement {
 | 
			
		||||
  fields = {};
 | 
			
		||||
@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({mode: 'open'});
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.innerHTML = `
 | 
			
		||||
 | 
			
		||||
@ -281,27 +294,29 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
      }
 | 
			
		||||
    }`;
 | 
			
		||||
 | 
			
		||||
    this.container = document.createElement('div');
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.container.appendChild(this.styleElement);
 | 
			
		||||
    this.container.classList.add("generic-form-container");
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    const preloadedForm = this.getAttribute('preloaded-structure');
 | 
			
		||||
    const preloadedForm = this.getAttribute("preloaded-structure");
 | 
			
		||||
    if (preloadedForm) {
 | 
			
		||||
      try {
 | 
			
		||||
        const form = JSON.parse(preloadedForm);
 | 
			
		||||
        this.constructForm(form)
 | 
			
		||||
        this.constructForm(form);
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error(error, preloadedForm);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    const url = this.getAttribute('url');
 | 
			
		||||
    const url = this.getAttribute("url");
 | 
			
		||||
    if (url) {
 | 
			
		||||
      const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
 | 
			
		||||
      const fullUrl = url.startsWith("/")
 | 
			
		||||
        ? window.location.origin + url
 | 
			
		||||
        : new URL(window.location.origin + "/http-get");
 | 
			
		||||
      if (!url.startsWith("/")) {
 | 
			
		||||
        fullUrl.searchParams.set('url', url);
 | 
			
		||||
        fullUrl.searchParams.set("url", url);
 | 
			
		||||
      }
 | 
			
		||||
      this.loadForm(fullUrl.toString());
 | 
			
		||||
    } else {
 | 
			
		||||
@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
      let hasAutoFocus = Object.keys(this.fields).length !== 0;
 | 
			
		||||
 | 
			
		||||
      fields.sort((a, b) => a.index - b.index);
 | 
			
		||||
      fields.forEach(field => {
 | 
			
		||||
        const updatingField = field.name in this.fields
 | 
			
		||||
      fields.forEach((field) => {
 | 
			
		||||
        const updatingField = field.name in this.fields;
 | 
			
		||||
 | 
			
		||||
        this.fields[field.name] ??= document.createElement('generic-field');
 | 
			
		||||
        this.fields[field.name] ??= document.createElement("generic-field");
 | 
			
		||||
 | 
			
		||||
        const fieldElement = this.fields[field.name];
 | 
			
		||||
 | 
			
		||||
@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
                window.location.pathname = saveResult.redirect_url;
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
 | 
			
		||||
        throw new Error(
 | 
			
		||||
          `Failed to fetch: ${response.status} ${response.statusText}`,
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await this.constructForm(await response.json());
 | 
			
		||||
@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
    const url = this.getAttribute("url");
 | 
			
		||||
 | 
			
		||||
    let response = await fetch(url, {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'application/json'
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify({"action": "validate", "form": this.form})
 | 
			
		||||
      body: JSON.stringify({ action: "validate", form: this.form }),
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const form = await response.json();
 | 
			
		||||
    Object.values(form.fields).forEach(field => {
 | 
			
		||||
    Object.values(form.fields).forEach((field) => {
 | 
			
		||||
      if (!this.form.fields[field.name]) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
 | 
			
		||||
      this.fields[field.name].setAttribute("field", field);
 | 
			
		||||
      this.fields[field.name].updateAttributes();
 | 
			
		||||
    });
 | 
			
		||||
    Object.values(form.fields).forEach(field => {
 | 
			
		||||
    Object.values(form.fields).forEach((field) => {
 | 
			
		||||
      this.fields[field.name].setErrors(field.errors);
 | 
			
		||||
    });
 | 
			
		||||
    return form['is_valid'];
 | 
			
		||||
    return form["is_valid"];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async submit() {
 | 
			
		||||
    const url = this.getAttribute("url");
 | 
			
		||||
    const response = await fetch(url, {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      method: "POST",
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'application/json'
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify({"action": "submit", "form": this.form})
 | 
			
		||||
      body: JSON.stringify({ action: "submit", form: this.form }),
 | 
			
		||||
    });
 | 
			
		||||
    return await response.json();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('generic-form', GenericForm);
 | 
			
		||||
customElements.define("generic-form", GenericForm);
 | 
			
		||||
 | 
			
		||||
@ -7,48 +7,50 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
 | 
			
		||||
class HTMLFrame extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.container = document.createElement('div');
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        this.container.classList.add("html_frame");
 | 
			
		||||
        let url = this.getAttribute('url');
 | 
			
		||||
        if (!url.startsWith("https")) {
 | 
			
		||||
            url = "https://" + url;
 | 
			
		||||
        }
 | 
			
		||||
        if (url) {
 | 
			
		||||
            const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
 | 
			
		||||
            if (!url.startsWith("/")) {
 | 
			
		||||
                fullUrl.searchParams.set('url', url);
 | 
			
		||||
            }
 | 
			
		||||
            this.loadAndRender(fullUrl.toString());
 | 
			
		||||
        } else {
 | 
			
		||||
            this.container.textContent = "No source URL!";
 | 
			
		||||
        }
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.container.classList.add("html_frame");
 | 
			
		||||
    let url = this.getAttribute("url");
 | 
			
		||||
    if (!url.startsWith("https")) {
 | 
			
		||||
      url = "https://" + url;
 | 
			
		||||
    }
 | 
			
		||||
    if (url) {
 | 
			
		||||
      const fullUrl = url.startsWith("/")
 | 
			
		||||
        ? window.location.origin + url
 | 
			
		||||
        : new URL(window.location.origin + "/http-get");
 | 
			
		||||
      if (!url.startsWith("/")) {
 | 
			
		||||
        fullUrl.searchParams.set("url", url);
 | 
			
		||||
      }
 | 
			
		||||
      this.loadAndRender(fullUrl.toString());
 | 
			
		||||
    } else {
 | 
			
		||||
      this.container.textContent = "No source URL!";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async loadAndRender(url) {
 | 
			
		||||
        try {
 | 
			
		||||
            const response = await fetch(url);
 | 
			
		||||
            if (!response.ok) {
 | 
			
		||||
                throw new Error(`Error: ${response.status} ${response.statusText}`);
 | 
			
		||||
            }
 | 
			
		||||
            const html = await response.text();
 | 
			
		||||
            if (url.endsWith(".md")) {
 | 
			
		||||
                const markdownElement = document.createElement('div');
 | 
			
		||||
                markdownElement.innerHTML = html;
 | 
			
		||||
                this.outerHTML = html;
 | 
			
		||||
            } else {
 | 
			
		||||
                this.container.innerHTML = html;
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            this.container.textContent = `Error: ${error.message}`;
 | 
			
		||||
        }
 | 
			
		||||
  async loadAndRender(url) {
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch(url);
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`Error: ${response.status} ${response.statusText}`);
 | 
			
		||||
      }
 | 
			
		||||
      const html = await response.text();
 | 
			
		||||
      if (url.endsWith(".md")) {
 | 
			
		||||
        const markdownElement = document.createElement("div");
 | 
			
		||||
        markdownElement.innerHTML = html;
 | 
			
		||||
        this.outerHTML = html;
 | 
			
		||||
      } else {
 | 
			
		||||
        this.container.innerHTML = html;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      this.container.textContent = `Error: ${error.message}`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('html-frame', HTMLFrame);
 | 
			
		||||
customElements.define("html-frame", HTMLFrame);
 | 
			
		||||
 | 
			
		||||
@ -12,22 +12,22 @@
 | 
			
		||||
class HTMLFrame extends HTMLElement {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: 'open' });
 | 
			
		||||
    this.container = document.createElement('div');
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.container.classList.add('html_frame');
 | 
			
		||||
    const url = this.getAttribute('url');
 | 
			
		||||
    this.container.classList.add("html_frame");
 | 
			
		||||
    const url = this.getAttribute("url");
 | 
			
		||||
    if (url) {
 | 
			
		||||
      const fullUrl = url.startsWith('/')
 | 
			
		||||
      const fullUrl = url.startsWith("/")
 | 
			
		||||
        ? window.location.origin + url
 | 
			
		||||
        : new URL(window.location.origin + '/http-get');
 | 
			
		||||
      if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);
 | 
			
		||||
        : new URL(window.location.origin + "/http-get");
 | 
			
		||||
      if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
 | 
			
		||||
      this.loadAndRender(fullUrl.toString());
 | 
			
		||||
    } else {
 | 
			
		||||
      this.container.textContent = 'No source URL!';
 | 
			
		||||
      this.container.textContent = "No source URL!";
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('markdown-frame', HTMLFrame);
 | 
			
		||||
customElements.define("markdown-frame", HTMLFrame);
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@
 | 
			
		||||
 | 
			
		||||
// No external libraries or dependencies are used other than standard web components.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// MIT License
 | 
			
		||||
//
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 | 
			
		||||
@ -13,20 +12,19 @@
 | 
			
		||||
//
 | 
			
		||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TileGridElement extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.gridId = this.getAttribute('grid');
 | 
			
		||||
        this.component = document.createElement('div');
 | 
			
		||||
        this.shadowRoot.appendChild(this.component);
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.gridId = this.getAttribute("grid");
 | 
			
		||||
    this.component = document.createElement("div");
 | 
			
		||||
    this.shadowRoot.appendChild(this.component);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        console.log('connected');
 | 
			
		||||
        this.styleElement = document.createElement('style');
 | 
			
		||||
        this.styleElement.textContent = `
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    console.log("connected");
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.textContent = `
 | 
			
		||||
            .grid {
 | 
			
		||||
                padding: 10px;
 | 
			
		||||
                display: flex;
 | 
			
		||||
@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
 | 
			
		||||
                transform: scale(1.1);
 | 
			
		||||
            }
 | 
			
		||||
        `;
 | 
			
		||||
        this.component.appendChild(this.styleElement);
 | 
			
		||||
        this.container = document.createElement('div');
 | 
			
		||||
        this.container.classList.add('gallery');
 | 
			
		||||
        this.component.appendChild(this.container);
 | 
			
		||||
    }
 | 
			
		||||
    this.component.appendChild(this.styleElement);
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.container.classList.add("gallery");
 | 
			
		||||
    this.component.appendChild(this.container);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    addImage(src) {
 | 
			
		||||
        const item = document.createElement('img');
 | 
			
		||||
        item.src = src;
 | 
			
		||||
        item.classList.add('tile');
 | 
			
		||||
        item.style.width = '100px';
 | 
			
		||||
        item.style.height = '100px';
 | 
			
		||||
        this.container.appendChild(item);
 | 
			
		||||
    }
 | 
			
		||||
  addImage(src) {
 | 
			
		||||
    const item = document.createElement("img");
 | 
			
		||||
    item.src = src;
 | 
			
		||||
    item.classList.add("tile");
 | 
			
		||||
    item.style.width = "100px";
 | 
			
		||||
    item.style.height = "100px";
 | 
			
		||||
    this.container.appendChild(item);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    addImages(srcs) {
 | 
			
		||||
        srcs.forEach(src => this.addImage(src));
 | 
			
		||||
    }
 | 
			
		||||
  addImages(srcs) {
 | 
			
		||||
    srcs.forEach((src) => this.addImage(src));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    addElement(element) {
 | 
			
		||||
        element.classList.add('tile');
 | 
			
		||||
        this.container.appendChild(element);
 | 
			
		||||
    }
 | 
			
		||||
  addElement(element) {
 | 
			
		||||
    element.classList.add("tile");
 | 
			
		||||
    this.container.appendChild(element);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class UploadButton extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.component = document.createElement('div');
 | 
			
		||||
        this.shadowRoot.appendChild(this.component);
 | 
			
		||||
        window.u = this;
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.component = document.createElement("div");
 | 
			
		||||
    this.shadowRoot.appendChild(this.component);
 | 
			
		||||
    window.u = this;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    get gridSelector() {
 | 
			
		||||
        return this.getAttribute('grid');
 | 
			
		||||
    }
 | 
			
		||||
    grid = null;
 | 
			
		||||
  get gridSelector() {
 | 
			
		||||
    return this.getAttribute("grid");
 | 
			
		||||
  }
 | 
			
		||||
  grid = null;
 | 
			
		||||
 | 
			
		||||
    addImages(urls) {
 | 
			
		||||
        this.grid.addImages(urls);
 | 
			
		||||
    }
 | 
			
		||||
  addImages(urls) {
 | 
			
		||||
    this.grid.addImages(urls);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        console.log('connected');
 | 
			
		||||
        this.styleElement = document.createElement('style');
 | 
			
		||||
        this.styleElement.textContent = `
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    console.log("connected");
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.textContent = `
 | 
			
		||||
        .upload-button {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
 | 
			
		||||
            background-color: #999;
 | 
			
		||||
        }
 | 
			
		||||
        `;
 | 
			
		||||
        this.shadowRoot.appendChild(this.styleElement);
 | 
			
		||||
        this.container = document.createElement('div');
 | 
			
		||||
        this.container.classList.add('upload-button');
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
        const input = document.createElement('input');
 | 
			
		||||
        input.type = 'file';
 | 
			
		||||
        input.accept = 'image/*';
 | 
			
		||||
        input.multiple = true;
 | 
			
		||||
        input.addEventListener('change', (e) => {
 | 
			
		||||
            const files = e.target.files;
 | 
			
		||||
            const urls = [];
 | 
			
		||||
            for (let i = 0; i < files.length; i++) {
 | 
			
		||||
                const reader = new FileReader();
 | 
			
		||||
                reader.onload = (e) => {
 | 
			
		||||
                    urls.push(e.target.result);
 | 
			
		||||
                    if (urls.length === files.length) {
 | 
			
		||||
                        this.addImages(urls);
 | 
			
		||||
                    }
 | 
			
		||||
                };
 | 
			
		||||
                reader.readAsDataURL(files[i]);
 | 
			
		||||
            }
 | 
			
		||||
         });
 | 
			
		||||
        const label = document.createElement('label');
 | 
			
		||||
        label.textContent = 'Upload Images';
 | 
			
		||||
        label.appendChild(input);
 | 
			
		||||
        this.container.appendChild(label);
 | 
			
		||||
    }
 | 
			
		||||
    this.shadowRoot.appendChild(this.styleElement);
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.container.classList.add("upload-button");
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
    const input = document.createElement("input");
 | 
			
		||||
    input.type = "file";
 | 
			
		||||
    input.accept = "image/*";
 | 
			
		||||
    input.multiple = true;
 | 
			
		||||
    input.addEventListener("change", (e) => {
 | 
			
		||||
      const files = e.target.files;
 | 
			
		||||
      const urls = [];
 | 
			
		||||
      for (let i = 0; i < files.length; i++) {
 | 
			
		||||
        const reader = new FileReader();
 | 
			
		||||
        reader.onload = (e) => {
 | 
			
		||||
          urls.push(e.target.result);
 | 
			
		||||
          if (urls.length === files.length) {
 | 
			
		||||
            this.addImages(urls);
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
        reader.readAsDataURL(files[i]);
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    const label = document.createElement("label");
 | 
			
		||||
    label.textContent = "Upload Images";
 | 
			
		||||
    label.appendChild(input);
 | 
			
		||||
    this.container.appendChild(label);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('upload-button', UploadButton);
 | 
			
		||||
customElements.define('tile-grid', TileGridElement);
 | 
			
		||||
customElements.define("upload-button", UploadButton);
 | 
			
		||||
customElements.define("tile-grid", TileGridElement);
 | 
			
		||||
 | 
			
		||||
class MeniaUploadElement extends HTMLElement {
 | 
			
		||||
    constructor(){
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.component = document.createElement("div");
 | 
			
		||||
        alert('aaaa');
 | 
			
		||||
        this.shadowRoot.appendChild(this.component);
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.component = document.createElement("div");
 | 
			
		||||
    alert("aaaa");
 | 
			
		||||
    this.shadowRoot.appendChild(this.component);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        this.container = document.createElement("div");
 | 
			
		||||
        this.component.style.height = '100%';
 | 
			
		||||
        this.component.style.backgroundColor = 'blue';
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.component.style.height = "100%";
 | 
			
		||||
    this.component.style.backgroundColor = "blue";
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
 | 
			
		||||
        this.tileElement = document.createElement("tile-grid");
 | 
			
		||||
        this.tileElement.style.backgroundColor = 'red';
 | 
			
		||||
        this.tileElement.style.height = '100%';
 | 
			
		||||
        this.component.appendChild(this.tileElement);
 | 
			
		||||
    this.tileElement = document.createElement("tile-grid");
 | 
			
		||||
    this.tileElement.style.backgroundColor = "red";
 | 
			
		||||
    this.tileElement.style.height = "100%";
 | 
			
		||||
    this.component.appendChild(this.tileElement);
 | 
			
		||||
 | 
			
		||||
        this.uploadButton = document.createElement('upload-button');
 | 
			
		||||
        this.component.appendChild(this.uploadButton);
 | 
			
		||||
    }
 | 
			
		||||
    this.uploadButton = document.createElement("upload-button");
 | 
			
		||||
    this.component.appendChild(this.uploadButton);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('menia-upload', MeniaUploadElement);
 | 
			
		||||
customElements.define("menia-upload", MeniaUploadElement);
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
// This JavaScript source code defines a custom HTML element named "message-list-manager" to manage a list of message lists for different channels obtained asynchronously.
 | 
			
		||||
 | 
			
		||||
//
 | 
			
		||||
// 
 | 
			
		||||
//
 | 
			
		||||
 | 
			
		||||
// MIT License
 | 
			
		||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
 | 
			
		||||
@ -22,23 +22,22 @@
 | 
			
		||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 | 
			
		||||
// SOFTWARE.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class MessageListManagerElement extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.container = document.createElement("div");
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
    }
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async connectedCallback() {
 | 
			
		||||
        const channels = await app.rpc.getChannels();
 | 
			
		||||
        channels.forEach(channel => {
 | 
			
		||||
            const messageList = document.createElement("message-list");
 | 
			
		||||
            messageList.setAttribute("channel", channel.uid);
 | 
			
		||||
            this.container.appendChild(messageList);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  async connectedCallback() {
 | 
			
		||||
    const channels = await app.rpc.getChannels();
 | 
			
		||||
    channels.forEach((channel) => {
 | 
			
		||||
      const messageList = document.createElement("message-list");
 | 
			
		||||
      messageList.setAttribute("channel", channel.uid);
 | 
			
		||||
      this.container.appendChild(messageList);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define("message-list-manager", MessageListManagerElement);
 | 
			
		||||
customElements.define("message-list-manager", MessageListManagerElement);
 | 
			
		||||
 | 
			
		||||
@ -5,226 +5,71 @@
 | 
			
		||||
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
 | 
			
		||||
 | 
			
		||||
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
 | 
			
		||||
 import {app} from '../app.js'
 | 
			
		||||
 class MessageList extends HTMLElement {
 | 
			
		||||
      constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        app.ws.addEventListener("update_message_text",(data)=>{
 | 
			
		||||
            this.updateMessageText(data.data.uid,data.data)
 | 
			
		||||
        })
 | 
			
		||||
          app.ws.addEventListener("set_typing",(data)=>{
 | 
			
		||||
		    this.triggerGlow(data.data.user_uid)	
 | 
			
		||||
import { app } from "../app.js";
 | 
			
		||||
class MessageList extends HTMLElement {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    app.ws.addEventListener("update_message_text", (data) => {
 | 
			
		||||
      this.updateMessageText(data.uid, data);
 | 
			
		||||
    });
 | 
			
		||||
    app.ws.addEventListener("set_typing", (data) => {
 | 
			
		||||
      this.triggerGlow(data.user_uid,data.color);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
	    })
 | 
			
		||||
    this.items = [];
 | 
			
		||||
  }
 | 
			
		||||
  scrollToBottom(force) {
 | 
			
		||||
    console.info("Scrolling down")
 | 
			
		||||
   // if (force) {
 | 
			
		||||
    this.scrollTop = this.scrollHeight;
 | 
			
		||||
 | 
			
		||||
          this.items = [];
 | 
			
		||||
    this.querySelector(".message-list-bottom").scrollIntoView();
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
      
 | 
			
		||||
        this.scrollTop = this.scrollHeight;
 | 
			
		||||
          this.querySelector(".message-list-bottom").scrollIntoView();
 | 
			
		||||
      },200)
 | 
			
		||||
   // }
 | 
			
		||||
  }
 | 
			
		||||
  updateMessageText(uid, message) {
 | 
			
		||||
    const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
 | 
			
		||||
 | 
			
		||||
    if (!messageDiv) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    const receivedHtml = document.createElement("div");
 | 
			
		||||
    receivedHtml.innerHTML = message.html;
 | 
			
		||||
    const html = receivedHtml.querySelector(".text").innerHTML;
 | 
			
		||||
    const textElement = messageDiv.querySelector(".text");
 | 
			
		||||
    textElement.innerHTML = html;
 | 
			
		||||
    textElement.style.display = message.text == "" ? "none" : "block";
 | 
			
		||||
  }
 | 
			
		||||
  triggerGlow(uid,color) {
 | 
			
		||||
    app.starField.glowColor(color)
 | 
			
		||||
      let lastElement = null;
 | 
			
		||||
    this.querySelectorAll(".avatar").forEach((el) => {
 | 
			
		||||
      const div = el.closest("a");
 | 
			
		||||
      if (el.href.indexOf(uid) != -1) {
 | 
			
		||||
        lastElement = el;
 | 
			
		||||
      }
 | 
			
		||||
      updateMessageText(uid,message){
 | 
			
		||||
        const messageDiv = this.querySelector("div[data-uid=\""+uid+"\"]")
 | 
			
		||||
 | 
			
		||||
          if(!messageDiv){
 | 
			
		||||
            return
 | 
			
		||||
          }
 | 
			
		||||
          const receivedHtml = document.createElement("div")
 | 
			
		||||
          receivedHtml.innerHTML = message.html
 | 
			
		||||
          const html = receivedHtml.querySelector(".text").innerHTML
 | 
			
		||||
          const textElement = messageDiv.querySelector(".text")
 | 
			
		||||
          textElement.innerHTML = html 
 | 
			
		||||
          textElement.style.display = message.text == '' ? 'none' : 'block'
 | 
			
		||||
        
 | 
			
		||||
      }
 | 
			
		||||
        triggerGlow(uid) {
 | 
			
		||||
	 	let lastElement = null;
 | 
			
		||||
            this.querySelectorAll(".avatar").forEach((el)=>{
 | 
			
		||||
		   const div = el.closest('a');
 | 
			
		||||
		   if(el.href.indexOf(uid)!=-1){
 | 
			
		||||
			lastElement = el
 | 
			
		||||
            }    		    
 | 
			
		||||
 | 
			
		||||
	      })
 | 
			
		||||
        if(lastElement){
 | 
			
		||||
            lastElement.classList.add("glow")
 | 
			
		||||
            setTimeout(()=>{
 | 
			
		||||
                lastElement.classList.remove("glow")
 | 
			
		||||
            },1000)
 | 
			
		||||
        }
 | 
			
		||||
 	
 | 
			
		||||
    	}
 | 
			
		||||
 | 
			
		||||
      set data(items) {
 | 
			
		||||
        this.items = items;
 | 
			
		||||
          this.render();
 | 
			
		||||
      }
 | 
			
		||||
      render() {
 | 
			
		||||
        this.innerHTML = '';
 | 
			
		||||
 | 
			
		||||
          //this.insertAdjacentHTML("beforeend", html);
 | 
			
		||||
        
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
    });
 | 
			
		||||
    if (lastElement) {
 | 
			
		||||
      lastElement.classList.add("glow");
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        lastElement.classList.remove("glow");
 | 
			
		||||
      }, 1000);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    customElements.define('message-list', MessageList);
 | 
			
		||||
  set data(items) {
 | 
			
		||||
    this.items = items;
 | 
			
		||||
    this.render();
 | 
			
		||||
  }
 | 
			
		||||
  render() {
 | 
			
		||||
    this.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
class MessageListElementOLD extends HTMLElement {
 | 
			
		||||
    static get observedAttributes() {
 | 
			
		||||
        return ["messages"];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    messages = [];
 | 
			
		||||
    room = null;
 | 
			
		||||
    url = null;
 | 
			
		||||
    container = null;
 | 
			
		||||
    messageEventSchedule = null;
 | 
			
		||||
    observer = null;
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
        this.component = document.createElement('div');
 | 
			
		||||
        this.shadowRoot.appendChild(this.component);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    linkifyText(text) {
 | 
			
		||||
        const urlRegex = /https?:\/\/[^\s]+/g;
 | 
			
		||||
        return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    timeAgo(date1, date2) {
 | 
			
		||||
        const diffMs = Math.abs(date2 - date1);
 | 
			
		||||
        const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
 | 
			
		||||
        const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
 | 
			
		||||
        const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
 | 
			
		||||
        const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
 | 
			
		||||
 | 
			
		||||
        if (days) {
 | 
			
		||||
            return `${days} ${days > 1 ? 'days' : 'day'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        if (hours) {
 | 
			
		||||
            return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        if (minutes) {
 | 
			
		||||
            return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
 | 
			
		||||
        }
 | 
			
		||||
        return 'just now';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    timeDescription(isoDate) {
 | 
			
		||||
        const date = new Date(isoDate);
 | 
			
		||||
        const hours = String(date.getHours()).padStart(2, "0");
 | 
			
		||||
        const minutes = String(date.getMinutes()).padStart(2, "0");
 | 
			
		||||
        let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
 | 
			
		||||
        return timeStr;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    createElement(message) {
 | 
			
		||||
        const element = document.createElement("div");
 | 
			
		||||
        element.dataset.uid = message.uid;
 | 
			
		||||
        element.dataset.color = message.color;
 | 
			
		||||
        element.dataset.channel_uid = message.channel_uid;
 | 
			
		||||
        element.dataset.user_nick = message.user_nick;
 | 
			
		||||
        element.dataset.created_at = message.created_at;
 | 
			
		||||
        element.dataset.user_uid = message.user_uid;
 | 
			
		||||
        element.dataset.message = message.message;
 | 
			
		||||
 | 
			
		||||
        element.classList.add("message");
 | 
			
		||||
        if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
 | 
			
		||||
            element.classList.add("switch-user");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const avatar = document.createElement("div");
 | 
			
		||||
        avatar.classList.add("avatar");
 | 
			
		||||
        avatar.classList.add("no-select");
 | 
			
		||||
        avatar.style.backgroundColor = message.color;
 | 
			
		||||
        avatar.style.color = "black";
 | 
			
		||||
        avatar.innerText = message.user_nick[0];
 | 
			
		||||
 | 
			
		||||
        const messageContent = document.createElement("div");
 | 
			
		||||
        messageContent.classList.add("message-content");
 | 
			
		||||
 | 
			
		||||
        const author = document.createElement("div");
 | 
			
		||||
        author.classList.add("author");
 | 
			
		||||
        author.style.color = message.color;
 | 
			
		||||
        author.textContent = message.user_nick;
 | 
			
		||||
 | 
			
		||||
        const text = document.createElement("div");
 | 
			
		||||
        text.classList.add("text");
 | 
			
		||||
        if (message.html) text.innerHTML = message.html;
 | 
			
		||||
 | 
			
		||||
        const time = document.createElement("div");
 | 
			
		||||
        time.classList.add("time");
 | 
			
		||||
        time.dataset.created_at = message.created_at;
 | 
			
		||||
        time.textContent = this.timeDescription(message.created_at);
 | 
			
		||||
 | 
			
		||||
        messageContent.appendChild(author);
 | 
			
		||||
        messageContent.appendChild(text);
 | 
			
		||||
        messageContent.appendChild(time);
 | 
			
		||||
 | 
			
		||||
        element.appendChild(avatar);
 | 
			
		||||
        element.appendChild(messageContent);
 | 
			
		||||
 | 
			
		||||
        message.element = element;
 | 
			
		||||
 | 
			
		||||
        return element;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addMessage(message) {
 | 
			
		||||
        const obj = new models.Message(
 | 
			
		||||
            message.uid,
 | 
			
		||||
            message.channel_uid,
 | 
			
		||||
            message.user_uid,
 | 
			
		||||
            message.user_nick,
 | 
			
		||||
            message.color,
 | 
			
		||||
            message.message,
 | 
			
		||||
            message.html,
 | 
			
		||||
            message.created_at,
 | 
			
		||||
            message.updated_at
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        const element = this.createElement(obj);
 | 
			
		||||
        this.messages.push(obj);
 | 
			
		||||
        this.container.appendChild(element);
 | 
			
		||||
 | 
			
		||||
        this.messageEventSchedule.delay(() => {
 | 
			
		||||
            this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        return obj;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    scrollBottom() {
 | 
			
		||||
        this.container.scrollTop = this.container.scrollHeight;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        const link = document.createElement('link');
 | 
			
		||||
        link.rel = 'stylesheet';
 | 
			
		||||
        link.href = '/base.css';
 | 
			
		||||
        this.component.appendChild(link);
 | 
			
		||||
        this.component.classList.add("chat-messages");
 | 
			
		||||
 | 
			
		||||
        this.container = document.createElement('div');
 | 
			
		||||
        this.component.appendChild(this.container);
 | 
			
		||||
 | 
			
		||||
        this.messageEventSchedule = new Schedule(500);
 | 
			
		||||
        this.messages = [];
 | 
			
		||||
        this.channel_uid = this.getAttribute("channel");
 | 
			
		||||
 | 
			
		||||
        app.addEventListener(this.channel_uid, (data) => {
 | 
			
		||||
            this.addMessage(data);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
 | 
			
		||||
 | 
			
		||||
        this.timeUpdateInterval = setInterval(() => {
 | 
			
		||||
            this.messages.forEach((message) => {
 | 
			
		||||
                const newText = this.timeDescription(message.created_at);
 | 
			
		||||
                if (newText != message.element.innerText) {
 | 
			
		||||
                    message.element.querySelector(".time").innerText = newText;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }, 30000);
 | 
			
		||||
    }
 | 
			
		||||
    //this.insertAdjacentHTML("beforeend", html);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
//customElements.define('message-list', MessageListElement);
 | 
			
		||||
customElements.define("message-list", MessageList);
 | 
			
		||||
 | 
			
		||||
@ -7,20 +7,30 @@
 | 
			
		||||
// MIT License
 | 
			
		||||
 | 
			
		||||
class MessageModel {
 | 
			
		||||
    constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
 | 
			
		||||
        this.uid = uid
 | 
			
		||||
        this.message = message
 | 
			
		||||
        this.html = html
 | 
			
		||||
        this.user_uid = user_uid
 | 
			
		||||
        this.user_nick = user_nick
 | 
			
		||||
        this.color = color
 | 
			
		||||
        this.channel_uid = channel_uid
 | 
			
		||||
        this.created_at = created_at
 | 
			
		||||
        this.updated_at = updated_at
 | 
			
		||||
        this.element = null
 | 
			
		||||
    }       
 | 
			
		||||
  constructor(
 | 
			
		||||
    uid,
 | 
			
		||||
    channel_uid,
 | 
			
		||||
    user_uid,
 | 
			
		||||
    user_nick,
 | 
			
		||||
    color,
 | 
			
		||||
    message,
 | 
			
		||||
    html,
 | 
			
		||||
    created_at,
 | 
			
		||||
    updated_at,
 | 
			
		||||
  ) {
 | 
			
		||||
    this.uid = uid;
 | 
			
		||||
    this.message = message;
 | 
			
		||||
    this.html = html;
 | 
			
		||||
    this.user_uid = user_uid;
 | 
			
		||||
    this.user_nick = user_nick;
 | 
			
		||||
    this.color = color;
 | 
			
		||||
    this.channel_uid = channel_uid;
 | 
			
		||||
    this.created_at = created_at;
 | 
			
		||||
    this.updated_at = updated_at;
 | 
			
		||||
    this.element = null;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const models = {
 | 
			
		||||
    Message: MessageModel
 | 
			
		||||
}
 | 
			
		||||
  Message: MessageModel,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1 +0,0 @@
 | 
			
		||||
 | 
			
		||||
@ -1,30 +1,34 @@
 | 
			
		||||
this.onpush = (event) => {
 | 
			
		||||
            console.log(event.data);
 | 
			
		||||
            // From here we can write the data to IndexedDB, send it to any open
 | 
			
		||||
            // windows, display a notification, etc.
 | 
			
		||||
        };
 | 
			
		||||
  console.log(event.data);
 | 
			
		||||
  // From here we can write the data to IndexedDB, send it to any open
 | 
			
		||||
  // windows, display a notification, etc.
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
        navigator.serviceWorker
 | 
			
		||||
            .register("/service-worker.js")
 | 
			
		||||
            .then((serviceWorkerRegistration) => {
 | 
			
		||||
                serviceWorkerRegistration.pushManager.subscribe().then(
 | 
			
		||||
                    (pushSubscription) => {
 | 
			
		||||
                        const subscriptionObject = {
 | 
			
		||||
                            endpoint: pushSubscription.endpoint,
 | 
			
		||||
                            keys: {
 | 
			
		||||
                                p256dh: pushSubscription.getKey('p256dh'),
 | 
			
		||||
                                auth: pushSubscription.getKey('auth'),
 | 
			
		||||
                            },
 | 
			
		||||
                            encoding: PushManager.supportedContentEncodings,
 | 
			
		||||
                            /* other app-specific data, such as user identity */
 | 
			
		||||
                        };
 | 
			
		||||
                        console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
 | 
			
		||||
                        // The push subscription details needed by the application
 | 
			
		||||
                        // server are now available, and can be sent to it using,
 | 
			
		||||
                        // for example, the fetch() API.
 | 
			
		||||
                    },
 | 
			
		||||
                    (error) => {
 | 
			
		||||
                        console.error(error);
 | 
			
		||||
                    },
 | 
			
		||||
                );
 | 
			
		||||
            });
 | 
			
		||||
navigator.serviceWorker
 | 
			
		||||
  .register("/service-worker.js")
 | 
			
		||||
  .then((serviceWorkerRegistration) => {
 | 
			
		||||
    serviceWorkerRegistration.pushManager.subscribe().then(
 | 
			
		||||
      (pushSubscription) => {
 | 
			
		||||
        const subscriptionObject = {
 | 
			
		||||
          endpoint: pushSubscription.endpoint,
 | 
			
		||||
          keys: {
 | 
			
		||||
            p256dh: pushSubscription.getKey("p256dh"),
 | 
			
		||||
            auth: pushSubscription.getKey("auth"),
 | 
			
		||||
          },
 | 
			
		||||
          encoding: PushManager.supportedContentEncodings,
 | 
			
		||||
          /* other app-specific data, such as user identity */
 | 
			
		||||
        };
 | 
			
		||||
        console.log(
 | 
			
		||||
          pushSubscription.endpoint,
 | 
			
		||||
          pushSubscription,
 | 
			
		||||
          subscriptionObject,
 | 
			
		||||
        );
 | 
			
		||||
        // The push subscription details needed by the application
 | 
			
		||||
        // server are now available, and can be sent to it using,
 | 
			
		||||
        // for example, the fetch() API.
 | 
			
		||||
      },
 | 
			
		||||
      (error) => {
 | 
			
		||||
        console.error(error);
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
@ -1,42 +1,178 @@
 | 
			
		||||
    :root {
 | 
			
		||||
      --star-color: white;
 | 
			
		||||
      --background-color: black;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
.star {
 | 
			
		||||
    body.day {
 | 
			
		||||
      --star-color: #444;
 | 
			
		||||
      --background-color: #e6f0ff;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    body.night {
 | 
			
		||||
      --star-color: white;
 | 
			
		||||
      --background-color: black;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    body {
 | 
			
		||||
      margin: 0;
 | 
			
		||||
      overflow: hidden;
 | 
			
		||||
      background-color: var(--background-color);
 | 
			
		||||
      transition: background-color 0.5s;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .star {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      border-radius: 50%;
 | 
			
		||||
      background-color: var(--star-color);
 | 
			
		||||
      animation: twinkle 2s infinite ease-in-out;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    @keyframes twinkle {
 | 
			
		||||
      0%, 100% { opacity: 0.8; transform: scale(1); }
 | 
			
		||||
      50% { opacity: 1; transform: scale(1.2); }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    #themeToggle {
 | 
			
		||||
      position: absolute;
 | 
			
		||||
      top: 10px;
 | 
			
		||||
      left: 10px;
 | 
			
		||||
      padding: 8px 12px;
 | 
			
		||||
      font-size: 14px;
 | 
			
		||||
      z-index: 1000;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
.star.special {
 | 
			
		||||
  box-shadow: 0 0 10px 3px gold;
 | 
			
		||||
  transform: scale(1.4);
 | 
			
		||||
  z-index: 10;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-tooltip {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  width: 2px;
 | 
			
		||||
  height: 2px;
 | 
			
		||||
  background: var(--star-color, #fff);
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: background 0.5s ease;
 | 
			
		||||
  animation: twinkle ease-in-out infinite;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes twinkle {
 | 
			
		||||
  0%, 100% { opacity: 0; }
 | 
			
		||||
  50%      { opacity: 1; }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes star-glow-frames {
 | 
			
		||||
    0% {
 | 
			
		||||
	box-shadow: 0 0 5px --star-color;
 | 
			
		||||
    }
 | 
			
		||||
    50% {
 | 
			
		||||
	box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
 | 
			
		||||
    }
 | 
			
		||||
    100% {
 | 
			
		||||
	box-shadow: 0 0 5px --star-color;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-glow {
 | 
			
		||||
    animation: star-glow-frames 1s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.content {
 | 
			
		||||
  position: relative;
 | 
			
		||||
  z-index: 1;
 | 
			
		||||
  color: var(--star-content-color, #eee);
 | 
			
		||||
  font-size: 12px;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  top: 40%;
 | 
			
		||||
  transform: translateY(-40%);
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
  text-shadow: 1px 1px 2px black;
 | 
			
		||||
  display: none;
 | 
			
		||||
  padding: 2px 6px;
 | 
			
		||||
}
 | 
			
		||||
.star-popup {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  z-index: 10000;
 | 
			
		||||
  text-shadow: 1px 1px 3px black;
 | 
			
		||||
  display: none;
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-radius: 12px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.star:hover {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  max-width: 300px;
 | 
			
		||||
  background: white;
 | 
			
		||||
  color: black;
 | 
			
		||||
  padding: 15px;
 | 
			
		||||
  border-radius: 12px;
 | 
			
		||||
  box-shadow: 0 8px 20px rgba(0,0,0,0.3);
 | 
			
		||||
  z-index: 10000;
 | 
			
		||||
  font-family: sans-serif;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup h3 {
 | 
			
		||||
  margin: 0 0 5px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-popup button {
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.demo-overlay {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50%;
 | 
			
		||||
  left: 50%;
 | 
			
		||||
  transform: translate(-50%, -50%);
 | 
			
		||||
  font-size: 3em;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-family: 'Segoe UI', sans-serif;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  text-shadow: 0 0 20px rgba(0,0,0,0.8);
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition: opacity 0.6s ease;
 | 
			
		||||
  max-width: 80vw;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes demoFadeIn {
 | 
			
		||||
  from {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transform: translate(-50%, -60%) scale(0.95);
 | 
			
		||||
  }
 | 
			
		||||
  to {
 | 
			
		||||
    opacity: 1;
 | 
			
		||||
    transform: translate(-50%, -50%) scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@keyframes demoPulse {
 | 
			
		||||
  0% {
 | 
			
		||||
    box-shadow: 0 0 0 rgba(255, 255, 150, 0);
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
  30% {
 | 
			
		||||
    box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
 | 
			
		||||
    transform: scale(1.05);
 | 
			
		||||
  }
 | 
			
		||||
  100% {
 | 
			
		||||
    box-shadow: 0 0 0 rgba(255, 255, 150, 0);
 | 
			
		||||
    transform: scale(1);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.demo-highlight {
 | 
			
		||||
  animation: demoPulse 1.5s ease-out;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
 | 
			
		||||
  position: relative;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-notify-container {
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 50px;
 | 
			
		||||
  right: 20px;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  align-items: flex-end;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  z-index: 9999;
 | 
			
		||||
  pointer-events: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.star-notify {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  padding: 5px 10px;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-weight: 300;
 | 
			
		||||
  text-shadow: 0 0 10px rgba(0,0,0,0.7);
 | 
			
		||||
  transition: opacity 0.5s ease, transform 0.5s ease;
 | 
			
		||||
  transform: translateY(-10px);
 | 
			
		||||
  font-family: 'Segoe UI', sans-serif;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -51,4 +51,4 @@ export class Schedule {
 | 
			
		||||
      me.timeOutCount = 0;
 | 
			
		||||
    }, this.msDelay);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,33 +1,34 @@
 | 
			
		||||
async function requestNotificationPermission() {
 | 
			
		||||
  const permission = await Notification.requestPermission();
 | 
			
		||||
  return permission === 'granted';
 | 
			
		||||
  return permission === "granted";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Subscribe to Push Notifications
 | 
			
		||||
async function subscribeUser() {
 | 
			
		||||
  const registration = await navigator.serviceWorker.register('/service-worker.js');
 | 
			
		||||
  
 | 
			
		||||
  const registration =
 | 
			
		||||
    await navigator.serviceWorker.register("/service-worker.js");
 | 
			
		||||
 | 
			
		||||
  const subscription = await registration.pushManager.subscribe({
 | 
			
		||||
    userVisibleOnly: true,
 | 
			
		||||
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
 | 
			
		||||
    applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // Send subscription to your backend
 | 
			
		||||
  await fetch('/subscribe', {
 | 
			
		||||
    method: 'POST',
 | 
			
		||||
  await fetch("/subscribe", {
 | 
			
		||||
    method: "POST",
 | 
			
		||||
    body: JSON.stringify(subscription),
 | 
			
		||||
    headers: {
 | 
			
		||||
      'Content-Type': 'application/json'
 | 
			
		||||
    }
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Service Worker (service-worker.js)
 | 
			
		||||
self.addEventListener('push', event => {
 | 
			
		||||
self.addEventListener("push", (event) => {
 | 
			
		||||
  const data = event.data.json();
 | 
			
		||||
  self.registration.showNotification(data.title, {
 | 
			
		||||
    body: data.message,
 | 
			
		||||
    icon: data.icon
 | 
			
		||||
    icon: data.icon,
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,142 +1,144 @@
 | 
			
		||||
import {EventHandler} from "./event-handler.js";
 | 
			
		||||
import { EventHandler } from "./event-handler.js";
 | 
			
		||||
 | 
			
		||||
export class Socket extends EventHandler {
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {URL}
 | 
			
		||||
     */
 | 
			
		||||
    url
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {WebSocket|null}
 | 
			
		||||
     */
 | 
			
		||||
    ws = null
 | 
			
		||||
  /**
 | 
			
		||||
   * @type {URL}
 | 
			
		||||
   */
 | 
			
		||||
  url;
 | 
			
		||||
  /**
 | 
			
		||||
   * @type {WebSocket|null}
 | 
			
		||||
   */
 | 
			
		||||
  ws = null;
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
 | 
			
		||||
     */
 | 
			
		||||
    connection = null
 | 
			
		||||
  /**
 | 
			
		||||
   * @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
 | 
			
		||||
   */
 | 
			
		||||
  connection = null;
 | 
			
		||||
 | 
			
		||||
    shouldReconnect = true;
 | 
			
		||||
  shouldReconnect = true;
 | 
			
		||||
 | 
			
		||||
    get isConnected() {
 | 
			
		||||
        return this.ws && this.ws.readyState === WebSocket.OPEN;
 | 
			
		||||
  get isConnected() {
 | 
			
		||||
    return this.ws && this.ws.readyState === WebSocket.OPEN;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get isConnecting() {
 | 
			
		||||
    return this.ws && this.ws.readyState === WebSocket.CONNECTING;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
 | 
			
		||||
    this.url = new URL("/rpc.ws", window.location.origin);
 | 
			
		||||
    this.url.protocol = this.url.protocol.replace("http", "ws");
 | 
			
		||||
 | 
			
		||||
    this.connect();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  connect() {
 | 
			
		||||
    if (this.ws) {
 | 
			
		||||
      return this.connection.promise;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    get isConnecting() {
 | 
			
		||||
        return this.ws && this.ws.readyState === WebSocket.CONNECTING;
 | 
			
		||||
    if (!this.connection || this.connection.resolved) {
 | 
			
		||||
      this.connection = Promise.withResolvers();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
    this.ws = new WebSocket(this.url);
 | 
			
		||||
    this.ws.addEventListener("open", () => {
 | 
			
		||||
      this.connection.resolved = true;
 | 
			
		||||
      this.connection.resolve(this);
 | 
			
		||||
      this.emit("connected");
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
        this.url = new URL('/rpc.ws', window.location.origin);
 | 
			
		||||
        this.url.protocol = this.url.protocol.replace('http', 'ws');
 | 
			
		||||
 | 
			
		||||
        this.connect()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connect() {
 | 
			
		||||
        if (this.ws) {
 | 
			
		||||
            return this.connection.promise;
 | 
			
		||||
    this.ws.addEventListener("close", () => {
 | 
			
		||||
      console.log("Connection closed");
 | 
			
		||||
      this.disconnect();
 | 
			
		||||
    });
 | 
			
		||||
    this.ws.addEventListener("error", (e) => {
 | 
			
		||||
      console.error("Connection error", e);
 | 
			
		||||
      this.disconnect();
 | 
			
		||||
    });
 | 
			
		||||
    this.ws.addEventListener("message", (e) => {
 | 
			
		||||
      if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
 | 
			
		||||
        console.error("Binary data not supported");
 | 
			
		||||
      } else {
 | 
			
		||||
        try {
 | 
			
		||||
          this.onData(JSON.parse(e.data));
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          console.error("Failed to parse message", e);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
        if (!this.connection || this.connection.resolved) {
 | 
			
		||||
            this.connection = Promise.withResolvers()
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.ws = new WebSocket(this.url);
 | 
			
		||||
        this.ws.addEventListener("open", () => {
 | 
			
		||||
            this.connection.resolved = true;
 | 
			
		||||
            this.connection.resolve(this);
 | 
			
		||||
            this.emit("connected");
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.ws.addEventListener("close", () => {
 | 
			
		||||
            console.log("Connection closed");
 | 
			
		||||
            this.disconnect()
 | 
			
		||||
        })
 | 
			
		||||
        this.ws.addEventListener("error", (e) => {
 | 
			
		||||
            console.error("Connection error", e);
 | 
			
		||||
            this.disconnect()
 | 
			
		||||
        })
 | 
			
		||||
        this.ws.addEventListener("message", (e) => {
 | 
			
		||||
            if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
 | 
			
		||||
                console.error("Binary data not supported");
 | 
			
		||||
            } else {
 | 
			
		||||
                try {
 | 
			
		||||
                    this.onData(JSON.parse(e.data));
 | 
			
		||||
                } catch (e) {
 | 
			
		||||
                    console.error("Failed to parse message", e);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        })
 | 
			
		||||
  onData(data) {
 | 
			
		||||
    if (data.success !== undefined && !data.success) {
 | 
			
		||||
      console.error(data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    onData(data) {
 | 
			
		||||
        if (data.success !== undefined && !data.success) {
 | 
			
		||||
            console.error(data);
 | 
			
		||||
        }
 | 
			
		||||
        if (data.callId) {
 | 
			
		||||
            this.emit(data.callId, data.data);
 | 
			
		||||
        }
 | 
			
		||||
        if (data.channel_uid) {
 | 
			
		||||
            this.emit(data.channel_uid, data.data);
 | 
			
		||||
	    if(!data['event'])
 | 
			
		||||
            this.emit("channel-message", data);
 | 
			
		||||
        }
 | 
			
		||||
	this.emit("data", data.data)
 | 
			
		||||
	if(data['event']){
 | 
			
		||||
	    this.emit(data.event, data)
 | 
			
		||||
	}
 | 
			
		||||
    if (data.callId) {
 | 
			
		||||
      this.emit(data.callId, data.data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnect() {
 | 
			
		||||
        this.ws?.close();
 | 
			
		||||
        this.ws = null;
 | 
			
		||||
 | 
			
		||||
        if (this.shouldReconnect) setTimeout(() => {
 | 
			
		||||
            console.log("Reconnecting");
 | 
			
		||||
            return this.connect();
 | 
			
		||||
        }, 0);
 | 
			
		||||
    if (data.channel_uid) {
 | 
			
		||||
      this.emit(data.channel_uid, data.data);
 | 
			
		||||
      if (!data["event"]) this.emit("channel-message", data);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    _camelToSnake(str) {
 | 
			
		||||
        return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
 | 
			
		||||
    this.emit("data", data.data);
 | 
			
		||||
    if (data["event"]) {
 | 
			
		||||
      this.emit(data.event, data.data);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    get client() {
 | 
			
		||||
        const me = this;
 | 
			
		||||
        return new Proxy({}, {
 | 
			
		||||
            get(_, prop) {
 | 
			
		||||
                return (...args) => {
 | 
			
		||||
                    const functionName = me._camelToSnake(prop);
 | 
			
		||||
                    return me.call(functionName, ...args);
 | 
			
		||||
                };
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  disconnect() {
 | 
			
		||||
    this.ws?.close();
 | 
			
		||||
    this.ws = null;
 | 
			
		||||
 | 
			
		||||
    generateCallId() {
 | 
			
		||||
        return self.crypto.randomUUID();
 | 
			
		||||
    }
 | 
			
		||||
    if (this.shouldReconnect)
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        console.log("Reconnecting");
 | 
			
		||||
        this.emit("reconnecting");
 | 
			
		||||
          return this.connect();
 | 
			
		||||
      }, 0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async sendJson(data) {
 | 
			
		||||
        await this.connect().then(api => {
 | 
			
		||||
            api.ws.send(JSON.stringify(data));
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  _camelToSnake(str) {
 | 
			
		||||
    return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    async call(method, ...args) {
 | 
			
		||||
        const call = {
 | 
			
		||||
            callId: this.generateCallId(),
 | 
			
		||||
            method,
 | 
			
		||||
            args,
 | 
			
		||||
        };
 | 
			
		||||
        const me = this
 | 
			
		||||
        return new Promise((resolve) => {
 | 
			
		||||
            me.addEventListener(call.callId, data => resolve(data));
 | 
			
		||||
            me.sendJson(call);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  get client() {
 | 
			
		||||
    const me = this;
 | 
			
		||||
    return new Proxy(
 | 
			
		||||
      {},
 | 
			
		||||
      {
 | 
			
		||||
        get(_, prop) {
 | 
			
		||||
          return (...args) => {
 | 
			
		||||
            const functionName = me._camelToSnake(prop);
 | 
			
		||||
            return me.call(functionName, ...args);
 | 
			
		||||
          };
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generateCallId() {
 | 
			
		||||
    return self.crypto.randomUUID();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async sendJson(data) {
 | 
			
		||||
    await this.connect().then((api) => {
 | 
			
		||||
      api.ws.send(JSON.stringify(data));
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async call(method, ...args) {
 | 
			
		||||
    const call = {
 | 
			
		||||
      callId: this.generateCallId(),
 | 
			
		||||
      method,
 | 
			
		||||
      args,
 | 
			
		||||
    };
 | 
			
		||||
    const me = this;
 | 
			
		||||
    return new Promise((resolve) => {
 | 
			
		||||
      me.addEventListener(call.callId, (data) => resolve(data));
 | 
			
		||||
      me.sendJson(call);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -2,61 +2,62 @@
 | 
			
		||||
 | 
			
		||||
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// MIT License: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
 | 
			
		||||
 | 
			
		||||
class UploadButtonElement extends HTMLElement {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.attachShadow({ mode: 'open' });
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.attachShadow({ mode: "open" });
 | 
			
		||||
  }
 | 
			
		||||
  chatInput = null;
 | 
			
		||||
  async uploadFiles() {
 | 
			
		||||
    const fileInput = this.container.querySelector(".file-input");
 | 
			
		||||
    const uploadButton = this.container.querySelector(".upload-button");
 | 
			
		||||
 | 
			
		||||
    if (!fileInput.files.length) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    chatInput = null 
 | 
			
		||||
    async uploadFiles() {
 | 
			
		||||
        const fileInput = this.container.querySelector('.file-input');
 | 
			
		||||
        const uploadButton = this.container.querySelector('.upload-button');
 | 
			
		||||
 | 
			
		||||
        if (!fileInput.files.length) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const files = fileInput.files;
 | 
			
		||||
        const formData = new FormData();
 | 
			
		||||
        for (let i = 0; i < files.length; i++) {
 | 
			
		||||
            formData.append('files[]', files[i]);
 | 
			
		||||
        }
 | 
			
		||||
        const request = new XMLHttpRequest();
 | 
			
		||||
 | 
			
		||||
        request.responseType = 'json';
 | 
			
		||||
        request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true);
 | 
			
		||||
 | 
			
		||||
        request.upload.onprogress = function (event) {
 | 
			
		||||
            if (event.lengthComputable) {
 | 
			
		||||
                const percentComplete = (event.loaded / event.total) * 100;
 | 
			
		||||
                uploadButton.innerText = `${Math.round(percentComplete)}%`;
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        const me = this
 | 
			
		||||
        request.onload = function () {
 | 
			
		||||
            if (request.status === 200) {
 | 
			
		||||
                me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response }));
 | 
			
		||||
                uploadButton.innerHTML = '📤';
 | 
			
		||||
            } else {
 | 
			
		||||
                alert('Upload failed');
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        request.onerror = function () {
 | 
			
		||||
            alert('Error while uploading.');
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        request.send(formData);
 | 
			
		||||
        const uploadEvent = new Event('upload',{});
 | 
			
		||||
        this.dispatchEvent(uploadEvent);
 | 
			
		||||
    const files = fileInput.files;
 | 
			
		||||
    const formData = new FormData();
 | 
			
		||||
    for (let i = 0; i < files.length; i++) {
 | 
			
		||||
      formData.append("files[]", files[i]);
 | 
			
		||||
    }
 | 
			
		||||
    channelUid = null
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        this.styleElement = document.createElement('style');
 | 
			
		||||
        this.styleElement.innerHTML = `
 | 
			
		||||
    const request = new XMLHttpRequest();
 | 
			
		||||
 | 
			
		||||
    request.responseType = "json";
 | 
			
		||||
    request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
 | 
			
		||||
 | 
			
		||||
    request.upload.onprogress = function (event) {
 | 
			
		||||
      if (event.lengthComputable) {
 | 
			
		||||
        const percentComplete = (event.loaded / event.total) * 100;
 | 
			
		||||
        uploadButton.innerText = `${Math.round(percentComplete)}%`;
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    const me = this;
 | 
			
		||||
    request.onload = function () {
 | 
			
		||||
      if (request.status === 200) {
 | 
			
		||||
        me.dispatchEvent(
 | 
			
		||||
          new CustomEvent("uploaded", { detail: request.response }),
 | 
			
		||||
        );
 | 
			
		||||
        uploadButton.innerHTML = "📤";
 | 
			
		||||
      } else {
 | 
			
		||||
        alert("Upload failed");
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    request.onerror = function () {
 | 
			
		||||
      alert("Error while uploading.");
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    request.send(formData);
 | 
			
		||||
    const uploadEvent = new Event("upload", {});
 | 
			
		||||
    this.dispatchEvent(uploadEvent);
 | 
			
		||||
  }
 | 
			
		||||
  channelUid = null;
 | 
			
		||||
  connectedCallback() {
 | 
			
		||||
    this.styleElement = document.createElement("style");
 | 
			
		||||
    this.styleElement.innerHTML = `
 | 
			
		||||
            body {
 | 
			
		||||
                font-family: Arial, sans-serif;
 | 
			
		||||
                display: flex;
 | 
			
		||||
@ -97,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
 | 
			
		||||
                display: none;
 | 
			
		||||
            }
 | 
			
		||||
        `;
 | 
			
		||||
        this.shadowRoot.appendChild(this.styleElement);
 | 
			
		||||
        this.container = document.createElement('div');
 | 
			
		||||
        this.container.innerHTML = `
 | 
			
		||||
    this.shadowRoot.appendChild(this.styleElement);
 | 
			
		||||
    this.container = document.createElement("div");
 | 
			
		||||
    this.container.innerHTML = `
 | 
			
		||||
            <div class="upload-container">
 | 
			
		||||
                <button class="upload-button">
 | 
			
		||||
                   📤
 | 
			
		||||
@ -107,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
 | 
			
		||||
                <input class="hidden-input file-input" type="file" multiple />
 | 
			
		||||
            </div>
 | 
			
		||||
        `;
 | 
			
		||||
        this.shadowRoot.appendChild(this.container);
 | 
			
		||||
        this.channelUid = this.getAttribute('channel');
 | 
			
		||||
        this.uploadButton = this.container.querySelector('.upload-button');
 | 
			
		||||
        this.fileInput = this.container.querySelector('.hidden-input');
 | 
			
		||||
        this.uploadButton.addEventListener('click', () => {
 | 
			
		||||
            this.fileInput.click();
 | 
			
		||||
        });
 | 
			
		||||
        this.fileInput.addEventListener('change', () => {
 | 
			
		||||
            this.uploadFiles();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    this.shadowRoot.appendChild(this.container);
 | 
			
		||||
    this.channelUid = this.getAttribute("channel");
 | 
			
		||||
    this.uploadButton = this.container.querySelector(".upload-button");
 | 
			
		||||
    this.fileInput = this.container.querySelector(".hidden-input");
 | 
			
		||||
    this.uploadButton.addEventListener("click", () => {
 | 
			
		||||
      this.fileInput.click();
 | 
			
		||||
    });
 | 
			
		||||
    this.fileInput.addEventListener("change", () => {
 | 
			
		||||
      this.uploadFiles();
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('upload-button', UploadButtonElement);
 | 
			
		||||
customElements.define("upload-button", UploadButtonElement);
 | 
			
		||||
 | 
			
		||||
@ -1,36 +1,36 @@
 | 
			
		||||
 class UserList extends HTMLElement {
 | 
			
		||||
      constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.users = [];
 | 
			
		||||
      }
 | 
			
		||||
class UserList extends HTMLElement {
 | 
			
		||||
  constructor() {
 | 
			
		||||
    super();
 | 
			
		||||
    this.users = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
      set data(userArray) {
 | 
			
		||||
        this.users = userArray;
 | 
			
		||||
          this.render();
 | 
			
		||||
      }
 | 
			
		||||
  set data(userArray) {
 | 
			
		||||
    this.users = userArray;
 | 
			
		||||
    this.render();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
      formatRelativeTime(timestamp) {
 | 
			
		||||
        const now = new Date();
 | 
			
		||||
        const msgTime = new Date(timestamp);
 | 
			
		||||
        const diffMs = now - msgTime;
 | 
			
		||||
        const minutes = Math.floor(diffMs / 60000);
 | 
			
		||||
        const hours = Math.floor(minutes / 60);
 | 
			
		||||
        const days = Math.floor(hours / 24);
 | 
			
		||||
  formatRelativeTime(timestamp) {
 | 
			
		||||
    const now = new Date();
 | 
			
		||||
    const msgTime = new Date(timestamp);
 | 
			
		||||
    const diffMs = now - msgTime;
 | 
			
		||||
    const minutes = Math.floor(diffMs / 60000);
 | 
			
		||||
    const hours = Math.floor(minutes / 60);
 | 
			
		||||
    const days = Math.floor(hours / 24);
 | 
			
		||||
 | 
			
		||||
        if (days > 0) {
 | 
			
		||||
          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;
 | 
			
		||||
        } else if (hours > 0) {
 | 
			
		||||
          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;
 | 
			
		||||
        } else {
 | 
			
		||||
          return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    if (days > 0) {
 | 
			
		||||
      return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
 | 
			
		||||
    } else if (hours > 0) {
 | 
			
		||||
      return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
 | 
			
		||||
    } else {
 | 
			
		||||
      return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
      render() {
 | 
			
		||||
        this.innerHTML = '';
 | 
			
		||||
  render() {
 | 
			
		||||
    this.innerHTML = "";
 | 
			
		||||
 | 
			
		||||
        this.users.forEach(user => {
 | 
			
		||||
          const html = `
 | 
			
		||||
    this.users.forEach((user) => {
 | 
			
		||||
      const html = `
 | 
			
		||||
            <div class="user-list__item"
 | 
			
		||||
              data-uid="${user.uid}"
 | 
			
		||||
              data-color="${user.color}"
 | 
			
		||||
@ -51,9 +51,9 @@
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          `;
 | 
			
		||||
          this.insertAdjacentHTML("beforeend", html);
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
      this.insertAdjacentHTML("beforeend", html);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
    customElements.define('user-list', UserList);
 | 
			
		||||
customElements.define("user-list", UserList);
 | 
			
		||||
 | 
			
		||||
@ -1,504 +1,392 @@
 | 
			
		||||
 | 
			
		||||
<div id="star-tooltip" class="star-tooltip"></div>
 | 
			
		||||
<div id="star-popup" class="star-popup"></div>
 | 
			
		||||
<script type="module">
 | 
			
		||||
import { app } from "/app.js";
 | 
			
		||||
 | 
			
		||||
const STAR_COUNT = 200;
 | 
			
		||||
const body = document.body;
 | 
			
		||||
 | 
			
		||||
function getStarPosition(star) {
 | 
			
		||||
    const leftPercent = parseFloat(star.style.left);
 | 
			
		||||
    const topPercent = parseFloat(star.style.top);
 | 
			
		||||
 | 
			
		||||
    let position;
 | 
			
		||||
 | 
			
		||||
    if (topPercent < 40 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'North';
 | 
			
		||||
    } else if (topPercent > 60 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'South';
 | 
			
		||||
    } else if (leftPercent < 40 && topPercent >= 40 && topPercent <= 60) {
 | 
			
		||||
      position = 'West';
 | 
			
		||||
    } else if (leftPercent > 60 && topPercent >= 40 && topPercent <= 60) {
 | 
			
		||||
      position = 'East';
 | 
			
		||||
    } else if (topPercent >= 40 && topPercent <= 60 && leftPercent >= 40 && leftPercent <= 60) {
 | 
			
		||||
      position = 'Center';
 | 
			
		||||
    } else {
 | 
			
		||||
      position = 'Corner or Edge';
 | 
			
		||||
    }
 | 
			
		||||
    return position 
 | 
			
		||||
}
 | 
			
		||||
let stars = {}
 | 
			
		||||
window.stars = stars
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function createStar() {
 | 
			
		||||
  const star = document.createElement('div');
 | 
			
		||||
  star.classList.add('star');
 | 
			
		||||
  star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
  star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
  star.shuffle = () => {
 | 
			
		||||
    star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
 
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
 }
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
 | 
			
		||||
function moveStarToPosition(star, position) {
 | 
			
		||||
  let top, left;
 | 
			
		||||
 | 
			
		||||
  switch (position) {
 | 
			
		||||
    case 'North':
 | 
			
		||||
      top = `${Math.random() * 20}%`;
 | 
			
		||||
      left = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'South':
 | 
			
		||||
      top = `${80 + Math.random() * 10}%`;
 | 
			
		||||
      left = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'West':
 | 
			
		||||
      top = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      left = `${Math.random() * 20}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'East':
 | 
			
		||||
      top = `${40 + Math.random() * 20}%`;
 | 
			
		||||
      left = `${80 + Math.random() * 10}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    case 'Center':
 | 
			
		||||
      top = `${45 + Math.random() * 10}%`;
 | 
			
		||||
      left = `${45 + Math.random() * 10}%`;
 | 
			
		||||
      break;
 | 
			
		||||
    default: // 'Corner or Edge' fallback
 | 
			
		||||
      top = `${Math.random() * 100}%`;
 | 
			
		||||
      left = `${Math.random() * 100}%`;
 | 
			
		||||
      break;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  star.style.top = top;
 | 
			
		||||
  star.style.left = left;
 | 
			
		||||
 | 
			
		||||
  star.position = getStarPosition(star)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  if(!stars[star.position])
 | 
			
		||||
      stars[star.position] = []
 | 
			
		||||
  stars[star.position].push(star)
 | 
			
		||||
  const size = Math.random() * 2 + 1;
 | 
			
		||||
  star.style.width = `${size}px`;
 | 
			
		||||
  star.style.height = `${size}px`;
 | 
			
		||||
  const duration = Math.random() * 3 + 2;
 | 
			
		||||
  const delay = Math.random() * 5;
 | 
			
		||||
  star.style.animationDuration = `${duration}s`;
 | 
			
		||||
  star.style.animationDelay = `${delay}s`;
 | 
			
		||||
  body.appendChild(star);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Array.from({ length: STAR_COUNT }, createStar);
 | 
			
		||||
 | 
			
		||||
function lightenColor(hex, percent) {
 | 
			
		||||
  const num = parseInt(hex.replace("#", ""), 16);
 | 
			
		||||
  let r = (num >> 16) + Math.round(255 * percent / 100);
 | 
			
		||||
  let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100);
 | 
			
		||||
  let b = (num & 0x0000FF) + Math.round(255 * percent / 100);
 | 
			
		||||
  r = Math.min(255, r);
 | 
			
		||||
  g = Math.min(255, g);
 | 
			
		||||
  b = Math.min(255, b);
 | 
			
		||||
  return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
 | 
			
		||||
 | 
			
		||||
function glowCSSVariable(varName, glowColor, duration = 500) {
 | 
			
		||||
  const root = document.documentElement;
 | 
			
		||||
 | 
			
		||||
  //igetComputedStyle(root).getPropertyValue(varName).trim();
 | 
			
		||||
  glowColor = lightenColor(glowColor, 10);
 | 
			
		||||
  root.style.setProperty(varName, glowColor);
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    root.style.setProperty(varName, originalColor);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateStarColorDelayed(color) {
 | 
			
		||||
  glowCSSVariable('--star-color', color, 2500);
 | 
			
		||||
}
 | 
			
		||||
app.updateStarColor = updateStarColorDelayed;
 | 
			
		||||
app.ws.addEventListener("set_typing", (data) => {
 | 
			
		||||
  updateStarColorDelayed(data.data.color);
 | 
			
		||||
});
 | 
			
		||||
window.createAvatar = () => {
 | 
			
		||||
    let avatar = document.createElement("avatar-face")
 | 
			
		||||
    document.querySelector("main").appendChild(avatar)
 | 
			
		||||
    return avatar
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  
 | 
			
		||||
    class AvatarFace extends HTMLElement {
 | 
			
		||||
      static get observedAttributes(){
 | 
			
		||||
        return ['emotion','face-color','eye-color','text','balloon-color','text-color'];
 | 
			
		||||
      }
 | 
			
		||||
      constructor(){
 | 
			
		||||
        super();
 | 
			
		||||
        this._shadow = this.attachShadow({mode:'open'});
 | 
			
		||||
        this._shadow.innerHTML = `
 | 
			
		||||
          <style>
 | 
			
		||||
            :host { display:block; position:relative; }
 | 
			
		||||
            canvas { width:100%; height:100%; display:block; }
 | 
			
		||||
          </style>
 | 
			
		||||
          <canvas></canvas>
 | 
			
		||||
        `;
 | 
			
		||||
        this._c   = this._shadow.querySelector('canvas');
 | 
			
		||||
        this._ctx = this._c.getContext('2d');
 | 
			
		||||
 | 
			
		||||
        // state
 | 
			
		||||
        this._mouse      = {x:0,y:0};
 | 
			
		||||
        this._blinkTimer = 0;
 | 
			
		||||
        this._blinking   = false;
 | 
			
		||||
        this._lastTime   = 0;
 | 
			
		||||
 | 
			
		||||
        // defaults
 | 
			
		||||
        this._emotion      = 'neutral';
 | 
			
		||||
        this._faceColor    = '#ffdfba';
 | 
			
		||||
        this._eyeColor     = '#000';
 | 
			
		||||
        this._text         = '';
 | 
			
		||||
        this._balloonColor = '#fff';
 | 
			
		||||
        this._textColor    = '#000';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      attributeChangedCallback(name,_old,newV){
 | 
			
		||||
        if (name==='emotion')       this._emotion      = newV||'neutral';
 | 
			
		||||
        else if (name==='face-color')   this._faceColor    = newV||'#ffdfba';
 | 
			
		||||
        else if (name==='eye-color')    this._eyeColor     = newV||'#000';
 | 
			
		||||
        else if (name==='text')         this._text         = newV||'';
 | 
			
		||||
        else if (name==='balloon-color')this._balloonColor = newV||'#fff';
 | 
			
		||||
        else if (name==='text-color')   this._textColor    = newV||'#000';
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      connectedCallback(){
 | 
			
		||||
        // watch size so canvas buffer matches display
 | 
			
		||||
        this._ro = new ResizeObserver(entries=>{
 | 
			
		||||
          for(const ent of entries){
 | 
			
		||||
            const w = ent.contentRect.width;
 | 
			
		||||
            const h = ent.contentRect.height;
 | 
			
		||||
            const dpr = devicePixelRatio||1;
 | 
			
		||||
            this._c.width  = w*dpr;
 | 
			
		||||
            this._c.height = h*dpr;
 | 
			
		||||
            this._ctx.scale(dpr,dpr);
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
        this._ro.observe(this);
 | 
			
		||||
 | 
			
		||||
        // track mouse so eyes follow
 | 
			
		||||
        this._shadow.addEventListener('mousemove', e=>{
 | 
			
		||||
          const r = this._c.getBoundingClientRect();
 | 
			
		||||
          this._mouse.x = e.clientX - r.left;
 | 
			
		||||
          this._mouse.y = e.clientY - r.top;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this._lastTime = performance.now();
 | 
			
		||||
        this._raf      = requestAnimationFrame(t=>this._loop(t));
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      disconnectedCallback(){
 | 
			
		||||
        cancelAnimationFrame(this._raf);
 | 
			
		||||
        this._ro.disconnect();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _updateBlink(dt){
 | 
			
		||||
        this._blinkTimer -= dt;
 | 
			
		||||
        if (this._blinkTimer<=0){
 | 
			
		||||
          this._blinking = !this._blinking;
 | 
			
		||||
          this._blinkTimer = this._blinking
 | 
			
		||||
            ? 0.1
 | 
			
		||||
            : 2 + Math.random()*3;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _roundRect(x,y,w,h,r){
 | 
			
		||||
        const ctx = this._ctx;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.moveTo(x+r,y);
 | 
			
		||||
        ctx.lineTo(x+w-r,y);
 | 
			
		||||
        ctx.quadraticCurveTo(x+w,y, x+w,y+r);
 | 
			
		||||
        ctx.lineTo(x+w,y+h-r);
 | 
			
		||||
        ctx.quadraticCurveTo(x+w,y+h, x+w-r,y+h);
 | 
			
		||||
        ctx.lineTo(x+r,y+h);
 | 
			
		||||
        ctx.quadraticCurveTo(x,y+h, x,y+h-r);
 | 
			
		||||
        ctx.lineTo(x,y+r);
 | 
			
		||||
        ctx.quadraticCurveTo(x,y, x+r,y);
 | 
			
		||||
        ctx.closePath();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _draw(ts){
 | 
			
		||||
        const ctx = this._ctx;
 | 
			
		||||
        const W = this._c.clientWidth;
 | 
			
		||||
        const H = this._c.clientHeight;
 | 
			
		||||
        ctx.clearRect(0,0,W,H);
 | 
			
		||||
 | 
			
		||||
        // HEAD + BOB
 | 
			
		||||
        const cx = W/2;
 | 
			
		||||
        const cy = H/2 + Math.sin(ts*0.002)*8;
 | 
			
		||||
        const R  = Math.min(W,H)*0.25;
 | 
			
		||||
 | 
			
		||||
        // SPEECH BALLOON
 | 
			
		||||
        if (this._text){
 | 
			
		||||
          const pad = 6;
 | 
			
		||||
          ctx.font = `${R*0.15}px sans-serif`;
 | 
			
		||||
          const m = ctx.measureText(this._text);
 | 
			
		||||
          const tw = m.width, th = R*0.18;
 | 
			
		||||
          const bw = tw + pad*2, bh = th + pad*2;
 | 
			
		||||
          const bx = cx - bw/2, by = cy - R - bh - 10;
 | 
			
		||||
          // bubble
 | 
			
		||||
          ctx.fillStyle = this._balloonColor;
 | 
			
		||||
          this._roundRect(bx,by,bw,bh,6);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          ctx.strokeStyle = '#888';
 | 
			
		||||
          ctx.lineWidth = 1.2;
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
          // tail
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.moveTo(cx-6, by+bh);
 | 
			
		||||
          ctx.lineTo(cx+6, by+bh);
 | 
			
		||||
          ctx.lineTo(cx, cy-R+4);
 | 
			
		||||
          ctx.closePath();
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
          // text
 | 
			
		||||
          ctx.fillStyle = this._textColor;
 | 
			
		||||
          ctx.textBaseline = 'top';
 | 
			
		||||
          ctx.fillText(this._text, bx+pad, by+pad);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // FACE
 | 
			
		||||
        ctx.fillStyle = this._faceColor;
 | 
			
		||||
        ctx.beginPath();
 | 
			
		||||
        ctx.arc(cx,cy,R,0,2*Math.PI);
 | 
			
		||||
        ctx.fill();
 | 
			
		||||
 | 
			
		||||
        // EYES
 | 
			
		||||
        const eyeY = cy - R*0.2;
 | 
			
		||||
        const eyeX = R*0.4;
 | 
			
		||||
        const eyeR= R*0.12;
 | 
			
		||||
        const pupR= eyeR*0.5;
 | 
			
		||||
 | 
			
		||||
        for(let i=0;i<2;i++){
 | 
			
		||||
          const ex = cx + (i? eyeX:-eyeX);
 | 
			
		||||
          const ey = eyeY;
 | 
			
		||||
          // eyeball
 | 
			
		||||
          ctx.fillStyle = '#fff';
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(ex,ey,eyeR,0,2*Math.PI);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
          // pupil follows
 | 
			
		||||
          let dx = this._mouse.x - ex;
 | 
			
		||||
          let dy = this._mouse.y - ey;
 | 
			
		||||
          const d = Math.hypot(dx,dy);
 | 
			
		||||
          const max = eyeR - pupR - 2;
 | 
			
		||||
          if (d>max){ dx=dx/d*max; dy=dy/d*max; }
 | 
			
		||||
          if (this._blinking){
 | 
			
		||||
            ctx.strokeStyle='#000';
 | 
			
		||||
            ctx.lineWidth=3;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.moveTo(ex-eyeR,ey);
 | 
			
		||||
            ctx.lineTo(ex+eyeR,ey);
 | 
			
		||||
            ctx.stroke();
 | 
			
		||||
          } else {
 | 
			
		||||
            ctx.fillStyle = this._eyeColor;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.arc(ex+dx,ey+dy,pupR,0,2*Math.PI);
 | 
			
		||||
            ctx.fill();
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // ANGRY BROWS
 | 
			
		||||
        if (this._emotion==='angry'){
 | 
			
		||||
          ctx.strokeStyle='#000';
 | 
			
		||||
          ctx.lineWidth=4;
 | 
			
		||||
          [[-eyeX,1],[ eyeX,-1]].forEach(([off,dir])=>{
 | 
			
		||||
            const sx = cx+off - eyeR;
 | 
			
		||||
            const sy = eyeY - eyeR*1.3;
 | 
			
		||||
            const ex = cx+off + eyeR;
 | 
			
		||||
            const ey2= sy + dir*6;
 | 
			
		||||
            ctx.beginPath();
 | 
			
		||||
            ctx.moveTo(sx,sy);
 | 
			
		||||
            ctx.lineTo(ex,ey2);
 | 
			
		||||
            ctx.stroke();
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // MOUTH by emotion
 | 
			
		||||
        const mw = R*0.6;
 | 
			
		||||
        const my = cy + R*0.25;
 | 
			
		||||
        ctx.strokeStyle='#a33';
 | 
			
		||||
        ctx.lineWidth=4;
 | 
			
		||||
 | 
			
		||||
        if (this._emotion==='surprised'){
 | 
			
		||||
          ctx.fillStyle='#a33';
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw*0.3,0,2*Math.PI);
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
        }
 | 
			
		||||
        else if (this._emotion==='sad'){
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw/2,1.15*Math.PI,1.85*Math.PI,true);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
        else if (this._emotion==='angry'){
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.moveTo(cx-mw/2,my+2);
 | 
			
		||||
          ctx.lineTo(cx+mw/2,my-2);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
        else {
 | 
			
		||||
          const s = this._emotion==='happy'? 0.15*Math.PI:0.2*Math.PI;
 | 
			
		||||
          const e = this._emotion==='happy'? 0.85*Math.PI:0.8*Math.PI;
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(cx,my,mw/2,s,e);
 | 
			
		||||
          ctx.stroke();
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      _loop(ts){
 | 
			
		||||
        const dt = (ts - this._lastTime)/1000;
 | 
			
		||||
        this._lastTime = ts;
 | 
			
		||||
        this._updateBlink(dt);
 | 
			
		||||
        this._draw(ts);
 | 
			
		||||
        this._raf = requestAnimationFrame(t=>this._loop(t));
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    customElements.define('avatar-face', AvatarFace);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
  class AvatarReplacer {
 | 
			
		||||
    constructor(target, opts={}){
 | 
			
		||||
      this.target = target;
 | 
			
		||||
      // record original inline styles so we can restore
 | 
			
		||||
      this._oldVis = target.style.visibility || '';
 | 
			
		||||
      this._oldPos = target.style.position || '';
 | 
			
		||||
      // hide the target
 | 
			
		||||
      target.style.visibility = 'hidden';
 | 
			
		||||
      // measure
 | 
			
		||||
      const rect = target.getBoundingClientRect();
 | 
			
		||||
      // create avatar
 | 
			
		||||
      this.avatar = document.createElement('avatar-face');
 | 
			
		||||
      // copy all supported opts into attributes
 | 
			
		||||
      ['emotion','faceColor','eyeColor','text','balloonColor','textColor']
 | 
			
		||||
        .forEach(k => {
 | 
			
		||||
          const attr = k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase());
 | 
			
		||||
          if (opts[k] != null) this.avatar.setAttribute(attr, opts[k]);
 | 
			
		||||
        });
 | 
			
		||||
      // position absolutely
 | 
			
		||||
      const scrollX = window.pageXOffset;
 | 
			
		||||
      const scrollY = window.pageYOffset;
 | 
			
		||||
      Object.assign(this.avatar.style, {
 | 
			
		||||
        position: 'absolute',
 | 
			
		||||
        left: (rect.left + scrollX) + 'px',
 | 
			
		||||
        top:  (rect.top  + scrollY) + 'px',
 | 
			
		||||
        width:  rect.width  + 'px',
 | 
			
		||||
        height: rect.height + 'px',
 | 
			
		||||
        zIndex: 9999
 | 
			
		||||
      });
 | 
			
		||||
      document.body.appendChild(this.avatar);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    detach(){
 | 
			
		||||
      // remove avatar and restore target
 | 
			
		||||
      if (this.avatar && this.avatar.parentNode) {
 | 
			
		||||
        this.avatar.parentNode.removeChild(this.avatar);
 | 
			
		||||
        this.avatar = null;
 | 
			
		||||
      }
 | 
			
		||||
      this.target.style.visibility = this._oldVis;
 | 
			
		||||
      this.target.style.position   = this._oldPos;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // static convenience method
 | 
			
		||||
    static attach(target, opts){
 | 
			
		||||
      return new AvatarReplacer(target, opts);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
/*
 | 
			
		||||
  // DEMO wiring
 | 
			
		||||
  const btnGo = document.getElementById('go');
 | 
			
		||||
  const btnReset = document.getElementById('reset');
 | 
			
		||||
  let repl1, repl2;
 | 
			
		||||
 | 
			
		||||
  btnGo.addEventListener('click', ()=>{
 | 
			
		||||
    // replace #one with a happy avatar saying "Hi!"
 | 
			
		||||
    repl1 = AvatarReplacer.attach(
 | 
			
		||||
      document.getElementById('one'),
 | 
			
		||||
      {emotion:'happy', text:'Hi!', balloonColor:'#fffbdd', textColor:'#333'}
 | 
			
		||||
    );
 | 
			
		||||
    // replace #two with a surprised avatar
 | 
			
		||||
    repl2 = AvatarReplacer.attach(
 | 
			
		||||
      document.getElementById('two'),
 | 
			
		||||
      {emotion:'surprised', faceColor:'#eeffcc', text:'Wow!', balloonColor:'#ddffdd'}
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  btnReset.addEventListener('click', ()=>{
 | 
			
		||||
    if (repl1) repl1.detach();
 | 
			
		||||
    if (repl2) repl2.detach();
 | 
			
		||||
  });
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
class StarField {
 | 
			
		||||
  constructor(container = document.body, options = {}) {
 | 
			
		||||
  constructor({ count = 200, container = document.body } = {}) {
 | 
			
		||||
    this.container = container;
 | 
			
		||||
    this.starCount = count;
 | 
			
		||||
    this.stars = [];
 | 
			
		||||
    this.setOptions(options);
 | 
			
		||||
    this.positionMap = {};
 | 
			
		||||
    this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
 | 
			
		||||
    this._createStars();
 | 
			
		||||
    window.stars = this.positionMap;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setOptions({
 | 
			
		||||
    starCount = 200,
 | 
			
		||||
    minSize = 1,
 | 
			
		||||
    maxSize = 3,
 | 
			
		||||
    speed = 5,
 | 
			
		||||
    color = "white"
 | 
			
		||||
  }) {
 | 
			
		||||
    this.options = { starCount, minSize, maxSize, speed, color };
 | 
			
		||||
  _getStarPosition(star) {
 | 
			
		||||
    const left = parseFloat(star.style.left);
 | 
			
		||||
    const top = parseFloat(star.style.top);
 | 
			
		||||
    if (top < 40 && left >= 40 && left <= 60) return "North";
 | 
			
		||||
    if (top > 60 && left >= 40 && left <= 60) return "South";
 | 
			
		||||
    if (left < 40 && top >= 40 && top <= 60) return "West";
 | 
			
		||||
    if (left > 60 && top >= 40 && top <= 60) return "East";
 | 
			
		||||
    if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
 | 
			
		||||
    return "Corner or Edge";
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clear() {
 | 
			
		||||
    this.stars.forEach(star => star.remove());
 | 
			
		||||
    this.stars = [];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  generate() {
 | 
			
		||||
    this.clear();
 | 
			
		||||
    const { starCount, minSize, maxSize, speed, color } = this.options;
 | 
			
		||||
 | 
			
		||||
    for (let i = 0; i < starCount; i++) {
 | 
			
		||||
  _createStars() {
 | 
			
		||||
    for (let i = 0; i < this.starCount; i++) {
 | 
			
		||||
      const star = document.createElement("div");
 | 
			
		||||
      star.classList.add("star");
 | 
			
		||||
      const size = Math.random() * (maxSize - minSize) + minSize;
 | 
			
		||||
 | 
			
		||||
      Object.assign(star.style, {
 | 
			
		||||
        left: `${Math.random() * 100}%`,
 | 
			
		||||
        top: `${Math.random() * 100}%`,
 | 
			
		||||
        width: `${size}px`,
 | 
			
		||||
        height: `${size}px`,
 | 
			
		||||
        backgroundColor: color,
 | 
			
		||||
        position: "absolute",
 | 
			
		||||
        borderRadius: "50%",
 | 
			
		||||
        opacity: "0.8",
 | 
			
		||||
        animation: `twinkle ${speed}s ease-in-out infinite`,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      this._randomizeStar(star);
 | 
			
		||||
      this._placeStar(star);
 | 
			
		||||
      this.container.appendChild(star);
 | 
			
		||||
      this.stars.push(star);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _randomizeStar(star) {
 | 
			
		||||
    star.style.left = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.top = `${Math.random() * 100}%`;
 | 
			
		||||
    star.style.width = `${Math.random() * 2 + 1}px`;
 | 
			
		||||
    star.style.height = `${Math.random() * 2 + 1}px`;
 | 
			
		||||
    star.style.animationDelay = `${Math.random() * 2}s`;
 | 
			
		||||
    star.style.position = "absolute";
 | 
			
		||||
    star.style.transition = "top 1s ease, left 1s ease, opacity 1s ease";
 | 
			
		||||
 | 
			
		||||
    star.shuffle = () => this._randomizeStar(star);
 | 
			
		||||
    star.position = this._getStarPosition(star);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _placeStar(star) {
 | 
			
		||||
    const pos = star.position;
 | 
			
		||||
    if (!this.positionMap[pos]) this.positionMap[pos] = [];
 | 
			
		||||
    this.positionMap[pos].push(star);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  shuffleAll(duration = 1000) {
 | 
			
		||||
    this.stars.forEach(star => {
 | 
			
		||||
      star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`;
 | 
			
		||||
      star.style.filter = "drop-shadow(0 0 2px white)";
 | 
			
		||||
      const left = Math.random() * 100;
 | 
			
		||||
      const top = Math.random() * 100;
 | 
			
		||||
      star.style.left = `${left}%`;
 | 
			
		||||
      star.style.top = `${top}%`;
 | 
			
		||||
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        star.style.filter = "";
 | 
			
		||||
        star.position = this._getStarPosition(star);
 | 
			
		||||
      }, duration);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  glowColor(tempColor, duration = 2500) {
 | 
			
		||||
    const lighten = (hex, percent) => {
 | 
			
		||||
      const num = parseInt(hex.replace("#", ""), 16);
 | 
			
		||||
      const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
 | 
			
		||||
      const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
 | 
			
		||||
      const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
 | 
			
		||||
      return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const glow = lighten(tempColor, 10);
 | 
			
		||||
    document.documentElement.style.setProperty("--star-color", glow);
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      document.documentElement.style.setProperty("--star-color", this.originalColor);
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  showNotify(text, { duration = 3000, color = "white", fontSize = "1.2em" } = {}) {
 | 
			
		||||
  // Create container if needed
 | 
			
		||||
  if (!this._notifyContainer) {
 | 
			
		||||
    this._notifyContainer = document.createElement("div");
 | 
			
		||||
    this._notifyContainer.className = "star-notify-container";
 | 
			
		||||
    document.body.appendChild(this._notifyContainer);
 | 
			
		||||
  }
 | 
			
		||||
    const messages = document.querySelectorAll('.star-notify');
 | 
			
		||||
 | 
			
		||||
const count = Array.from(messages).filter(el => el.textContent.trim() === text).length;
 | 
			
		||||
 | 
			
		||||
  if (count) return;
 | 
			
		||||
  const note = document.createElement("div");
 | 
			
		||||
  note.className = "star-notify";
 | 
			
		||||
  note.textContent = text;
 | 
			
		||||
  note.style.color = color;
 | 
			
		||||
  note.style.fontSize = fontSize;
 | 
			
		||||
 | 
			
		||||
  this._notifyContainer.appendChild(note);
 | 
			
		||||
 | 
			
		||||
  // Trigger animation
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    note.style.opacity = 1;
 | 
			
		||||
    note.style.transform = "translateY(0)";
 | 
			
		||||
  }, 10);
 | 
			
		||||
 | 
			
		||||
  // Remove after duration
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    note.style.opacity = 0;
 | 
			
		||||
    note.style.transform = "translateY(-10px)";
 | 
			
		||||
    setTimeout(() => note.remove(), 500);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const starField = new StarField(document.body, {
 | 
			
		||||
  starCount: 200,
 | 
			
		||||
  minSize: 1,
 | 
			
		||||
  maxSize: 3,
 | 
			
		||||
  speed: 5,
 | 
			
		||||
  color: "white"    
 | 
			
		||||
});
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
  renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
 | 
			
		||||
    const canvas = document.createElement("canvas");
 | 
			
		||||
    const ctx = canvas.getContext("2d");
 | 
			
		||||
    canvas.width = this.container.clientWidth;
 | 
			
		||||
    canvas.height = this.container.clientHeight / 3;
 | 
			
		||||
 | 
			
		||||
    const fontSize = Math.floor(canvas.height / 2.5);
 | 
			
		||||
    ctx.font = `${fontSize}px sans-serif`;
 | 
			
		||||
    ctx.fillStyle = "black";
 | 
			
		||||
    ctx.textAlign = "center";
 | 
			
		||||
    ctx.textBaseline = "middle";
 | 
			
		||||
    ctx.fillText(word, canvas.width / 2, canvas.height / 2);
 | 
			
		||||
 | 
			
		||||
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
 | 
			
		||||
    const targetPositions = [];
 | 
			
		||||
 | 
			
		||||
    for (let y = 0; y < canvas.height; y += resolution) {
 | 
			
		||||
      for (let x = 0; x < canvas.width; x += resolution) {
 | 
			
		||||
        const i = (y * canvas.width + x) * 4;
 | 
			
		||||
        if (imageData[i + 3] > 128) {
 | 
			
		||||
          targetPositions.push([
 | 
			
		||||
            (x / canvas.width) * 100,
 | 
			
		||||
            (y / canvas.height) * 100,
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    targetPositions.sort(() => Math.random() - 0.5);
 | 
			
		||||
    const used = targetPositions.slice(0, this.stars.length);
 | 
			
		||||
 | 
			
		||||
    this.stars.forEach((star, i) => {
 | 
			
		||||
      star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease, background-color 1s ease`;
 | 
			
		||||
      if (i < used.length) {
 | 
			
		||||
        const [left, top] = used[i];
 | 
			
		||||
        star.style.left = `${left}%`;
 | 
			
		||||
        star.style.top = `${top}%`;
 | 
			
		||||
        star.style.opacity = 1;
 | 
			
		||||
        if (rainbow) {
 | 
			
		||||
          const hue = (i / used.length) * 360;
 | 
			
		||||
          star.style.backgroundColor = `hsl(${hue}, 100%, 70%)`;
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        star.style.opacity = 0;
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.stars.forEach(star => {
 | 
			
		||||
        star.position = this._getStarPosition(star);
 | 
			
		||||
        if (rainbow) star.style.backgroundColor = "";
 | 
			
		||||
      });
 | 
			
		||||
    }, duration);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  explodeAndReturn(duration = 1000) {
 | 
			
		||||
    const originalPositions = this.stars.map(star => ({
 | 
			
		||||
      left: star.style.left,
 | 
			
		||||
      top: star.style.top
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    this.stars.forEach(star => {
 | 
			
		||||
      const angle = Math.random() * 2 * Math.PI;
 | 
			
		||||
      const radius = Math.random() * 200;
 | 
			
		||||
      const x = 50 + Math.cos(angle) * radius;
 | 
			
		||||
      const y = 50 + Math.sin(angle) * radius;
 | 
			
		||||
      star.style.transition = `top ${duration / 2}ms ease-out, left ${duration / 2}ms ease-out`;
 | 
			
		||||
      star.style.left = `${x}%`;
 | 
			
		||||
      star.style.top = `${y}%`;
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => {
 | 
			
		||||
      this.stars.forEach((star, i) => {
 | 
			
		||||
        star.style.transition = `top ${duration}ms ease-in, left ${duration}ms ease-in`;
 | 
			
		||||
        star.style.left = originalPositions[i].left;
 | 
			
		||||
        star.style.top = originalPositions[i].top;
 | 
			
		||||
      });
 | 
			
		||||
    }, duration / 2);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  startColorCycle() {
 | 
			
		||||
    let hue = 0;
 | 
			
		||||
    if (this._colorInterval) clearInterval(this._colorInterval);
 | 
			
		||||
    this._colorInterval = setInterval(() => {
 | 
			
		||||
      hue = (hue + 2) % 360;
 | 
			
		||||
      this.stars.forEach((star, i) => {
 | 
			
		||||
        star.style.backgroundColor = `hsl(${(hue + i * 3) % 360}, 100%, 75%)`;
 | 
			
		||||
      });
 | 
			
		||||
    }, 100);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  stopColorCycle() {
 | 
			
		||||
    if (this._colorInterval) {
 | 
			
		||||
      clearInterval(this._colorInterval);
 | 
			
		||||
      this._colorInterval = null;
 | 
			
		||||
      this.stars.forEach(star => star.style.backgroundColor = "");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  addSpecialStar({ title, content, category = "Info", color = "gold", onClick }) {
 | 
			
		||||
    const star = this.stars.find(s => !s._dataAttached);
 | 
			
		||||
    if (!star) return;
 | 
			
		||||
 | 
			
		||||
    star.classList.add("special");
 | 
			
		||||
    star.style.backgroundColor = color;
 | 
			
		||||
    star._dataAttached = true;
 | 
			
		||||
    star._specialData = { title, content, category, color, onClick };
 | 
			
		||||
 | 
			
		||||
    const tooltip = document.getElementById("star-tooltip");
 | 
			
		||||
    const showTooltip = (e) => {
 | 
			
		||||
      tooltip.innerText = `${title} (${category})`;
 | 
			
		||||
      tooltip.style.display = "block";
 | 
			
		||||
      tooltip.style.left = `${e.clientX + 10}px`;
 | 
			
		||||
      tooltip.style.top = `${e.clientY + 10}px`;
 | 
			
		||||
    };
 | 
			
		||||
    const hideTooltip = () => tooltip.style.display = "none";
 | 
			
		||||
 | 
			
		||||
    star.addEventListener("mouseenter", showTooltip);
 | 
			
		||||
    star.addEventListener("mouseleave", hideTooltip);
 | 
			
		||||
    star.addEventListener("mousemove", showTooltip);
 | 
			
		||||
 | 
			
		||||
    const showPopup = (e) => {
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this._showPopup(star, star._specialData);
 | 
			
		||||
      if (onClick) onClick(star._specialData);
 | 
			
		||||
    };
 | 
			
		||||
    star.addEventListener("click", showPopup);
 | 
			
		||||
    star.addEventListener("touchend", showPopup);
 | 
			
		||||
 | 
			
		||||
    return star;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _showPopup(star, data) {
 | 
			
		||||
    const popup = document.getElementById("star-popup");
 | 
			
		||||
    popup.innerHTML = `<strong>${data.title}</strong><br><small>${data.category}</small><div style="margin-top: 5px;">${data.content}</div>`;
 | 
			
		||||
    popup.style.display = "block";
 | 
			
		||||
 | 
			
		||||
    const rect = star.getBoundingClientRect();
 | 
			
		||||
    popup.style.left = `${rect.left + window.scrollX + 10}px`;
 | 
			
		||||
    popup.style.top = `${rect.top + window.scrollY + 10}px`;
 | 
			
		||||
 | 
			
		||||
    const closeHandler = () => {
 | 
			
		||||
      popup.style.display = "none";
 | 
			
		||||
      document.removeEventListener("click", closeHandler);
 | 
			
		||||
    };
 | 
			
		||||
    setTimeout(() => document.addEventListener("click", closeHandler), 100);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeSpecialStar(star) {
 | 
			
		||||
    if (!star._specialData) return;
 | 
			
		||||
 | 
			
		||||
    star.classList.remove("special");
 | 
			
		||||
    delete star._specialData;
 | 
			
		||||
    star._dataAttached = false;
 | 
			
		||||
 | 
			
		||||
    const clone = star.cloneNode(true);
 | 
			
		||||
    star.replaceWith(clone);
 | 
			
		||||
 | 
			
		||||
    const index = this.stars.indexOf(star);
 | 
			
		||||
    if (index >= 0) this.stars[index] = clone;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSpecialStars() {
 | 
			
		||||
    return this.stars
 | 
			
		||||
      .filter(star => star._specialData)
 | 
			
		||||
      .map(star => ({
 | 
			
		||||
        star,
 | 
			
		||||
        data: star._specialData
 | 
			
		||||
      }));
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const starField = new StarField({starCount: 200});
 | 
			
		||||
app.starField = starField;
 | 
			
		||||
 | 
			
		||||
class DemoSequence {
 | 
			
		||||
  constructor(steps = []) {
 | 
			
		||||
    this.steps = steps;
 | 
			
		||||
    this.overlay = document.createElement("div");
 | 
			
		||||
    this.overlay.className = "demo-overlay";
 | 
			
		||||
    document.body.appendChild(this.overlay);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  start() {
 | 
			
		||||
    this._runStep(0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
_runStep(index) {
 | 
			
		||||
  if (index >= this.steps.length) {
 | 
			
		||||
    this._clearOverlay();
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const { target, text, duration = 3000 } = this.steps[index];
 | 
			
		||||
  this._clearHighlights();
 | 
			
		||||
  this._showOverlay(text);
 | 
			
		||||
 | 
			
		||||
  if (target) {
 | 
			
		||||
    const el = typeof target === 'string' ? document.querySelector(target) : target;
 | 
			
		||||
    if (el) {
 | 
			
		||||
      el.classList.remove("demo-highlight");
 | 
			
		||||
      void el.offsetWidth; // force reflow to reset animation
 | 
			
		||||
      el.classList.add("demo-highlight");
 | 
			
		||||
      el.scrollIntoView({ behavior: "smooth", block: "center" });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    this._hideOverlay();
 | 
			
		||||
    this._runStep(index + 1);
 | 
			
		||||
  }, duration);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
_showOverlay(text) {
 | 
			
		||||
  this.overlay.innerText = text;
 | 
			
		||||
  this.overlay.style.animation = "demoFadeIn 0.8s ease forwards";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  _hideOverlay() {
 | 
			
		||||
    this.overlay.style.opacity = 0;
 | 
			
		||||
    this._clearHighlights();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _clearOverlay() {
 | 
			
		||||
    this.overlay.remove();
 | 
			
		||||
    this._clearHighlights();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  _clearHighlights() {
 | 
			
		||||
    document.querySelectorAll(".demo-highlight").forEach(el => {
 | 
			
		||||
      el.classList.remove("demo-highlight");
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/*
 | 
			
		||||
const demo = new DemoSequence([
 | 
			
		||||
  {
 | 
			
		||||
    text: "💬 Welcome to the Snek Developer Community!",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".channels-list", 
 | 
			
		||||
    text: "🔗 Channels help you organize conversations.",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".user-icon",
 | 
			
		||||
    text: "👥 Invite team members here.",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    target: ".chat-input",
 | 
			
		||||
    text: "⌨️ Type your message and hit Enter!",
 | 
			
		||||
    duration: 3000
 | 
			
		||||
  }
 | 
			
		||||
]);*/
 | 
			
		||||
 | 
			
		||||
// Start when ready (e.g., after load or user action)
 | 
			
		||||
//demo.start();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -6,11 +6,12 @@
 | 
			
		||||
 | 
			
		||||
<section class="chat-area">
 | 
			
		||||
    <message-list class="chat-messages">
 | 
			
		||||
    {% for message in messages %}
 | 
			
		||||
        {% autoescape false %}
 | 
			
		||||
        {{ message.html }}
 | 
			
		||||
        {% endautoescape %}
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
        {% for message in messages %}
 | 
			
		||||
            {% autoescape false %}
 | 
			
		||||
            {{ message.html }}
 | 
			
		||||
            {% endautoescape %}
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        <div class="message-list-bottom"></div>
 | 
			
		||||
    </message-list>
 | 
			
		||||
    <chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
 | 
			
		||||
</section>
 | 
			
		||||
@ -36,7 +37,21 @@
 | 
			
		||||
            showHelp();
 | 
			
		||||
        }
 | 
			
		||||
     }
 | 
			
		||||
    
 | 
			
		||||
     app.ws.addEventListener("refresh", (data) => {
 | 
			
		||||
         app.starField.showNotify(data.message);
 | 
			
		||||
         setTimeout(() => {
 | 
			
		||||
             window.location.reload();
 | 
			
		||||
         },4000)
 | 
			
		||||
     })
 | 
			
		||||
    app.ws.addEventListener("deployed", (data) => {
 | 
			
		||||
         app.starField.renderWord("Deployed",{"rainbow":true,"resolution":8});
 | 
			
		||||
         setTimeout(() => {
 | 
			
		||||
             app.starField.shuffleAll(5000);
 | 
			
		||||
         },10000)
 | 
			
		||||
     })    
 | 
			
		||||
     app.ws.addEventListener("starfield.render_word", (data) => {
 | 
			
		||||
         app.starField.renderWord(data.word,data);
 | 
			
		||||
     })    
 | 
			
		||||
        const textBox = document.querySelector("chat-input").textarea
 | 
			
		||||
        textBox.addEventListener("paste", async (e) => {
 | 
			
		||||
            try {
 | 
			
		||||
@ -184,9 +199,8 @@
 | 
			
		||||
        });
 | 
			
		||||
        lastMessage = messagesContainer.querySelector(".message:last-child");
 | 
			
		||||
        if (doScrollDown) {
 | 
			
		||||
            lastMessage?.scrollIntoView({ block: "end", inline: "nearest" });
 | 
			
		||||
             
 | 
			
		||||
             chatInputField.scrollIntoView({ block: "end", inline: "nearest" });
 | 
			
		||||
            messagesContainer.scrollToBottom()
 | 
			
		||||
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -9,7 +9,7 @@
 | 
			
		||||
 | 
			
		||||
import json
 | 
			
		||||
import traceback
 | 
			
		||||
 | 
			
		||||
import asyncio
 | 
			
		||||
from aiohttp import web
 | 
			
		||||
 | 
			
		||||
from snek.system.model import now
 | 
			
		||||
@ -21,13 +21,30 @@ import logging
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
class RPCView(BaseView):
 | 
			
		||||
 | 
			
		||||
    class RPCApi:
 | 
			
		||||
        def __init__(self, view, ws):
 | 
			
		||||
            self.view = view
 | 
			
		||||
            self.app = self.view.app
 | 
			
		||||
            self.services = self.app.services
 | 
			
		||||
            self.ws = ws
 | 
			
		||||
            self.user_session = {}
 | 
			
		||||
            self._scheduled = []
 | 
			
		||||
 | 
			
		||||
        async def _session_ensure(self):
 | 
			
		||||
            uid = await self.view.session_get("uid")
 | 
			
		||||
            if not uid in self.user_session:
 | 
			
		||||
                self.user_session[uid] = {
 | 
			
		||||
                    "said_hello": False,
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
        async def session_get(self, key, default):
 | 
			
		||||
            await self._session_ensure()
 | 
			
		||||
            return self.user_session[self.user_uid].get(key, default)
 | 
			
		||||
 | 
			
		||||
        async def session_set(self, key, value):
 | 
			
		||||
            await self._session_ensure()
 | 
			
		||||
            self.user_session[self.user_uid][key] = value
 | 
			
		||||
            return True
 | 
			
		||||
 | 
			
		||||
        async def db_insert(self, table_name, record):
 | 
			
		||||
            self._require_login()
 | 
			
		||||
@ -323,6 +340,13 @@ class RPCView(BaseView):
 | 
			
		||||
                async for record in self.services.channel.get_users(channel_uid)
 | 
			
		||||
            ]
 | 
			
		||||
 | 
			
		||||
        async def _schedule(self, seconds, call):
 | 
			
		||||
            self._scheduled.append(call)
 | 
			
		||||
            await asyncio.sleep(seconds)
 | 
			
		||||
            await self.services.socket.send_to_user(self.user_uid, call)
 | 
			
		||||
            self._scheduled.remove(call)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        async def ping(self, callId, *args):
 | 
			
		||||
            if self.user_uid:
 | 
			
		||||
                user = await self.services.user.get(uid=self.user_uid)
 | 
			
		||||
@ -330,7 +354,17 @@ class RPCView(BaseView):
 | 
			
		||||
                await self.services.user.save(user)
 | 
			
		||||
            return {"pong": args}
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    async def get(self):
 | 
			
		||||
        scheduled = []
 | 
			
		||||
        async def schedule(uid, seconds, call):
 | 
			
		||||
            scheduled.append(call)
 | 
			
		||||
            await asyncio.sleep(seconds)
 | 
			
		||||
            await self.services.socket.send_to_user(uid, call)
 | 
			
		||||
            scheduled.remove(call)
 | 
			
		||||
 | 
			
		||||
        ws = web.WebSocketResponse()
 | 
			
		||||
        await ws.prepare(self.request)
 | 
			
		||||
        if self.request.session.get("logged_in"):
 | 
			
		||||
@ -343,6 +377,16 @@ class RPCView(BaseView):
 | 
			
		||||
                await self.services.socket.subscribe(
 | 
			
		||||
                    ws, subscription["channel_uid"], self.request.session.get("uid")
 | 
			
		||||
                )
 | 
			
		||||
        if not scheduled and  self.request.app.uptime_seconds < 5:
 | 
			
		||||
            await schedule(self.request.session.get("uid"),0,{"event":"refresh", "data": {
 | 
			
		||||
                    "message": "Finishing deployment"}
 | 
			
		||||
                    }
 | 
			
		||||
                )
 | 
			
		||||
            await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": {
 | 
			
		||||
                "uptime": self.request.app.uptime}
 | 
			
		||||
                }
 | 
			
		||||
            )
 | 
			
		||||
        
 | 
			
		||||
        rpc = RPCView.RPCApi(self, ws)
 | 
			
		||||
        async for msg in ws:
 | 
			
		||||
            if msg.type == web.WSMsgType.TEXT:
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user