not so initial commit

This commit is contained in:
2026-01-11 03:50:03 +01:00
commit a2016e2b0e
41 changed files with 54638 additions and 0 deletions

18
src/components/App.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { RenderMessages } from "@components/Message";
import { useState, type ReactElement } from "react";
import { Chatbar } from "@components/Chatbar.tsx";
import { ChannelBar } from "@components/ChannelBar.tsx";
import { ServerBar } from "@components/ServerBar.tsx";
export function App(): ReactElement {
const [serverName, setServerName] = useState("undefined");
return (
<div>
<ServerBar setServerName={setServerName} currentServer={serverName} />
<ChannelBar servername={serverName} />
<RenderMessages />
<Chatbar />
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Mentions, Pill } from "@components/PillMentions";
import { ReactElement } from "react";
export function ChannelBar({servername}: {servername: string}): ReactElement {
return (
<div className="channel-bar">
<h2>{servername}</h2>
<Channel channelName="meow" mentions={0} unread={true} muted={false} />
</div>
)
}
function Channel({channelName, mentions, unread, muted}: {channelName: string, mentions: number, unread: boolean, muted: boolean}): ReactElement {
return (
<div className="channel">
<Pill unread={unread} muted={muted} open={false} hover={false} />
<span className="channelname">{channelName}</span>
<Mentions count={mentions} />
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { user } from "@localtypes";
import { sendChatMessage, token, self, getUsers, subscribeUsers } from "@ws";
import { useRef, useState, useSyncExternalStore, type ReactElement } from "react";
export function Chatbar(): ReactElement {
const [message, setMessage] = useState("");
const users: user[] = useSyncExternalStore(
subscribeUsers,
getUsers
);
const editorRef = useRef<HTMLDivElement | null>(null);
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (message.trim() !== "") {
const msg = replaceMentions(message.trim());
sendChatMessage({id: Date.now(), author: self, content: msg, timestamp: Date.now(), token: token!, hasSent: false});
console.log("Sent message:", msg);
setMessage("");
if (editorRef.current) {
editorRef.current.innerText = "";
}
}
}
};
function replaceMentions(
text: string,
): string {
// Build a quick lookup map: username -> id
const userMap = new Map(
users.map(u => [u.username, u.id])
);
console.log(userMap);
// Match @[username] (username = anything except ])
return text.replace(/@(\S+)/g, (match, username) => {
const userId = userMap.get(username);
console.log(userId);
return userId ? `<@${userId}>` : match;
});
}
function handleChange(event: React.FormEvent<HTMLDivElement>) {
if (editorRef.current) {
setMessage(editorRef.current.innerText.trim());
if (editorRef.current.innerText === "\n") {
editorRef.current.innerHTML = "";
}
}
};
return (
<div className="textbox">
<div
ref={editorRef}
className="editor"
contentEditable
onKeyDown={handleKeyDown}
onInput={handleChange}
data-text="Type here..."
></div>
</div>
);
}

150
src/components/Message.tsx Normal file
View File

@@ -0,0 +1,150 @@
import { RefObject, useEffect, useRef, useSyncExternalStore, type ReactElement } from "react";
import { user, message } from "@localtypes";
import { subscribeUsers, getUsers, subscribeMessages, getMessages, self } from "@ws";
function formatUnixTimestamp(ts: number) {
const date = new Date(ts);
const now = new Date();
const isToday =
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear();
const HH = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
if (isToday) {
return `Today at ${HH}:${min}`;
}
const dd = String(date.getDate()).padStart(2, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0');
const yy = String(date.getFullYear())
return `${dd}/${mm}/${yy} ${HH}:${min}`;
}
export function RenderMessages(): ReactElement {
let currentAuthor: user | null = null;
const messages: (message | null)[] = useSyncExternalStore(
subscribeMessages,
getMessages
);
console.log(messages);
const bottomRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "instant" });
}, [messages.length]);
return (
<div className="messages">
{messages.map((message, index) => {
if (message) {
const nextMessage = messages[index + 1];
const nextAuthor = nextMessage ? nextMessage.author : null;
const isLastMessageFromAuthor = nextAuthor?.id !== message.author.id;
const inline = currentAuthor?.id === message.author.id;
if (!inline) {
currentAuthor = message.author;
}
return (
<Message
key={message.id}
author={message.author}
content={message.content}
inline={inline}
bottom={isLastMessageFromAuthor}
timestamp={message.timestamp}
hasSent={message.hasSent}
mention={message.content.includes(`<@${self.id}>`) || message.content.includes("@everyone") || message.content.includes("@here")}
/>
);
} else return ""
})}
<div ref={bottomRef} />
</div>
);
}
function Message({author, content, inline = false, bottom = false, timestamp = 0, hasSent, mention}: {author: user, content: string, inline?: boolean, bottom?: boolean, timestamp?: number, hasSent: boolean, mention: boolean}): ReactElement {
const users: user[] = useSyncExternalStore(
subscribeUsers,
getUsers
);
function formatContent(content: string): ReactElement[] {
const parts = content.split('\n'); // Split the content into parts by newlines
return parts.flatMap((part, index) => {
const elements: ReactElement[] = [];
let lastIndex = 0;
// Match all mentions in the part (e.g., <@123>)
const mentionRegex = /<@(\d+)>/g;
let match;
while ((match = mentionRegex.exec(part)) !== null) {
const [mentionTag, userId] = match;
const textBeforeMention = part.slice(lastIndex, match.index);
// Add the text before the mention as plain text
if (textBeforeMention) {
elements.push(<span key={`${index}-text-${lastIndex}`}>{textBeforeMention}</span>);
}
// Add the Mention component
const user = users.find((user) => user.id === parseInt(userId!)); // Use parseInt for ID comparison
elements.push(
<Mention
key={`${index}-mention-${userId}`}
name={user?.name}
username={user ? user.username : 'unknown-user'}
/>
);
lastIndex = mentionRegex.lastIndex; // Update the last index
}
// Add any remaining text after the last mention (if any)
const remainingText = part.slice(lastIndex);
if (remainingText) {
elements.push(<span key={`${index}-text-${lastIndex}`}>{remainingText}</span>);
}
// Add a <br /> between parts if its not the last part
if (index < parts.length - 1) {
elements.push(<br key={`br-${index}`} />);
}
return elements;
});
}
const formattedContent = formatContent(content);
return (
<div className={`message ${inline ? "inline" : ""} ${bottom ? "bottom" : ""} ${hasSent ? "" : "sending"} ${mention ? "mentionsyou" : ""}`}>
{!inline ? <img className="messagePfp" src={`/pfps/${author.pfp}.png`} width={48} height={48}></img> : ""}
{!inline ? <div className="messageAuthor"><span>{self.dev ? `${author.name} (${author.username}, ${author.id})` : author.name ?? author.username}</span> {author.dev ? <Badge text="DEV" /> : ""} <Timestamp ts={timestamp} /></div> : ""}
<div className="messageContent">{formattedContent}</div>
</div>
)
}
function Badge({text, color = "#a200ff"}: {text: string, color?: string}) {
return (
<div className="badge" style={{["--badge-color" as any]: color ?? "var(--brand-color)"}}>{text}</div>
)
}
function Timestamp({ts}: {ts: number}): ReactElement {
return (
<div className="messageTimestamp">{formatUnixTimestamp(ts)}</div>
)
}
function Mention({name, username}: { name?: string, username: string }): ReactElement {
return (
<div className="mention">@{name ?? username}</div>
)
}

View File

@@ -0,0 +1,19 @@
import { ReactElement } from "react";
export function Pill({unread, muted, open, hover}: {unread: boolean, muted: boolean, open: boolean, hover: boolean}): ReactElement {
return (
<div className="pillWrapper">
<div className={`pill${muted ? "" : open ? " open" : hover ? " hover" : unread ? " unread" : ""}`}></div>
</div>
)
}
export function Mentions({count}: {count: number}): ReactElement {
return (
<>
{count !== 0 ?<div className="mentions">
<div className="count">{count}</div>
</div> : ""}
</>
)
}

View File

@@ -0,0 +1,26 @@
import { Pill } from "@components/PillMentions.tsx";
import { guild } from "@localtypes";
import { ReactElement, useState } from "react";
let serverId = null;
export function ServerBar({setServerName, currentServer}: {setServerName: (name: string) => void, currentServer: string}): ReactElement {
return (
<div className="server-bar">
<Guild guild={{channels: [], guildId: 2, members: [], name: "meow", unread: true, muted: false}} open={"meow" === currentServer} setServerName={setServerName} />
<Guild guild={{channels: [], guildId: 2, members: [], name: "mrrow", unread: true, muted: false}} open={"mrrow" === currentServer} setServerName={setServerName} />
<Guild guild={{channels: [], guildId: 2, members: [], name: "mrrp", unread: true, muted: false}} open={"mrrp" === currentServer} setServerName={setServerName} />
</div>
)
}
function Guild({guild, open, setServerName}: {guild: guild, open: boolean, setServerName: (name: string) => void}): ReactElement {
const [hover, setHover] = useState(false);
return (
<div className="guild" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} onClick={() => setServerName(guild.name)}>
<Pill unread={guild.unread} muted={guild.muted} hover={hover} open={open} />
<div className="guildname">{guild.name}</div>
</div>
)
}

8
src/index.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { createRoot } from "react-dom/client";
import { App } from "@components/App";
import "./style.css";
const domNode = document.body as HTMLBodyElement;
const root = createRoot(domNode);
root.render(<App />);

193
src/style.css Normal file
View File

@@ -0,0 +1,193 @@
:root {
--brand-color: #a200ff;
}
* {
margin: 0;
}
html {
background-color: #333;
color: #fff;
font-family: Arial, Helvetica, sans-serif;
}
.mentions {
color: white;
background-color: #ff1717;
padding: 2px;
width: 15px;
height: 15px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 14px;
}
.channel-bar {
padding: 5px;
width: 200px;
position: absolute;
right: calc(100% - 250px);
}
.pillWrapper {
align-items: center;
contain: layout size;
display: flex;
height: 100%;
justify-content: flex-start;
overflow: hidden;
position: relative;
width: 8px
}
.pill {
width: 8px;
height: 0;
background-color: white;
border-radius: 0 4px 4px 0;
transition: height 0.2s ease, translate 0.3s ease, opacity 0.1s ease;
opacity: 0;
&.unread {
height: 8px;
opacity: 1;
}
&.hover {
height: 20px;
opacity: 1;
}
&.open {
height: 40px;
opacity: 1;
}
}
.channel {
.mentions {
position: relative;
bottom: 27px;
left: 85%;
}
.pillWrapper {
position: relative;
right: 2px;
top: 9px;
height: 17px;
}
.channelname {
position: relative;
top: -10px;
left: 10px;
}
}
.server-bar {
width: 50px;
position: absolute;
left: 0;
}
.guild {
width: 40px;
height: 40px;
font-size: 12px;
.guildname {
right: -10px;
top: -20px;
position: relative;
}
.pillWrapper {
position: relative;
right: 2px;
top: 9px;
}
}
.messages {
padding: 5px;
width: calc(100% - 250px);
height: calc(100% - 44px);
overflow: hidden scroll;
position: absolute;
right: 0;
}
.message {
margin-left: 50px;
margin-bottom: 2px;
&.bottom {
margin-bottom: 15px;
}
position: relative;
&.sending {
color: #777;
}
&.mentionsyou {
background-color: #b87d00;
}
.messagePfp {
left: -50px;
position: absolute;
border-radius: 100%;
cursor: pointer;
&:active {
top: 1px;
}
}
.messageAuthor {
span {
font-weight: 600;
cursor: pointer;
max-width: fit-content;
&:hover {
text-decoration: underline;
}
}
.messageTimestamp {
font-weight: initial;
color: #777;
font-size: 10px;
display: inline;
}
}
.messageContent {
top: -1px;
position: relative;
}
.mention {
display: inline;
background-color: #0099ff54;
padding: 2px;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: #0099ff79;
}
}
.badge {
background-color: var(--badge-color);
max-width: fit-content;
padding: 2px;
border-radius: 10%;
font-size: 8pt;
display: inline;
}
}
.textbox {
padding: 3px;
position: absolute;
bottom: 5px;
right: 5px;
width: calc(100% - 250px);
background-color: #222;
}
.editor:empty:before {
content: attr(data-text);
color: grey;
}

7
src/types/channel.ts Normal file
View File

@@ -0,0 +1,7 @@
import { id, message } from "@localtypes";
export interface channel {
channelId: id;
messages: message[];
}

10
src/types/guild.ts Normal file
View File

@@ -0,0 +1,10 @@
import { id, channel, user } from "@localtypes";
export interface guild {
guildId: id;
channels: channel[];
name: string;
members: user[];
unread: boolean;
muted: boolean;
}

5
src/types/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./url"
export * from "./user"
export * from "./message"
export * from "./guild"
export * from "./channel"

13
src/types/message.ts Normal file
View File

@@ -0,0 +1,13 @@
import { user } from "@localtypes";
export type id = number;
export interface message {
id: id;
author: user;
content: string;
timestamp: number;
editedTimestamp?: number
replyTo?: id;
hasSent: boolean;
}

1
src/types/url.ts Normal file
View File

@@ -0,0 +1 @@
export type url = string;

11
src/types/user.ts Normal file
View File

@@ -0,0 +1,11 @@
import { id, url } from "@localtypes";
export interface user {
name?: string,
username: string,
pfp: url,
id: id,
token: null,
password: null,
dev: boolean;
}

192
src/ws/index.ts Normal file
View File

@@ -0,0 +1,192 @@
import { message, user } from "@localtypes";
let passwd = "";
let token = localStorage.getItem("token") ?? "";
while (!(passwd && passwd !== "") && !token) {
passwd = prompt("input passwd")!;
}
export let ws = new WebSocket("ws://localhost/ws");
let messages: message[] = [];
let self: user;
let users: user[] = [];
let queue: (message | null)[] = new Array(6).fill(null);
const listenersMessages = new Set<() => void>();
const listenersUsers = new Set<() => void>();
let visibleMessages: (message | null)[] = [];
let dirty = true;
function rebuildVisibleMessages() {
if (!dirty) return;
visibleMessages = [...messages, ...queue];
dirty = false;
}
ws.onopen = () => {
console.log("opened websocket!");
if (!token) {
ws.send(JSON.stringify({
op: OpcodesServerbound.identify,
data: {
password: passwd,
}
}))
} else {
ws.send(JSON.stringify({
op: OpcodesServerbound.identify,
data: {
token,
}
}))
}
}
ws.onmessage = message => {
const msg = message.data;
parseMessages(msg);
}
const onclose = () => {
console.log("ws disconnected. reconnecting");
ws = new WebSocket("ws://localhost/ws");
ws.onopen = () => {
console.log("opened websocket!");
if (!token) {
ws.send(JSON.stringify({
op: OpcodesServerbound.identify,
data: {
password: passwd,
}
}))
} else {
ws.send(JSON.stringify({
op: OpcodesServerbound.identify,
data: {
token,
}
}))
}
}
ws.onmessage = message => {
const msg = message.data;
parseMessages(msg);
}
ws.onclose = onclose;
}
ws.onclose = onclose;
function notifyMessages() {
listenersMessages.forEach((l) => l());
}
function notifyUsers() {
listenersUsers.forEach((l) => l());
}
enum OpcodesClientbound {
authorizeLogin,
initMessages,
updateMessages,
registerSuccess,
error,
users,
keepAlive = 9,
}
enum OpcodesServerbound {
identify,
sendMessage,
register,
getUsers,
keepAlive = 9,
}
function parseMessages(message: string) {
const json: {op: number, data: any} = JSON.parse(message) as {op: number, data: any};
console.log(json);
switch (json.op) {
case OpcodesClientbound.authorizeLogin:
token = json.data.token;
localStorage.setItem("token", token!);
self = json.data;
self.token = null;
self.password = null;
ws.send(JSON.stringify({
op: OpcodesServerbound.getUsers,
data: null
}))
break;
case OpcodesClientbound.initMessages:
messages.push(...json.data);
dirty = true;
notifyMessages();
break;
case OpcodesClientbound.users:
users = json.data;
console.log(users);
notifyUsers();
break;
case OpcodesClientbound.keepAlive:
ws.send(JSON.stringify({
op: OpcodesServerbound.keepAlive,
data: null,
}))
break;
case OpcodesClientbound.updateMessages:
messages.push(json.data);
if (json.data.author.id === self.id) {
console.log(queue);
queue.reverse();
queue.pop();
queue.reverse();
queue.push(null as never);
console.log(queue);
}
dirty = true;
notifyMessages();
break;
default:
break;
}
}
export function getMessages(): (message | null)[] {
rebuildVisibleMessages();
return visibleMessages;
}
export function getUsers(): user[] {
return users;
}
export function sendChatMessage(message: message & { token?: string }) {
if (queue.filter(m => m !== null).length < 6) {
console.log(message);
for (let i = 0; i < queue.length; i++) {
if (!queue[i]) {
queue[i] = message;
i = 6;
}
}
dirty = true;
notifyMessages();
ws.send(JSON.stringify({op: OpcodesServerbound.sendMessage, data: message}));
}
}
export function subscribeMessages(listener: () => void) {
listenersMessages.add(listener);
return () => listenersMessages.delete(listener);
}
export function subscribeUsers(listener: () => void) {
listenersUsers.add(listener);
return () => listenersUsers.delete(listener);
}
export { token, self };