gpg: Signature made Fri 04 Sep 2026 00:58:11 IST

gpg:                using EDDSA key 25BB975698147F3F76719E0C60B5F0386E3DDB7Egpg:                using EDDSA key 25BB975698147F3F76719E0C60B5F0386E3DDB7E
gpg: Good signature from "ari melody <ari@arimelody.space>" [ultimate]
gpg:                 aka "ari melody <ari@arimelody.me>" [ultimate]
initial design for new chat widget
This commit is contained in:
ari melody 2026-09-04 00:58:11 +01:00
parent 4d0efce2e0
commit eab3c09b8b
Signed by: ari
GPG key ID: 60B5F0386E3DDB7E
8 changed files with 359 additions and 17 deletions

View file

@ -213,15 +213,16 @@ func (srv *Service) handleNotification(payload *api.EventSubPayload) error {
if event.MessageType == api.MESSAGE_CHANNEL_POINTS_HIGHLIGHTED {
modifier = CHAT_MODIFIER_HIGHLIGHT
log.Printf("Highlighted from %s: %s", event.ChatterLogin, event.Message.Text)
log.Printf("Highlighted from %s: %s", event.ChatterName, event.Message.Text)
} else {
log.Printf("%s: %s", event.ChatterLogin, event.Message.Text)
log.Printf("%s: %s", event.ChatterName, event.Message.Text)
}
srv.ChatC <- &chatMessage{
ID: event.MessageID,
Username: event.ChatterLogin,
Username: event.ChatterName,
Colour: event.Color,
Badges: event.Badges,
Text: event.Message.Text,
Fragments: event.Message.Fragments,
Modifier: modifier,

View file

@ -4,7 +4,6 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Twitch Chat</title>
<link rel="stylesheet" href="/public/fonts/inter/inter.css">
<link rel="stylesheet" href="/twitch/public/chat.css">
</head>
<body>

View file

@ -0,0 +1,95 @@
/*
@font-face {
font-family: 'Darumadrop One';
src: url('/public/fonts/darumadrop-one/DarumadropOne-Regular.ttf') format('truetype');
}
*/
@import url('https://fonts.googleapis.com/css2?family=Darumadrop+One&display=swap');
body {
min-height: 100vh;
margin: 0;
padding: 0;
background: hsl(0 0 50%);
}
#chat {
position: relative;
top: .33em;
min-height: calc(100vh - .66em);
max-width: 600px;
margin: .33em;
display: flex;
flex-direction: column;
justify-content: end;
gap: .25em;
font-family: "Darumadrop One", sans-serif;
font-size: 32px;
line-height: 1em;
word-wrap: break-word;
&.end {
text-align: end;
align-items: end;
}
}
.message {
width: 100%;
--colour: hsl(0 0 75%);
color: hsl(0 0 100%);
text-shadow: .05em .05em 0 hsl(0 0 0 / 75%);
&.system {
width: 420px;
margin: 0 1em;
display: flex;
flex-direction: row;
--colour: hsl(0 0 100%);
color: var(--colour);
&.follow { --hue: 128; }
&.subscribe { --hue: 284; }
&.cheer { --hue: 222; }
&.raid { --hue: 28; }
&.poll { --hue: 269; }
&.shoutout { --hue: 104; }
&.point-redeem { --hue: 338; }
&.follow, &.subscribe, &.cheer, &.raid, &.poll, &.shoutout, &.point-redeem {
--colour: oklch(80% 80% var(--hue) / 100%);
}
.icon {
height: .5em;
margin-top: .3em;
margin-right: .2em;
transform: translateY(.15em);
aspect-ratio: 1;
background-color: var(--colour);
border-radius: 100%;
box-shadow: .05em .05em 0 hsl(0 0 0 / 75%);
}
}
p { margin: 0; }
.header {
color: var(--colour);
}
.content {
z-index: 1;
position: relative;
}
}

View file

@ -1,4 +1,98 @@
const chatContainer = document.getElementById("chat");
const end = new URLSearchParams(location.search).get("end") !== null;
class ChatMessage {
get username() { return this._username; }
set username(username) {
this._username = username;
this._usernameEl.innerText = username;
}
get content() { return this._content; }
set content(content) {
this._content = content;
this._contentEl.innerText = content;
}
get colour() { return this._colour; }
set colour(colour) {
this._colour = colour;
if (colour != undefined)
this._container.style.setProperty("--colour", colour);
}
get element() { return this._container; }
destroy() {
this._container.remove();
}
constructor(message) {
this._headerEl = document.createElement('div');
this._headerEl.className = "header";
// TODO: assemble badges in header, before username
this._usernameEl = document.createElement('p');
this._usernameEl.className = "username";
this.username = message.username;
this._headerEl.appendChild(this._usernameEl);
this._contentEl = document.createElement('p');
this._contentEl.className = "content";
// TODO: build message content from fragments
// TODO: 7TV emotes (stretch goal: FFZ emotes)
this.content = message.text;
this._container = document.createElement("div");
this._container.className = "message";
this._container.dataset.id = message.id;
this._container.dataset.username = message.username;
// TODO: generate and cache random colour for users that do not have one
this.colour = message.colour;
this._container.appendChild(this._headerEl);
this._container.appendChild(this._contentEl);
}
}
class SystemMessage {
get text() { return this._text; }
set text(text) {
this._text = text;
this._textEl.innerText = text;
}
get type() { return this._type; }
set type(type) {
this._type = type;
this._container.className = ["message", "system", type].join(" ").trim();
}
get element() { return this._container; }
destroy() {
this._container.remove();
}
constructor(text, type) {
// TODO: actual icon designs for system messages
this._iconEl = document.createElement('div');
this._iconEl.className = "icon";
this._textEl = document.createElement('p');
this._textEl.className = "text";
this.text = text;
this._container = document.createElement("div");
this.type = type;
this._container.appendChild(this._iconEl);
this._container.appendChild(this._textEl);
}
}
async function sync() {
let eventSource;
@ -20,10 +114,16 @@ async function sync() {
});
eventSource.addEventListener("chat", event => {
console.log(`[${chat}] ${event.data}`);
const messageEl = document.createElement("p");
messageEl.innerText = event.data;
chatContainer.appendChild(messageEl);
handleChatMessage(JSON.parse(event.data));
});
eventSource.addEventListener("system", event => {
handleSystemMessage(JSON.parse(event.data));
});
eventSource.addEventListener("delete", event => {
const data = JSON.parse(event.data);
const username = data.username;
const message_id = data.id;
handleDeleteMessage(username, message_id);
});
eventSource.addEventListener("open", () => {
@ -31,8 +131,60 @@ async function sync() {
});
}
function twitchEmoteURL(emoteID) {
return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/1.0#e=0`
function handleChatMessage(data) {
console.log(`[CHAT] ${data.username}: ${data.text}`);
const chatMessage = new ChatMessage(data);
addMessageAndTruncateChat(chatMessage.element);
}
function handleSystemMessage(data) {
if (data.type)
console.log(`[SYSTEM] [${data.type}] ${data.text}`);
else
console.log(`[SYSTEM] ${data.text}`);
const systemMessage = new SystemMessage(data.text, data.type || "");
addMessageAndTruncateChat(systemMessage.element);
}
function handleDeleteMessage(username, message_id) {
if (username == "*" && message_id == "*") {
chatContainer.innerHTML = "";
return;
}
if (username.length > 0 && message_id == "*") {
chatContainer.querySelectorAll(`.message[data-username=${username}]`)
.forEach(msg => {
msg.remove();
});
return;
}
if (username.length == 0 && message_id.length > 0) {
const msg = chatContainer.querySelector(`.message[data-id=${message_id}]`);
if (msg) msg.remove();
}
}
const CHAT_LIMIT = 50;
function addMessageAndTruncateChat(element) {
chatContainer.appendChild(element);
window.scrollTo(0, document.body.scrollHeight);
if (chatContainer.childElementCount <= CHAT_LIMIT) return;
chatContainer.children[0].remove();
}
function twitchEmoteURL(emoteID) {
return `https://static-cdn.jtvnw.net/emoticons/v2/${emoteID}/default/dark/4.0#e=0`
}
function twitchBadgeURL(badgeID) {
return `https://static-cdn.jtvnw.net/badges/v1/${badgeID}/3`
}
if (end)
chatContainer.classList.add("end")
sync();

View file

@ -42,21 +42,22 @@ type (
ChatMessageModifier string
chatMessage struct {
ID string `json:"id"`
Username string `json:"username,omitempty"`
Username string `json:"username"`
Colour string `json:"colour,omitempty"`
Text string `json:"text,omitempty"`
Fragments []api.ChatMessageFragment `json:"fragments,omitempty"`
Text string `json:"text"`
Badges []api.ChatBadge `json:"badges"`
Fragments []api.ChatMessageFragment `json:"fragments"`
Modifier ChatMessageModifier `json:"modifier,omitempty"`
AnnouncementColour string `json:"announcement_colour,omitempty"`
}
SystemChatMessageType string
systemChatMessage struct {
Text string `json:"text,omitempty"`
Text string `json:"text"`
Type SystemChatMessageType `json:"type,omitempty"`
}
deleteChatMessage struct {
Username string `json:"username,omitempty"`
MessageID string `json:"message_id,omitempty"`
Username string `json:"username"`
MessageID string `json:"message_id"`
}
ServiceOptions struct {
@ -319,6 +320,8 @@ func (srv *Service) BindRoutes(group *gin.RouterGroup) {
deleteChatUpdate := srv.DeleteChatBroadcast.Subscribe()
defer srv.DeleteChatBroadcast.Cancel(deleteChatUpdate)
ctx.SSEvent("hello", "hello")
ticker := time.NewTicker(10 * time.Millisecond)
ctx.Stream(func(w io.Writer) bool {
select {