tmpchat / cmd / frontend / templates / tmpchat-channel.gohtml

@ 57cd687b81d1640c584c2673280ec456a5463380 | history


<html>
<!-- Questions? Comments? Concerns? Let us know what you think: 1-800-TMP-CHAT@nmyk.io -->
<head>
    <link href="https://fonts.googleapis.com/css?family=Inconsolata" rel="stylesheet">
    <link href="https://cdn.nmyk.io/assets/style.css" rel="stylesheet">
    <link href="https://cdn.nmyk.io/assets/favicon.ico" rel="icon">
    <meta charset="UTF-8">
    <meta name="viewport" content="width=500">
    <meta name="description"
          content="tmpchat is a WebRTC-based group text messaging application designed and built by Nick Mykins (nmyk.io)"/>
    <title>tmpch.at - {{.ChannelName}}</title>
    <script type="text/javascript" src="https://cdn.nmyk.io/assets/he.js"></script>
    <script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
    <script>
        const signalingURL = "{{.SignalingURL|safeURL}}";
        const myUserID = "{{.UserID}}";
        const SEPARATOR = " โ€ข ";
        const EMOJI = ["๐Ÿ", "๐ŸŽ", "๐Ÿ", "๐ŸŠ", "๐Ÿ‹", "๐ŸŒ", "๐Ÿ‰", "๐Ÿ‡", "๐Ÿ“", "๐Ÿˆ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿ", "๐Ÿฅฅ", "๐Ÿฅ", "๐Ÿ…", "๐Ÿ†", "๐Ÿฅ‘", "๐Ÿฅฆ", "๐Ÿฅ’", "๐ŸŒถ", "๐ŸŒฝ", "๐Ÿฅ•", "๐Ÿฅ”", "๐Ÿ ", "๐Ÿฅ", "๐Ÿž", "๐Ÿฅ–", "๐Ÿฅจ", "๐Ÿง€", "๐Ÿณ", "๐Ÿฅž", "๐Ÿฅ“", "๐Ÿฅฉ", "๐Ÿ—", "๐Ÿ–", "๐ŸŒญ", "๐Ÿ”", "๐ŸŸ", "๐Ÿ•", "๐Ÿฅช", "๐Ÿฅ™", "๐ŸŒฎ", "๐ŸŒฏ", "๐Ÿฅ—", "๐Ÿฅ˜", "๐Ÿฅซ", "๐Ÿ", "๐Ÿœ", "๐Ÿฒ", "๐Ÿ›", "๐Ÿฃ", "๐Ÿฑ", "๐ŸฅŸ", "๐Ÿค", "๐Ÿ™", "๐Ÿš", "๐Ÿ˜", "๐Ÿฅ", "๐Ÿฅ ", "๐Ÿข", "๐Ÿก", "๐Ÿง", "๐Ÿจ", "๐Ÿฆ", "๐Ÿฅง", "๐Ÿฐ", "๐ŸŽ‚", "๐Ÿฎ", "๐Ÿญ", "๐Ÿฌ", "๐Ÿซ", "๐Ÿฟ", "๐Ÿฉ", "๐Ÿช", "๐ŸŒฐ", "๐Ÿฅœ", "๐Ÿฏ", "๐Ÿฅ›", "๐Ÿผ", "โ˜•", "๏ธ๐Ÿต", "๐Ÿฅค", "๐Ÿถ", "๐Ÿบ", "๐Ÿป", "๐Ÿฅ‚", "๐Ÿท", "๐Ÿฅƒ", "๐Ÿธ", "๐Ÿน", "๐Ÿพ", "๐Ÿฅก", "โšฝ", "๐Ÿ€", "๐Ÿˆ", "โ›“", "๐ŸŽพ", "๐Ÿ", "๐Ÿ‰", "๐ŸŽฑ", "๐Ÿ“", "๐Ÿธ", "๐Ÿ’", "๐Ÿ‘", "๐Ÿ", "๐Ÿฅ…", "โ›ณ", "๐ŸฅŠ", "๐Ÿฅ‹", "๐ŸŽฝ", "๐Ÿ†", "๐Ÿฅ‡", "๐ŸŽญ", "๐ŸŽจ", "๐ŸŽฌ", "๐ŸŽค", "๐ŸŽง", "๐ŸŽผ", "๐ŸŽน", "๐Ÿฅ", "๐ŸŽท", "๐ŸŽบ", "๐ŸŽธ", "๐ŸŽป", "๐ŸŽฒ", "๐Ÿ‘„", "๐ŸŽฏ", "๐ŸŽณ", "๐ŸŽฎ", "๐ŸŽฐ", "๐Ÿš—", "๐Ÿš•", "๐Ÿš™", "๐ŸšŒ", "๐ŸšŽ", "๐ŸŽ", "๐Ÿš“", "๐Ÿš‘", "๐Ÿš’", "๐Ÿš", "๐Ÿšš", "๐Ÿš›", "๐Ÿšœ", "๐Ÿ", "๐Ÿœ", "๐ŸŒ‹", "๐Ÿ”", "๐Ÿฃ", "๐Ÿค", "๐Ÿฅ", "๐Ÿฆ", "๐Ÿจ", "๐Ÿช", "๐Ÿซ", "๐Ÿฉ", "๐Ÿ’’", "๐Ÿ›", "๐Ÿก", "๐ŸŽ‘", "๐Ÿž", "๐ŸŒ…", "๐ŸŒ„", "๐ŸŒ ", "๐ŸŽ‡", "๐ŸŽ†", "๐ŸŒ‡", "๐ŸŒƒ", "๐ŸŒŒ", "๐ŸŒ‰", "๐ŸŒ", "๐Ÿ””", "๐Ÿ”ง", "๐Ÿ”จ", "โš’", "๐Ÿšฌ", "๐ŸŽŽ", "โš™๏ธ", "๐Ÿ“ซ", "๐Ÿ”ฎ", "๐Ÿ“ฟ", "๐Ÿ’Š", "๐Ÿ’‰", "๐Ÿ’Ž", "๐Ÿ“ธ", "๐Ÿ’ฐ", "๐Ÿ”ฆ", "๐Ÿ•ฏ", "๐ŸŽ›", "๐Ÿ’ฃ", "๐Ÿ—ฟ", "๐Ÿ—ฝ", "๐Ÿ—ผ", "๐Ÿฐ", "๐Ÿฏ", "๐ŸŸ", "๐ŸŽก", "๐ŸŽข", "๐ŸŽ ", "๐Ÿšฒ", "๐ŸŒบ", "๐ŸŒธ", "๐ŸŒผ", "๐ŸŒป", "๐ŸŒž", "๐ŸŒณ", "๐ŸŒด", "๐ŸŒฑ", "๐ŸŒฟ", "๐Ÿ€", "๐ŸŽ", "๐ŸŽ‹", "๐Ÿƒ", "๐Ÿ‚", "๐Ÿ", "๐Ÿ„", "๐Ÿš", "๐ŸŒพ", "๐Ÿ’", "๐ŸŒท", "๐ŸŒน", "๐Ÿฅ€", "๐Ÿ", "๐Ÿ€", "๐Ÿฟ", "๐Ÿฆ”", "๐Ÿพ", "๐Ÿ•Š", "๐Ÿ‡", "๐ŸŒต", "๐ŸŽ„", "๐Ÿˆ", "๐Ÿ“", "๐Ÿฆƒ", "๐Ÿฆ", "๐Ÿช", "๐Ÿซ", "๐Ÿฆ’", "๐Ÿก", "๐Ÿ ", "๐ŸŸ", "๐Ÿฌ", "๐Ÿณ", "๐Ÿ‹", "๐Ÿฆˆ", "๐ŸŠ", "๐Ÿ…", "๐Ÿ†", "๐Ÿฆ“", "๐Ÿฆ", "๐Ÿƒ", "๐Ÿ‚", "๐Ÿ„", "๐ŸŽ", "๐ŸŒŠ", "๐Ÿ", "๐Ÿ‘", "๐Ÿฆ‚", "๐Ÿข", "๐Ÿ", "๐ŸฆŽ", "๐Ÿฆ–", "๐Ÿฆ•", "๐Ÿ™", "๐Ÿฆ‘", "๐Ÿฆ", "๐Ÿฆ€", "๐Ÿฆ‹", "๐ŸŒ", "๐Ÿž", "๐Ÿœ", "๐Ÿฆ†", "๐Ÿฆ…", "๐Ÿฆ‰", "๐Ÿฆ‡", "๐Ÿบ", "๐Ÿ—", "๐Ÿด", "๐Ÿฆ„", "๐Ÿ", "๐Ÿ›", "๐Ÿฃ", "๐Ÿถ", "๐Ÿฑ", "๐Ÿญ", "๐Ÿน", "๐Ÿฐ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿผ", "๐Ÿจ", "๐Ÿฏ", "๐Ÿฆ", "๐Ÿฎ", "๐Ÿท", "๐Ÿธ", "๐Ÿต", "๐Ÿ˜ˆ", "๐Ÿ‘น", "๐Ÿ‘บ", "๐Ÿคก", "๐Ÿ’ฉ", "๐Ÿ‘ป", "๐Ÿ’€", "๐Ÿ’", "๐Ÿ‘ฝ", "๐Ÿ‘พ", "๐Ÿค–", "๐ŸŽƒ", "๐Ÿ˜น", "๐Ÿ˜ป", "๐Ÿ’ง", "๐Ÿ‘ ", "๐Ÿ‘‘", "๐Ÿ‘’", "๐ŸŽฉ", "๐ŸŽ“", "๐Ÿงข", "โ›‘", "๐Ÿ’„", "๐Ÿ’", "๐Ÿ’ผ", "๐Ÿ‘โ€"];

        const SignalingEvent = {
            "Entrance": 0,
            "Exit": 1,
            "RTCOffer": 2,
            "RTCAnswer": 3,
            "RTCICECandidate": 4,
            "TURNCredRequest": 5,
            "TURNCredResponse": 6
        };

        const TmpchatEvent = {
            "Message": 7,
            "Clear": 8,
            "NameChange": 9
        };

        const chooseOne = arr => arr[Math.floor(Math.random() * arr.length)];

        let myName = chooseOne(EMOJI);

        const newMessage = (type, content) => {
            return {
                "from": myUserID,
                "type": type,
                "content": content
            };
        };

        const broadcast = message => {
            for (let id in rtcPeerConns) {
                let dc = rtcPeerConns[id]["dataChannel"];
                if (dc && dc.readyState === "open") {
                    dc.send(JSON.stringify(message));
                }
            }
        };

        const nameTag = (message, isFromMe) => {
            let tag = document.createElement("div");
            let name = document.createElement("span");
            name.className = message["from"];
            name.innerHTML = isFromMe ? myName : userNames[message["from"]];
            tag.appendChild(name);
            if (isFromMe) {
                tag.className = "myname";
                tag.innerHTML = SEPARATOR + tag.innerHTML;
            } else {
                tag.className = "theirname";
                tag.innerHTML = tag.innerHTML + SEPARATOR;
            }
            return tag;
        };

        const shouldStackMsg = (message, lastMsgElement) => {
            if (message["type"] !== TmpchatEvent.Message || !lastMsgElement) {
                return false;
            }
            if (lastMsgElement.className === "systemmessage") {
                return false;
            }
            let lastMsgUserId = lastMsgElement.firstElementChild.firstElementChild.className;
            return message["from"] === lastMsgUserId;
        };

        const announceEntrance = user => {
            let name = document.createElement("span");
            name.className = user["id"];
            name.innerHTML = user["name"];
            announce(name.outerHTML + " joined");
        };

        const announceExit = user => {
            let name = document.createElement("span");
            name.className = user["id"];
            name.innerHTML = userNames[user["id"]];
            announce(name.outerHTML + " left");
        };

        const announce = announcementHTML => {
            let announcement = document.createElement("div");
            announcement.className = "systemmessage";
            announcement.innerHTML = announcementHTML;
            document.getElementById("messagelog").appendChild(announcement);
        };

        const appendToRoll = user => {
            userNames[user["id"]] = user["name"];
            let tag = document.createElement("div");
            let name = document.createElement("span");
            name.className = user["id"];
            name.innerHTML = user["name"];
            tag.appendChild(name);
            tag.style.display = "inline";
            tag.innerHTML = SEPARATOR + tag.innerHTML;
            document.getElementById("namechange").appendChild(tag);
        };

        const doClear = () => {
            let node = document.getElementById("messagelog");
            while (node.firstChild) {
                node.removeChild(node.firstChild);
            }
            document.getElementById("messagetext").focus();
        };

        const doNameChange = message => {
            let userId = message["from"];
            let newName = he.escape(message["content"]);
            let toChange = document.getElementsByClassName(userId);
            for (let i = 0; i < toChange.length; i++) {
                toChange[i].innerHTML = newName;
            }
            if (userId === myUserID) {
                myName = newName;
                resetNameChangeInput();
            } else {
                userNames[userId] = newName;
            }
        };

        const resetNameChangeInput = () => {
            document.getElementById("myname").value = he.unescape(myName);
            document.getElementById("myname").size = he.unescape(myName).length;
        };

        const newNameIsOk = newName => {
            if (newName === "" || newName === myName) {
                return false
            }
            for (let id in userNames) {
                if (userNames[id] === newName) {
                    return false;
                }
            }
            return true;
        };

        const shuffle = array => {
            let i = array.length, tmp, r;
            while (0 !== i) {
                r = Math.floor(Math.random() * i);
                i -= 1;
                tmp = array[i];
                array[i] = array[r];
                array[r] = tmp;
            }
            return array;
        };

        const getNewName = () => {
            if (Object.keys(userNames).length >= EMOJI.length) {
                let n = 0;
                while (!newNameIsOk(String(n))) {
                    n += 1;
                }
                return String(n);
            }
            let e = shuffle(EMOJI);
            for (let i = 0; i < EMOJI.length; i++) {
                if (newNameIsOk(e[i])) {
                    return e[i];
                }
            }
        };

        const parseAndValidate = (event, dataChannel) => {
            let message = JSON.parse(event.data), nothing = {};
            let userID = message && message["from"];
            let isValid = (
                message["type"] &&
                userID &&
                userID !== myUserID &&
                rtcPeerConns.hasOwnProperty(userID) &&
                rtcPeerConns[userID]["dataChannel"] === dataChannel
            );
            return isValid ? message : nothing;
        };

        const handleTmpchatEvent = (event, dataChannel) => {
            let message = parseAndValidate(event, dataChannel);
            switch (message.type) {
                case TmpchatEvent.Message:
                    write(message);
                    break;
                case TmpchatEvent.Clear:
                    doClear();
                    break;
                case TmpchatEvent.NameChange:
                    doNameChange(message);
                    break;
            }
        };

        const write = message => {
            let messageLog = document.getElementById("messagelog");
            const lastMsgElement = messageLog.lastElementChild;
            if (shouldStackMsg(message, lastMsgElement)) {
                let currentText = lastMsgElement.getElementsByClassName("chatmessage")[0];
                currentText.textContent += "\n" + message["content"];
            } else {
                let isFromMe = message["from"] === myUserID;
                let name = nameTag(message, isFromMe);
                let msg = document.createElement("div");
                msg.className = isFromMe ? "mymessage" : "theirmessage";
                let pre = document.createElement("pre");
                pre.className = "chatmessage";
                pre.textContent = message["content"];
                msg.appendChild(pre);
                msg.insertAdjacentHTML("afterbegin", name.outerHTML);
                messageLog.appendChild(msg);
            }
            if (document.activeElement === document.getElementById("messagetext")) {
                messageLog.scrollTop = messageLog.scrollHeight;
            }
        };

        const info = txt => {
            document.getElementById("info").innerText = txt;
        };

        const rtcPeerConns = {};
        const userNames = {};

        const getMember = id => {
            return {"id": id, "name": userNames[id]}
        };

        let ws = new WebSocket(`${signalingURL}/?userID=${myUserID}&channelName={{.ChannelName}}`);

        ws.sendMessage = message => ws.send(JSON.stringify(message));

        ws.onopen = () => {
            ws.sendMessage(newMessage(SignalingEvent.TURNCredRequest, myName));
        };

        ws.onerror = event => {
            info(event);
            ws.close();
        };

        const addNewRTCPeerConn = (turnCreds, member, isLocal) => {
            // isLocal: true if we're already in the chat and adding an
            // RTCPeerConnection for a new arrival. false if we're adding
            // RTCPeerConnections for existing members because we're new.
            let pc = new RTCPeerConnection({
                iceServers: [{
                    urls: "turns:turn.tmpch.at:443",
                    username: turnCreds["username"],
                    credential: turnCreds["credential"]
                }]
            });
            pc.onicecandidate = event => {
                if (event.candidate !== null) {
                    let desc = btoa(JSON.stringify(event.candidate));
                    let msg = newMessage(SignalingEvent.RTCICECandidate, desc);
                    msg["to"] = member["id"];
                    ws.sendMessage(msg);
                }
            };
            pc.onnegotiationneeded = () => pc.createOffer()
                .then(d => pc.setLocalDescription(d))
                .then(() => {
                    if (isLocal) {
                        let desc = btoa(JSON.stringify(pc.localDescription));
                        let msg = newMessage(SignalingEvent.RTCOffer, {"name": myName, "desc": desc});
                        msg["to"] = member["id"];
                        ws.sendMessage(msg);
                    }
                })
                .catch(info);
            pc.ondatachannel = event => {
                event.channel.onopen = () => {
                    rtcPeerConns[member["id"]]["dataChannel"] = event.channel;
                    if (!isLocal && userNames[member["id"]] === myName) {
                        let newName = getNewName();
                        let message = newMessage(TmpchatEvent.NameChange, newName);
                        doNameChange(message);
                        broadcast(message);
                    }
                };
                event.channel.onmessage = e => handleTmpchatEvent(e, event.channel);
            };
            rtcPeerConns[member["id"]] = {
                "conn": pc,
            };
        };

        const answerRTCOffer = message => {
            let offerDesc = JSON.parse(atob(message["content"]["desc"]));
            let member = getMember(message["from"]);
            rtcPeerConns.add(member, false);
            let peerConn = rtcPeerConns[message["from"]]["conn"];
            peerConn.setRemoteDescription(new RTCSessionDescription(offerDesc))
                .then(() => peerConn.createAnswer())
                .then(answer => peerConn.setLocalDescription(answer))
                .then(() => {
                    let desc = btoa(JSON.stringify(peerConn.localDescription));
                    let response = newMessage(SignalingEvent.RTCAnswer, desc);
                    response["to"] = message["from"];
                    ws.sendMessage(response);
                })
                .catch(info);
        };

        const addNewDataChannel = member => {
            let dc = rtcPeerConns[member["id"]]["conn"]
                .createDataChannel(unescape(window.location.pathname.substr(1)));
            dc.onclose = () => {
                console.log(`dataChannel for ${member["id"]} has closed`);
                delete rtcPeerConns[member["id"]];
            };
            dc.onopen = () => rtcPeerConns[member["id"]]["dataChannel"] = dc;
            dc.onmessage = event => handleTmpchatEvent(event, dc);
            rtcPeerConns[member["id"]]["dataChannel"] = dc;
        };

        ws.onmessage = event => {
            let message = JSON.parse(event.data);
            switch (message.type) {
                case SignalingEvent.Entrance:
                    handleEntrance(message);
                    break;
                case SignalingEvent.Exit:
                    handleExit(message);
                    break;
                case SignalingEvent.RTCOffer:
                    handleRTCOffer(message);
                    break;
                case SignalingEvent.RTCAnswer:
                    handleRTCAnswer(message);
                    break;
                case SignalingEvent.RTCICECandidate:
                    handleICECandidate(message);
                    break;
                case SignalingEvent.TURNCredResponse:
                    handleTURNCredResponse(message);
            }
        };

        const handleEntrance = message => {
            let member = message["content"];
            if (rtcPeerConns[member["id"]]) {
                return;
            }
            if (member["id"] !== myUserID) {
                rtcPeerConns.add(member, true);
                addNewDataChannel(member);
                appendToRoll(member);
            }
            announceEntrance(member);
        };

        const handleExit = message => {
            let member = getMember(message["from"]);
            if (member["id"] !== myUserID) {
                let element = document.getElementById("namechange").getElementsByClassName(member["id"])[0];
                element.parentElement.outerHTML = "";
                announceExit(member);
                delete userNames[member["id"]];
                delete rtcPeerConns[member["id"]];
            }
        };

        const handleRTCOffer = message => {
            userNames[message["from"]] = message["content"]["name"];
            appendToRoll(getMember(message["from"]));
            answerRTCOffer(message);
        };

        const handleRTCAnswer = message => {
            let answerDesc = JSON.parse(atob(message["content"]));
            rtcPeerConns[message["from"]]["conn"]
                .setRemoteDescription(new RTCSessionDescription(answerDesc))
                .catch(info);
        };

        const handleICECandidate = message => {
            let candidate = JSON.parse(atob(message["content"]));
            rtcPeerConns[message["from"]]["conn"]
                .addIceCandidate(candidate)
                .catch(info);
        };

        const handleTURNCredResponse = message => {
            rtcPeerConns.add = (member, isLocal) =>
                addNewRTCPeerConn(message["content"], member, isLocal)
        };

        window.onload = () => {
            const input = document.getElementById("messagetext");

            document.getElementById("send").onclick = () => {
                if (input.value === "") {
                    return false;
                }
                let msg = newMessage(TmpchatEvent.Message, input.value);
                write(msg);
                broadcast(msg);
                input.value = "";
                return false;
            };

            document.getElementById("myname").onblur =
                document.getElementById("namechange").onsubmit = () => {
                    let newName = document.getElementById("myname").value;
                    if (!newNameIsOk(newName)) {
                        input.focus();
                        resetNameChangeInput();
                        return false;
                    }
                    let message = newMessage(TmpchatEvent.NameChange, newName);
                    doNameChange(message);
                    broadcast(message);
                    input.focus();
                    return false;
                };

            document.getElementById("myname").onfocus = () => {
                document.getElementById("myname").value = "";
                document.getElementById("myname").size = 10;
                return false;
            };

            document.getElementById("clear").onclick = () => {
                if (!ws) {
                    doClear();
                    return false;
                }
                doClear();
                broadcast(newMessage(TmpchatEvent.Clear, null));
                return false;
            };

            input.onfocus = () => {
                let m = document.getElementById("messagelog");
                m.scrollTop = m.scrollHeight;
            };

            let doubleEnterTs; // Clear chat by double-pressing Enter with no text in the input field
            input.onkeypress = event => {
                if (event.key === "Enter" && !event.shiftKey) {
                    if (input.value === "" && (Date.now() - doubleEnterTs < 150)) {
                        document.getElementById("clear").click();
                        return false;
                    }
                    if (input.value === "") {
                        doubleEnterTs = Date.now();
                        return false;
                    }
                    document.getElementById("send").click();
                    return false;
                }
            };
            document.getElementById("myname").value = myName;
            input.focus();
        };
    </script>
</head>

<body>
<div class="vspace50px"></div>
<div class="container" style="width: 440px; text-align: center;">
    <a href="/" type="home" style="margin:auto;">tmpchat</a>
    <br>
    #{{.ChannelName}}
</div>
<br>
<div class="container" style="width: 440px;">
    <div class="chatcontainer">
        <div class="messagelog" id="messagelog"></div>
    </div>
</div>
<div class="chatui">
    <div style="margin-top: 2px;">
        <form id="namechange">
            online: <input id="myname" autocomplete="off" type="nametext" size=2 maxlength=16>
        </form>
        <div style="display:inline-block;float: right;">
            <input type="button" id="clear" value="clear">
        </div>
    </div>
</div>
<div class="chatui">
    <form id="message">
        <div class="messagetextcontainer">
            <textarea rows=1 id="messagetext"></textarea>
        </div>

        <div style="float: right;">
            <input type="submit" id="send" value="send">
        </div>
    </form>
</div>
<br>
<div id="info" class="chatui" style="text-align: center;"></div>
</body>

</html>