diff --git a/ChatTwo/Http/static/gfd.js b/ChatTwo/Http/static/gfd.js new file mode 100644 index 0000000..3a76802 --- /dev/null +++ b/ChatTwo/Http/static/gfd.js @@ -0,0 +1,120 @@ +// from kizer, gfd icons +async function AddGfdStylesheet(gfdPath, texPath) { + const texPromise = LoadTexAsBlob(texPath); + const gfdPromise = LoadGfd(gfdPath); + const texUrl = URL.createObjectURL(await texPromise); + const gfd = await gfdPromise; + + const stylesheets = []; + for (const entry of gfd) { + if (entry.width * entry.height <= 0) + continue; + + width = entry.width; + height = entry.height; + + if (entry.redirect !== 0) { + stylesheets[entry.redirect][0].push(entry.id); + continue; + } + + stylesheets[entry.id] = [ + [entry.id], + [ + `background-position: -${entry.left}px -${entry.top}px`, + `background-image: url('${texUrl}')`, + `width: ${entry.width}px`, + `height: ${entry.height}px` + ].join(';'), + [ + `background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`, + `background-image: url('${texUrl}')`, + `width: ${entry.width * 2}px`, + `height: ${entry.height * 2}px` + ].join(';'), + entry.width + ]; + } + + let stylesheet = ''; + for (const entry of stylesheets) { + if (!entry) + continue; + + stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`; + stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`; + stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry[3]}px;}`; + stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry[3] * 2}px;}`; + } + + const styleNode = document.createElement('style'); + styleNode.appendChild(document.createTextNode(stylesheet)); + document.head.appendChild(styleNode); +} + +async function LoadTexAsBlob(path) { + const tex = ParseTex(await (await fetch(path)).arrayBuffer()); + if (tex.format !== 0x1450) // B8G8R8A8 + throw 'Not supported'; + + const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4); + for (let i = 0; i < dataArray.length; i += 4) { + const t = dataArray[i]; + dataArray[i] = dataArray[i + 2]; + dataArray[i + 2] = t; + } + const imageData = new ImageData(dataArray, tex.width, tex.height); + const bitmap = await createImageBitmap(imageData); + + const canvas = new OffscreenCanvas(tex.width, tex.height); + canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap); + return await canvas.convertToBlob(); +} + +async function LoadGfd(path) { + const buffer = new DataView(await (await fetch(path)).arrayBuffer()); + const count = buffer.getInt32(8, true); + const entries = new Array(count); + for (let i = 0; i < count; i++) { + const offset = 0x10 + (i * 0x10); + entries[i] = { + id: buffer.getInt16(offset, true), + left: buffer.getInt16(offset + 2, true), + top: buffer.getInt16(offset + 4, true), + width: buffer.getInt16(offset + 6, true), + height: buffer.getInt16(offset + 8, true), + unk0A: buffer.getInt16(offset + 10, true), + redirect: buffer.getInt16(offset + 12, true), + unk0E: buffer.getInt16(offset + 14, true), + }; + } + + return entries; +} + +function ParseTex(arrayBuffer) { + const buffer = new DataView(arrayBuffer); + const type = buffer.getInt32(0, true); + const format = buffer.getInt32(4, true); + const width = buffer.getInt16(8, true); + const height = buffer.getInt16(10, true); + const depth = buffer.getInt16(12, true); + const mipsAndFlag = buffer.getInt8(14, true); + const arraySize = buffer.getInt8(15, true); + const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)]; + const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)]; + return { + buffer: arrayBuffer, + type, + format, + width, + height, + depth, + mipsAndFlag, + arraySize, + lodOffsets, + offsetToSurface, + }; +} + +AddGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex'); diff --git a/ChatTwo/Http/static/start.css b/ChatTwo/Http/static/start.css index 3ed631c..28a1fee 100644 --- a/ChatTwo/Http/static/start.css +++ b/ChatTwo/Http/static/start.css @@ -120,17 +120,6 @@ section#messages { height: 100%; overflow-y: scroll; scrollbar-color: var(--fg-scrollbar) var(--bg); - - &.more-messages::before { - content: ''; - position: absolute; - bottom: -20px; - left: 100px; - width: calc(100% - 200px); - height: 200px; - background-image: radial-gradient(50% 20% at 50% 100%, #60a0ff40, transparent); - pointer-events: none; - } } ol { @@ -152,6 +141,22 @@ section#messages { white-space: pre-wrap; } } + + #more-messages-indicator { + position: absolute; + display: none; + right: 30px; + bottom: 0; + pointer-events: none; + + svg { + filter: drop-shadow(0 0 5px #60a0ff) drop-shadow(0 0 15px #60a0ff); + } + } + + &.more-messages #more-messages-indicator { + display: block; + } } #timestamp-width-probe { @@ -259,9 +264,14 @@ section#input { position: absolute; top: -1.2em; left: 23px; + max-width: 100%; + overflow: hidden; font-size: 1.1rem; font-weight: 550; + white-space: nowrap; + text-overflow: ellipsis; pointer-events: none; + } } } @@ -297,6 +307,7 @@ section#input { top: 0; left: 0; width: 2rem; + height: 2rem; transform: translateY(-50%); pointer-events: none; } @@ -308,11 +319,27 @@ section#input { padding: 0; } - body > main { + body > main.chat { border-radius: 0; box-shadow: none; } + section#messages { + font-size: 0.9rem; + + li { + align-items: baseline; + + .timestamp { + font-size: 0.8rem; + } + } + } + + #timestamp-width-probe { + font-size: 0.8rem; + } + section#input { button { max-width: 0; @@ -325,5 +352,19 @@ section#input { left: 50%; transform: translateX(-50%) translateY(-50%); } + + .input-container #channel-hint { + font-size: 0.9rem; + } + } + + .gfd-icon { zoom: 0.65; } + .emote-icon { + width: 1.5rem; + + img { + width: 1.5rem; + height: 1.5rem; + } } } diff --git a/ChatTwo/Http/static/start.js b/ChatTwo/Http/static/start.js index 3655bae..712a7e2 100644 --- a/ChatTwo/Http/static/start.js +++ b/ChatTwo/Http/static/start.js @@ -1,283 +1,173 @@ -// websocket connection -class SSEConnection { +class ChatTwoWeb { constructor() { - this.socket = new EventSource('/sse'); - - this.socket.addEventListener('close', () => { - console.log('Closing SSE connection.'); - this.socket.close(); - }); - - this.socket.addEventListener('switch-channel', (event) => { - updateChannelHint(JSON.parse(event.data).channel); - }); - - // New messages that are able to be directly processed - this.socket.addEventListener('new-message', (event) => { - for (let message of JSON.parse(event.data).messages) { - addMessage(message); - } - }); - - // New messages, that require a clean message list before processing - this.socket.addEventListener('bulk-messages', (event) => { - clearMessages(); - for (let message of JSON.parse(event.data).messages) { - addMessage(message); - } - }); - - this.socket.addEventListener('channel-list', (event) => { - updateChannelOptions(JSON.parse(event.data).channels); - }); + this.setupDOMElements(); + this.setupSSEConnection(); } - send(message) { - this.socket.send(message); - } -} -const sse = new SSEConnection(); + setupDOMElements() { + this.elements = { + channelHint: document.getElementById('channel-hint'), + channelSelect: document.getElementById('channel-select'), + messagesContainer: document.querySelector('#messages > .scroll-container'), + messagesList: document.getElementById('messages-list'), -// channel switcher -function updateChannelHint(label) { - document.getElementById('channel-hint').innerHTML = label; -} + timestampWidthProbe: document.getElementById('timestamp-width-probe'), -document.getElementById('channel-select').addEventListener('change', (event) => { - (async () => { - const rawResponse = await fetch('/channel', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ channel: event.target.value }) - }); - const content = await rawResponse.json(); - - // TODO use the response - console.log(content); - })(); -}); - -function updateChannelOptions(channels) { - let select = document.getElementById('channel-select'); - - // clear existing channels - select.innerHTML = ''; - - for (const [ name, channel ] of Object.entries(channels)) { - let option = document.createElement('option'); - option.text = name; - option.value = channel; - select.appendChild(option) - } -} - - -// functions for handling the message list -function messagesContainerIsScrolledToBottom() { - const messagesContainer = document.querySelector('#messages > .scroll-container'); - return messagesContainer.scrollTop >= messagesContainer.scrollHeight - messagesContainer.offsetHeight; -} - -// calculate timestamp width -// to ensure that all timestamps have the same width. some typefaces have the same width across -// all number glyphs, others do not. the solution below is very rudimentary; at the very least, -// delaying it to account for font loading might make sense. perhaps there's an even better way? -let maxTimestampWidth = 0; -function calculateTimestampWidth(timestamp) { - const widthProbe = document.getElementById('timestamp-width-probe'); - widthProbe.innerText = timestamp; - - if (widthProbe.clientWidth > maxTimestampWidth) { - maxTimestampWidth = widthProbe.clientWidth; - document.body.style.setProperty('--timestamp-width', (Math.ceil(maxTimestampWidth) + 1) + 'px'); - } -} - -function addMessage(messageData) { - const scrolledToBottom = messagesContainerIsScrolledToBottom(); - calculateTimestampWidth(messageData.timestamp); - - const liMessage = document.createElement('li'); - const spanTimestamp = document.createElement('span'); - spanTimestamp.classList.add('timestamp'); - const spanMessage = document.createElement('span'); - spanMessage.classList.add('message'); - - spanTimestamp.innerText = messageData.timestamp; - spanMessage.innerHTML = messageData.messageHTML; - - liMessage.appendChild(spanTimestamp); - liMessage.appendChild(spanMessage); - document.getElementById('messages-list').appendChild(liMessage); - - if (scrolledToBottom) { - liMessage.scrollIntoView(); - } -} - -function clearMessages() { - document.getElementById('messages-list').innerHTML = ''; -} - -// add indicator signaling more messages -document.querySelector('#messages > .scroll-container').addEventListener('scroll', () => { - const messagesContainer = document.querySelector('#messages > .scroll-container'); - if (!messagesContainerIsScrolledToBottom()) { - messagesContainer.classList.add('more-messages'); - } else { - messagesContainer.classList.remove('more-messages'); - } -}); - - -// handle message sending -document.querySelector('#input > form').addEventListener('submit', (event) => { - event.preventDefault(); - - const chatInput = document.getElementById('chat-input'); - const message = chatInput.value; - - (async () => { - const rawResponse = await fetch('/send', { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ message: message }) - }); - const content = await rawResponse.json(); - - // TODO use the response - console.log(content); - })(); - - chatInput.value = ''; -}); - - -// from kizer, gfd icons -async function AddGfdStylesheet(gfdPath, texPath) { - const texPromise = LoadTexAsBlob(texPath); - const gfdPromise = LoadGfd(gfdPath); - const texUrl = URL.createObjectURL(await texPromise); - const gfd = await gfdPromise; - - const stylesheets = []; - for (const entry of gfd) { - if (entry.width * entry.height <= 0) - continue; - - width = entry.width; - height = entry.height; - - if (entry.redirect !== 0) { - stylesheets[entry.redirect][0].push(entry.id); - continue; - } - - stylesheets[entry.id] = [ - [entry.id], - [ - `background-position: -${entry.left}px -${entry.top}px`, - `background-image: url('${texUrl}')`, - `width: ${entry.width}px`, - `height: ${entry.height}px` - ].join(';'), - [ - `background-position: -${entry.left * 2}px -${entry.top * 2 + 341}px`, - `background-image: url('${texUrl}')`, - `width: ${entry.width * 2}px`, - `height: ${entry.height * 2}px` - ].join(';'), - entry.width - ]; - } - - let stylesheet = ''; - for (const entry of stylesheets) { - if (!entry) - continue; - - stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}::before`).join(', ')}{${entry[1]};}`; - stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}::before`).join(', ')}{${entry[2]};}`; - stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-${x}`).join(', ')}{width:${entry[3]}px;}`; - stylesheet += `\n${entry[0].map(x => `.gfd-icon.gfd-icon-hq-${x}`).join(', ')}{width:${entry[3] * 2}px;}`; - } - - const styleNode = document.createElement('style'); - styleNode.appendChild(document.createTextNode(stylesheet)); - document.head.appendChild(styleNode); -} - -async function LoadTexAsBlob(path) { - const tex = ParseTex(await (await fetch(path)).arrayBuffer()); - if (tex.format !== 0x1450) // B8G8R8A8 - throw 'Not supported'; - - const dataArray = new Uint8ClampedArray(tex.buffer, tex.offsetToSurface[0], tex.width * tex.height * 4); - for (let i = 0; i < dataArray.length; i += 4) { - const t = dataArray[i]; - dataArray[i] = dataArray[i + 2]; - dataArray[i + 2] = t; - } - const imageData = new ImageData(dataArray, tex.width, tex.height); - const bitmap = await createImageBitmap(imageData); - - const canvas = new OffscreenCanvas(tex.width, tex.height); - canvas.getContext('bitmaprenderer').transferFromImageBitmap(bitmap); - return await canvas.convertToBlob(); -} - -async function LoadGfd(path) { - const buffer = new DataView(await (await fetch(path)).arrayBuffer()); - const count = buffer.getInt32(8, true); - const entries = new Array(count); - for (let i = 0; i < count; i++) { - const offset = 0x10 + (i * 0x10); - entries[i] = { - id: buffer.getInt16(offset, true), - left: buffer.getInt16(offset + 2, true), - top: buffer.getInt16(offset + 4, true), - width: buffer.getInt16(offset + 6, true), - height: buffer.getInt16(offset + 8, true), - unk0A: buffer.getInt16(offset + 10, true), - redirect: buffer.getInt16(offset + 12, true), - unk0E: buffer.getInt16(offset + 14, true), + inputForm: document.querySelector('#input > form'), + chatInput: document.getElementById('chat-input') }; + this.maxTimestampWidth = 0; + this.scrolledToBottom = true; + + + // channel selector + this.elements.channelSelect.addEventListener('change', async (event) => { + const rawResponse = await fetch('/channel', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ channel: event.target.value }) + }); + const content = await rawResponse.json(); + // TODO: use the response + }); + + // add indicator signaling more messages below + this.elements.messagesContainer.addEventListener('scroll', (event) => { + if (!this.messagesAreScrolledToBottom()) { + event.target.parentElement?.classList.add('more-messages'); + } else { + event.target.parentElement?.classList.remove('more-messages'); + } + }); + + // adjust scroll when the window size changes; mostly for mobile (opening/closing the keyboard) + window.addEventListener('resize', () => { + if (this.scrolledToBottom) { + this.elements.messagesList.lastChild.scrollIntoView(); + } + }) + + // handle message sending + this.elements.inputForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const message = this.elements.chatInput.value; + + const rawResponse = await fetch('/send', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ message: message }) + }); + const content = await rawResponse.json(); + // TODO: use the response + + this.elements.chatInput.value = ''; + }); } - return entries; + updateChannelHint(labelHTML) { + this.elements.channelHint.innerHTML = labelHTML; + } + + updateChannels(channels) { + this.elements.channelSelect.innerHTML = ''; + for (const [ label, channel ] of Object.entries(channels)) { + const option = document.createElement('option'); + option.value = channel; + option.innerText = label; + this.elements.channelSelect.appendChild(option); + } + } + + // calculate timestamp width + // to ensure that all timestamps have the same width. some typefaces have the same width across + // all number glyphs, others do not. then there's AM/PM vs 24 hour, and so on + calculateTimestampWidth(timestamp) { + this.elements.timestampWidthProbe.innerText = timestamp; + if (this.elements.timestampWidthProbe.clientWidth > this.maxTimestampWidth) { + this.maxTimestampWidth = this.elements.timestampWidthProbe.clientWidth; + document.body.style.setProperty('--timestamp-width', (Math.ceil(this.maxTimestampWidth) + 1) + 'px'); + } + } + + messagesAreScrolledToBottom() { + this.scrolledToBottom = + this.elements.messagesContainer.scrollTop >= this.elements.messagesContainer.scrollHeight - this.elements.messagesContainer.offsetHeight; + return this.scrolledToBottom; + } + + addMessage(messageData) { + const scrolledToBottom = this.messagesAreScrolledToBottom(); + this.calculateTimestampWidth(messageData.timestamp); + + const liMessage = document.createElement('li'); + const spanTimestamp = document.createElement('span'); + spanTimestamp.classList.add('timestamp'); + const spanMessage = document.createElement('span'); + spanMessage.classList.add('message'); + + spanTimestamp.innerText = messageData.timestamp; + spanMessage.innerHTML = messageData.messageHTML; + + liMessage.appendChild(spanTimestamp); + liMessage.appendChild(spanMessage); + this.elements.messagesList.appendChild(liMessage); + + if (scrolledToBottom) { + liMessage.scrollIntoView(); + } + // // just to update this.scrolledToBottom + // this.messagesAreScrolledToBottom(); + } + + clearAllMessages() { + this.elements.messagesList.innerHTML = ''; + } + + setupSSEConnection() { + this.sse = new EventSource('/sse'); + + this.sse.addEventListener('close', () => { + console.log('Closing SSE connection.'); + this.sse.close(); + }); + + this.sse.addEventListener('switch-channel', (event) => { + // TODO: error handling + this.updateChannelHint(JSON.parse(event.data).channel); + }); + + // new messages to be appended to the message list + this.sse.addEventListener('new-message', (event) => { + // TODO: error handling + for (const message of JSON.parse(event.data).messages) { + this.addMessage(message); + } + }); + + // a bulk of new messages, with a clear of the message list beforehand + this.sse.addEventListener('bulk-messages', (event) => { + this.clearAllMessages(); + // TODO: error handling + for (const message of JSON.parse(event.data).messages) { + this.addMessage(message); + } + }); + + this.sse.addEventListener('channel-list', (event) => { + // TODO: error handling + this.updateChannels(JSON.parse(event.data).channels); + }); + } } -function ParseTex(arrayBuffer) { - const buffer = new DataView(arrayBuffer); - const type = buffer.getInt32(0, true); - const format = buffer.getInt32(4, true); - const width = buffer.getInt16(8, true); - const height = buffer.getInt16(10, true); - const depth = buffer.getInt16(12, true); - const mipsAndFlag = buffer.getInt8(14, true); - const arraySize = buffer.getInt8(15, true); - const lodOffsets = [buffer.getInt32(16, true), buffer.getInt32(20, true), buffer.getInt32(24, true)]; - const offsetToSurface = [buffer.getInt32(28, true), buffer.getInt32(32, true), buffer.getInt32(36, true), buffer.getInt32(40, true), buffer.getInt32(44, true), buffer.getInt32(48, true), buffer.getInt32(52, true), buffer.getInt32(56, true), buffer.getInt32(60, true), buffer.getInt32(64, true), buffer.getInt32(68, true), buffer.getInt32(72, true), buffer.getInt32(76, true)]; - return { - buffer: arrayBuffer, - type, - format, - width, - height, - depth, - mipsAndFlag, - arraySize, - lodOffsets, - offsetToSurface, - }; -} - -AddGfdStylesheet('/files/gfdata.gfd', '/files/fonticon_ps5.tex'); +window.addEventListener('load', function() { + this._app = new ChatTwoWeb(); +}); diff --git a/ChatTwo/Http/templates/chat.html b/ChatTwo/Http/templates/chat.html index 7184101..ff17f89 100644 --- a/ChatTwo/Http/templates/chat.html +++ b/ChatTwo/Http/templates/chat.html @@ -7,33 +7,39 @@ +
-