556 lines
19 KiB
HTML
Raw Normal View History

2024-05-29 18:49:28 +08:00
{% extends "_layout.html" %}
{% block title %}ChatBox{% endblock %}
{% block pagestylesheet %}
<link rel="stylesheet" href="/static/index.css" />
{% endblock %}
{% block content %}
<div id="app">
<div id="message-list">
</div>
<div id="inputs">
<select id="peers">
</select>
2024-06-04 11:29:30 +08:00
<input id="message-input" disabled />
<input type="file" id="sendfile" disabled />
2024-05-29 18:49:28 +08:00
</div>
</div>
{% endblock %}
{% block pagescripts %}
<script>
2024-06-04 11:29:30 +08:00
(() => {
const MessageBootstrap = "Bootstrap";
const MessageDiscoverRequest = "DiscoverRequest";
const MessageDiscoverResponse = "DiscoverResponse";
const MessageSessionOffer = "SessionOffer";
const MessageSessionAnswer = "SessionAnswer";
const MessageICECandidate = "IceCandidate";
2024-06-04 11:54:13 +08:00
function ChatRoom(room, name, options) {
2024-06-04 11:29:30 +08:00
options = options || {};
let { iceServers } = options;
this.room = room;
this.name = name;
this.iceServers = iceServers;
this.peers = new Map();
this.channels = new Map();
this.i_am_polite = new Map();
this.i_am_offering = new Map();
this.ws = null;
2024-06-04 11:57:17 +08:00
this.reconnectTimeout = null;
2024-06-04 11:29:30 +08:00
this.event = new EventTarget();
2024-05-30 02:07:37 +08:00
}
2024-06-04 11:29:30 +08:00
let nativeEvent = window.Event;
function Event(type, data) {
let event = new nativeEvent(type);
if (data) {
for (let [k, v] of Object.entries(data)) {
event[k] = v
}
2024-05-30 02:07:37 +08:00
}
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
return event;
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
// ---------------- Chat Room Methods ------------
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
// ---------------- Utilities --------------------
ChatRoom.prototype.kickoff = function () {
this.event.dispatchEvent(new Event("kickoff"))
this.send_message({
2024-05-29 18:49:28 +08:00
message: {
type: MessageBootstrap,
},
2024-06-04 11:29:30 +08:00
})
}
ChatRoom.prototype.setup_peerconnection = function (sender) {
peer = this.peers.get(sender);
if (peer == null) {
peer = new RTCPeerConnection({
iceServers: [{ urls: this.iceServers }]
2024-05-29 18:49:28 +08:00
})
2024-06-04 11:29:30 +08:00
peer.addEventListener("iceconnectionstatechange", () => {
if (peer.iceConnectionState === "failed") {
peer.restartIce();
2024-05-29 18:49:28 +08:00
}
})
2024-06-04 11:29:30 +08:00
peer.addEventListener("negotiationneeded", async (ev) => {
this.on_negotiation_needed(sender)
})
2024-05-29 18:49:28 +08:00
peer.addEventListener("icecandidate", (ev) => {
if (!ev.candidate) {
return
}
2024-06-04 11:29:30 +08:00
2024-05-29 18:49:28 +08:00
let candidate = ev.candidate.toJSON()
2024-06-04 11:29:30 +08:00
this.on_local_ice_candidate(sender, candidate)
2024-05-29 18:49:28 +08:00
})
2024-06-04 11:29:30 +08:00
peer.addEventListener("datachannel", ({ channel }) => {
this.on_datachannel(sender, channel)
2024-05-29 18:49:28 +08:00
})
2024-06-04 11:29:30 +08:00
this.peers.set(sender, peer);
}
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
return {
peer,
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
}
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
ChatRoom.prototype.setup_chat_datachannel = function (sender, channel) {
console.log("setup chat datachannel", { sender, channel })
channel.addEventListener("open", (ev) => {
this.event.dispatchEvent(new Event("chat-channel-open", {
sender,
channel
}))
})
channel.addEventListener("message", (ev) => {
console.log(ev)
this.on_chat_message(sender, ev.data)
})
channel.addEventListener("close", () => {
this.event.dispatchEvent(new Event("chat-channel-close", {
sender,
channel
}))
})
}
ChatRoom.prototype.setup_file_datachannel = function (sender, channel) {
let filename = channel.label.substr(5)
let buffers = [];
channel.addEventListener("open", ev => {
this.event.dispatchEvent(new Event("file_receiving", {
filename,
}))
// this.display(`${sender} 给你发了一个文件: ${filename}。 接收中...`)
})
channel.addEventListener("message", ev => {
buffers.push(ev.data)
})
channel.addEventListener("close", () => {
this.event.dispatchEvent(new Event("file_received", {
buffer: new Blob(buffers),
filename
}))
})
}
ChatRoom.prototype.reconnect = function () {
this.event.dispatchEvent(new Event("ws_reconnect", { ws: this.ws }))
if (this.ws && (this.ws.readyState == WebSocket.CONNECTING || this.ws.readyState == WebSocket.OPEN)) {
return;
}
let protocol = "ws://";
if (location.protocol === "https:") {
protocol = "wss://"
}
let ws = new WebSocket(protocol + location.host + "/ws");
ws.addEventListener("error", (ev) => {
console.error("ws error", ev)
2024-06-04 11:57:17 +08:00
if(this.reconnectTimeout) {
return
}
this.reconnectTimeout = setTimeout(() => {
this.reconnect()
}, 3000)
2024-06-04 11:29:30 +08:00
})
ws.addEventListener("close", () => {
console.warn("ws closed")
2024-06-04 11:57:17 +08:00
if (this.reconnectTimeout) {
return
}
this.reconnectTimeout = setTimeout(() => {
this.reconnect()
}, 3000)
2024-06-04 11:29:30 +08:00
})
ws.addEventListener("message", ({ data }) => {
this.on_ws_message(JSON.parse(data))
})
ws.addEventListener("open", () => {
this.kickoff()
})
this.ws = ws;
2024-06-04 11:57:17 +08:00
this.reconnectTimeout = null;
2024-06-04 11:29:30 +08:00
}
ChatRoom.prototype.send_message = function (message) {
this.event.dispatchEvent(new Event("send_wsmessage", { message }))
console.trace("sending message", message)
this.ws.send(JSON.stringify({
room: this.room,
sender: this.name,
...message
}))
}
// ----------------- Message from websocket --------------
ChatRoom.prototype.on_ws_message = function (wsMessage) {
this.event.dispatchEvent(new Event("recv_wsmessage", { wsMessage }))
console.trace("receiving message", wsMessage)
switch (wsMessage.message.type) {
case MessageBootstrap:
this.on_bootstrap(wsMessage);
break;
case MessageDiscoverRequest:
this.on_discover_request(wsMessage)
break
case MessageDiscoverResponse:
this.on_discover_response(wsMessage)
break;
case MessageSessionOffer:
this.on_session_message(wsMessage)
break
case MessageSessionAnswer:
this.on_session_message(wsMessage)
break
case MessageICECandidate:
this.on_remote_ice_candidate(wsMessage)
}
}
ChatRoom.prototype.on_bootstrap = function ({ sender, iceServers }) {
this.event.dispatchEvent(new Event("bootstrap", { sender }))
this.name = sender;
this.iceServers = iceServers || this.iceServers || ["stun:nhz.jeffthecoder.xyz:3478", "stun:nhz.jeffthecoder.xyz:3479", "stun:nhz.jeffthecoder.xyz:13478"];
this.send_message({
message: {
type: MessageDiscoverRequest,
},
})
}
ChatRoom.prototype.on_discover_request = function ({ sender }) {
this.event.dispatchEvent(new Event("discover_request", { sender }))
this.i_am_polite.set(sender, false)
this.setup_peerconnection(sender)
this.send_message({
message: {
type: MessageDiscoverResponse,
},
receiver: sender,
})
}
ChatRoom.prototype.on_discover_response = async function ({ sender }) {
this.i_am_polite.set(sender, true)
let { peer } = this.setup_peerconnection(sender);
let channel = peer.createDataChannel("chat");
this.setup_chat_datachannel(sender, channel)
this.channels.set(sender, channel)
}
ChatRoom.prototype.on_session_message = async function ({ sender, message }) {
let description = JSON.parse(message.sdp)
let polite = this.i_am_polite.get(sender);
let makingOffer = this.i_am_offering.get(sender) || false;
let peer = this.peers.get(sender)
console.log("on session message", { message, description, polite, makingOffer })
const offerCollision =
description.type === "offer" &&
(makingOffer || peer.signalingState !== "stable");
ignoreOffer = !polite && offerCollision;
if (ignoreOffer) {
return;
}
await peer.setRemoteDescription(description);
if (description.type === "offer") {
await peer.setLocalDescription();
this.event.dispatchEvent(new Event("offer", { sender, description }))
this.send_message({
message: {
type: MessageSessionAnswer,
sdp: JSON.stringify(peer.localDescription),
sender: this.name,
kind: 3,
},
receiver: sender,
2024-05-29 18:49:28 +08:00
})
2024-06-04 11:29:30 +08:00
}
}
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
ChatRoom.prototype.on_remote_ice_candidate = function ({ sender, message }) {
let candidate = JSON.parse(message.candidate);
this.event.dispatchEvent(new Event("candidate", { sender, candidate }))
let peer = this.peers.get(sender)
console.log("remote_candidate", sender, message)
peer.addIceCandidate(candidate)
}
// ----------------- webrtc state & events -----------------
ChatRoom.prototype.on_negotiation_needed = async function (sender) {
let polite = this.i_am_polite.get(sender);
console.log("negotiationneeded", { sender, polite })
let peer = this.peers.get(sender);
this.i_am_offering.set(sender, true)
try {
await peer.setLocalDescription();
this.send_message({
message: {
type: MessageSessionOffer,
sdp: JSON.stringify(peer.localDescription),
sender: this.name,
kind: 3,
},
receiver: sender,
})
} finally {
this.i_am_offering.delete(sender)
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
}
2024-05-29 18:49:28 +08:00
2024-06-04 11:29:30 +08:00
ChatRoom.prototype.on_local_ice_candidate = function (sender, candidate) {
this.event.dispatchEvent(new Event("candidate", { candidate }))
this.send_message({
2024-05-29 18:49:28 +08:00
message: {
2024-06-04 11:29:30 +08:00
type: MessageICECandidate,
candidate: JSON.stringify(candidate),
sender: this.name,
2024-05-29 18:49:28 +08:00
kind: 3,
},
2024-06-04 11:29:30 +08:00
receiver: sender,
})
}
ChatRoom.prototype.on_chat_message = function (sender, data) {
this.event.dispatchEvent(new Event("chat_message", { sender, data }))
}
ChatRoom.prototype.on_channel_open = function (sender, channel) {
this.event.dispatchEvent(new Event("channel_open", { data }))
}
ChatRoom.prototype.on_channel_close = function (sender, channel) {
this.event.dispatchEvent(new Event("channel_close", { data }))
}
ChatRoom.prototype.on_datachannel = function (sender, channel) {
console.trace("on datachannel", sender, channel)
if (channel.label === "chat") {
this.channels.set(sender, channel);
this.setup_chat_datachannel(sender, channel)
} else if (channel.label.startsWith("file:")) {
this.setup_file_datachannel(sender, channel)
}
}
// ----------------------------------------------------------
function ChatUI(messages, peerOptions, inputbox, sendfile) {
this.messages = messages;
this.peerOptions = peerOptions;
this.inputbox = inputbox;
this.sendfile = sendfile;
this.receiver = null;
this.event = new EventTarget();
}
ChatUI.prototype.setup = function () {
this.sendfile.addEventListener("change", e => this.on_sendfile_changed(e))
this.peerOptions.addEventListener("change", e => this.on_peer_list_changed(e))
this.inputbox.addEventListener("keyup", e => this.on_key_up(e))
}
ChatUI.prototype.on_peer_list_changed = function () {
this.receiver = this.peerOptions.value;
if (this.peerOptions.value != "") {
this.sendfile.removeAttribute("disabled")
this.inputbox.removeAttribute("disabled")
} else {
this.sendfile.setAttribute("disabled", "")
this.inputbox.setAttribute("disabled", "")
}
}
ChatUI.prototype.on_key_up = function (e) {
if (!(e.key === 'Enter' || e.keyCode === 13)) {
return
}
if (!this.inputbox.value || this.inputbox.value.trim().length == 0) {
return
}
let value = this.inputbox.value;
let receiver = this.receiver;
this.event.dispatchEvent(new Event("send_message", {
value,
receiver,
2024-05-29 18:49:28 +08:00
}))
2024-06-04 11:29:30 +08:00
this.inputbox.value = ""
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
ChatUI.prototype.on_sendfile_changed = function () {
let file = sendfile.files[0]
let receiver = this.receiver;
sendfile.value = ""
this.display(`你给 ${this.receiver} 传了一个文件: ${file.name}。传输中...`)
// let reader = new FileReader()
// let dc = peer.createDataChannel(`file:${file.name}`)
// reader.addEventListener("load", (ev) => {
// dc.send(reader.result)
// dc.close()
// })
// dc.addEventListener("open", () => {
// reader.readAsArrayBuffer(file)
// })
this.event.dispatchEvent(new Event("send_file", {
file,
receiver,
}))
}
ChatUI.prototype.add_peer = function (peerName) {
let newNode = document.createElement("option")
newNode.id = `peer-${peerName}`
newNode.setAttribute("value", peerName)
newNode.innerHTML = peerName;
this.peerOptions.appendChild(newNode)
this.on_peer_list_changed()
}
ChatUI.prototype.remove_peer = function (peerName) {
let el = document.getElementById(`peer-${peerName}`);
if (el) el.remove()
this.on_peer_list_changed()
}
ChatUI.prototype.display = function (...message) {
let message_list = this.messages;
let scrollToBottom = Math.abs(message_list.scrollHeight - message_list.scrollTop - message_list.clientHeight) < 1;
message_list.appendChild(createNode("div",
`${new Date().toTimeString()}: `,
...message
))
if (scrollToBottom) {
message_list.scrollTo({
top: message_list.scrollHeight
})
}
}
let message_list = document.getElementById("message-list")
let peers = document.getElementById("peers")
let inputbox = document.getElementById("message-input")
let sendfile = document.getElementById("sendfile")
const createNode = (tag, ...children) => {
let node = document.createElement(tag)
children.forEach(child => {
if (typeof child === "string") {
let inner = document.createElement("span")
inner.innerHTML = child
node.appendChild(inner)
} else if (child instanceof HTMLElement) {
node.appendChild(child)
} else {
console.log("child is not a node: ", child, typeof child)
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
})
return node
2024-05-29 18:49:28 +08:00
}
2024-06-04 11:29:30 +08:00
let search = new URLSearchParams(location.search);
2024-06-04 11:54:13 +08:00
let room = new ChatRoom(search.get("room") || "public", search.get("name") || "", {})
2024-06-04 11:29:30 +08:00
let ui = new ChatUI(message_list, peers, inputbox, sendfile);
ui.setup()
room.event.addEventListener("kickoff", () => {
ui.display("服务器连上啦。你等等,给你起个名字...")
})
2024-06-04 11:58:55 +08:00
room.event.addEventListener("bootstrap", ({sender}) => {
ui.display(`你就叫 ${sender} 吧!`)
})
2024-06-04 11:29:30 +08:00
room.event.addEventListener("chat-channel-open", ({ channel, sender }) => {
ui.display(`${sender} 连上了`)
ui.add_peer(sender)
})
room.event.addEventListener("chat-channel-closed", ({ channel, sender }) => {
ui.display(`${sender} 断开了`)
ui.remove_peer(sender)
})
room.event.addEventListener("chat_message", ({ sender, data }) => {
ui.display(`${sender} -> 你: ${data}`)
})
room.event.addEventListener("file_received", ({ filename, buffer }) => {
var link = document.createElement('a');
link.href = window.URL.createObjectURL(buffer);
link.download = filename;
link.innerHTML = filename;
ui.display(
"文件:",
link,
)
})
ui.event.addEventListener("send_message", ({ receiver, value }) => {
let channel = room.channels.get(receiver)
if (!channel) {
ui.display(`${receiver} 没准备好,这条消息发不出去: ${value}`)
return
}
ui.display(`你 -> ${receiver}: ${value}`)
channel.send(value)
})
ui.event.addEventListener("send_file", ({file,receiver}) => {
let peer = room.peers.get(receiver);
let reader = new FileReader()
let dc = peer.createDataChannel(`file:${file.name}`)
reader.addEventListener("load", (ev) => {
dc.send(reader.result)
dc.close()
})
dc.addEventListener("open", () => {
reader.readAsArrayBuffer(file)
})
})
let display = ui.display.bind(ui);
room.reconnect()
})()
2024-05-29 18:49:28 +08:00
</script>
{% endblock %}