Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
6b6335f
wip: matrix
cfpwastaken May 1, 2026
896857c
wip: matrix
cfpwastaken May 1, 2026
acdc267
feat: css magic
j0code May 2, 2026
f2f4f3a
feat(matrix): more msgtypes for m.room.message
cfpwastaken May 2, 2026
e01b2b0
feat(matrix): m.room.name event
cfpwastaken May 2, 2026
3fd76b8
feat(matrix): member list
cfpwastaken May 2, 2026
1d7a2b8
feat(matrix): leaving and joining rooms
cfpwastaken May 2, 2026
a7de2f8
feat(matrix): register
cfpwastaken May 2, 2026
6e05d19
feat(matrix): creating rooms
cfpwastaken May 2, 2026
db87c79
feat(matrix): inviting to rooms
cfpwastaken May 2, 2026
84a909c
fix: name RoomNoticeMessage correctly
cfpwastaken May 2, 2026
db6f2f7
fix: name RoomAudioMessage correctly
cfpwastaken May 2, 2026
0c6ed72
fix(avatar): reset element content before rerendering
cfpwastaken May 2, 2026
fe3b761
fix(RoomView): bad event .off handling
cfpwastaken May 2, 2026
f703a6c
fix(RoomEmoteMessage): body fallback handling
cfpwastaken May 2, 2026
fd7df05
feat(messages): correct formatting stuff
cfpwastaken May 2, 2026
ff38efc
fix(ChatInput): null room
cfpwastaken May 2, 2026
c058e57
fix(login): typed errors
cfpwastaken May 2, 2026
6d57611
fix(client): init from App.ts
cfpwastaken May 2, 2026
e67ae78
fix(message): xss from innerHTML = body
cfpwastaken May 2, 2026
d5f32f7
fix(RoomFileMessage): add rel=noopener noreferrer
cfpwastaken May 2, 2026
add5f6b
fix(messages): cleanup file-based msgtypes
cfpwastaken May 2, 2026
ff90cb7
feat: use user profile cache
cfpwastaken May 2, 2026
35590f8
fix(RoomNameEvent): call super.reset
cfpwastaken May 2, 2026
b5b682a
fix(html): more sanitization
cfpwastaken May 2, 2026
c818bfe
feat(messages): spoilers
cfpwastaken May 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 388 additions & 0 deletions client/bun.lock

Large diffs are not rendered by default.

1,097 changes: 409 additions & 688 deletions client/package-lock.json

Large diffs are not rendered by default.

6 changes: 2 additions & 4 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,21 @@
"preview": "vite preview"
},
"dependencies": {
"@discord/embedded-app-sdk": "^1.9.0",
"@discordapp/twemoji": "^15.1.0",
"@discordjs/rest": "^2.4.3",
"@emoji-mart/data": "^1.2.1",
"@j0code/threadlet-api": "npm:@jsr/j0code__threadlet-api@^0.0.21",
"@j0code/yson": "npm:@jsr/j0code__yson@^1.2.4",
"@types/dompurify": "^3.0.5",
"@types/highlight.js": "^9.12.4",
"@types/marked": "^5.0.2",
"discord-api-types": "^0.37.119",
"dompurify": "^3.2.4",
"dompurify": "^3.4.2",
"emoji-mart": "^5.6.0",
"esbuild": "^0.25.0",
"highlight.js": "^11.11.1",
"marked": "^15.0.7",
"marked-alert": "^2.1.2",
"marked-highlight": "^2.2.1",
"matrix-js-sdk": "^41.4.0",
"vite": "^6.1.0"
},
"devDependencies": {
Expand Down
Binary file added client/public/2pills.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 31 additions & 4 deletions client/src/comps/App.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,57 @@
import { ClientEvent, Room, RoomEvent } from "matrix-js-sdk"
import { initMatrixClient, matrix } from "../matrix"
import ChannelList from "./ChannelList"
import Component from "./Component"
import Form from "./Form"
import View from "./View"
import MemberList from "./MemberList"

export default class App extends Component {

readonly channelList: ChannelList
private currentView?: View | Form
readonly memberList: MemberList

constructor(forums: Array<any>) {
constructor() {
super("div", { id: "app" })

this.channelList = new ChannelList(forums)
this.channelList = new ChannelList([])
this.element.appendChild(this.channelList.element)
this.memberList = new MemberList()
this.element.appendChild(this.memberList.element)

matrix.once(ClientEvent.Sync, () => {
this.updateChannelList();
})

matrix.on(RoomEvent.MyMembership, () => {
this.updateChannelList();
})

void initMatrixClient()
}

renderView(view: View | Form, ...args: any[]) {
updateChannelList() {
let rooms = matrix.getRooms()
this.channelList.reset(rooms)
}

renderView(view: View | Form | undefined, ...args: any[]) {
if (this.currentView) {
this.currentView.element.remove()
}
if(!view) return

view.reset(...args)
this.element.appendChild(view.element)
this.element.insertBefore(view.element, this.memberList.element)
this.currentView = view
}

async updateMemberList(room: Room | null) {
const members = matrix.getRoom(room?.roomId)?.getMembers() || []
await this.memberList.reset(members, room?.roomId)
}

getCurrentView() {
return this.currentView
}
Expand Down
60 changes: 60 additions & 0 deletions client/src/comps/Avatar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { matrix } from "../matrix";
import Component from "./Component";
import MXCImage from "./MXCImage";

export default class Avatar extends Component {
public readonly mxid: string

constructor(mxid: string, className?: string) {
super("div", { classes: ["avatar", className].filter((v): v is string => !!v) })
this.mxid = mxid
this.element.setAttribute("data-mxid", mxid)
this.reset()
}

async reset() {
const mxid = this.element.getAttribute("data-mxid")
if(!mxid) return

this.element.innerHTML = ""

const user = matrix.getUser(mxid)
let displayname = user?.displayName
let avatar_url = user?.avatarUrl
if(!user) {
const profile = await matrix.getProfileInfo(mxid)
displayname = profile?.displayname || mxid
avatar_url = profile?.avatar_url
}
if(avatar_url) {
let img = new MXCImage(avatar_url)
await img.reset()
this.element.appendChild(img.element)
} else {
Comment thread
cfpwastaken marked this conversation as resolved.
this.element.style.backgroundColor = this.stringToColor(mxid)
this.element.style.color = this.contrastingColor(this.stringToColor(mxid))
this.element.style.fontSize = "24px"
this.element.style.display = "flex"
this.element.style.alignItems = "center"
this.element.style.justifyContent = "center"
this.element.innerHTML = displayname?.[0].toUpperCase() || "?"
}
}

private stringToColor(str: string) {
let hash = 0
for(let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const color = (hash & 0x00FFFFFF).toString(16).toUpperCase()
return "#" + "00000".substring(0, 6 - color.length) + color
}

private contrastingColor(hex: string): "#000000" | "#FFFFFF" {
const r = parseInt(hex.substr(1, 2), 16)
const g = parseInt(hex.substr(3, 2), 16)
const b = parseInt(hex.substr(5, 2), 16)
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
return luminance > 0.5 ? "#000000" : "#FFFFFF"
}
}
24 changes: 18 additions & 6 deletions client/src/comps/ChannelList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,38 @@ import { app, views } from "../main";
import Component from "./Component";
import FormButton from "./FormButton";
import ForumTab from "./ForumTab";
import { Room } from "matrix-js-sdk";
import App from "./App";

export default class ChannelList extends Component {

constructor(forums: Array<Forum>) {
constructor(rooms: Array<Room>) {
super("div", { id: "channels" })

this.reset(forums)
this.reset(rooms)
}

reset(forums: Array<Forum>) {
reset(forums: Array<Room>) {
for (let child of Array.from(this.element.children)) {
child.remove()
}
if(!(app instanceof App)) {
// How did we get here
return
}

let _app = app satisfies App

const createButton = new FormButton("create-forum-button", "(+) New", () => app.renderView(views.forumCreateForm))
const createButton = new FormButton("create-forum-button", "(+) New", () => _app.renderView(views.roomCreateForm))
this.element.appendChild(createButton.element)

for (let forum of forums) {
for (let forum of forums.filter(r => r.getMyMembership() != "leave")) {
const tab = new ForumTab(forum)
tab.element.addEventListener("click", () => app.renderView(views.forumView, forum))
tab.tab.addEventListener("click", () => {
const membership = forum.getMyMembership()
_app.renderView(membership == "join" ? views.roomView : views.roomInviteView, forum)
_app.updateMemberList(forum)
})
this.element.appendChild(tab.element)
}
}
Expand Down
29 changes: 22 additions & 7 deletions client/src/comps/ChatInput.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { MsgType } from "matrix-js-sdk"
import { api, app } from "../main"
import { matrix } from "../matrix"
import Component from "./Component"
import EmojiPicker from "./EmojiPicker"
import PostView from "./PostView"
import RoomView from "./RoomView"

// Credits to DeepSeek-R1, wow (edited though)
export default class ChatInput extends Component {

readonly emojiPicker: EmojiPicker

constructor(postView: PostView) {
constructor(view: PostView | RoomView) {
super("div", { id: "chat-input-container" })

// Create file upload button
Expand All @@ -34,14 +37,26 @@ export default class ChatInput extends Component {
chatInput.innerHTML = ""

async function createMessage() {
const forum_id = postView.getCurrentForumId()
const post_id = postView.getCurrentPostId()
if (!forum_id || !post_id) {
throw new Error("TODO")
// const forum_id = view.getCurrentForumId()
// const post_id = view.getCurrentPostId()
// if (!forum_id || !post_id) {
// throw new Error("TODO")
// }

// const msg = await api.createMessage(forum_id, post_id, { content })
// console.log(msg)
if (view instanceof PostView) {
// TODO
return
}

const msg = await api.createMessage(forum_id, post_id, { content })
console.log(msg)
const room = view.getCurrentRoom()
if (!room) return

await matrix.sendMessage(room.roomId, {
body: content,
msgtype: MsgType.Text
})
}

console.log("Send MSG:", chatInput.innerText)
Expand Down
34 changes: 34 additions & 0 deletions client/src/comps/ContextMenu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Component from "./Component";

export default class ContextMenu extends Component {
public readonly content: HTMLElement
public readonly trigger: HTMLElement

constructor(tagName: keyof HTMLElementTagNameMap, { id, classes }: { id?: string, classes?: string[] }) {
super("div", { classes: ["context-menu"] })

this.content = document.createElement(tagName)
if (id) this.content.id = id
this.content.classList.add("context-menu-content")
if (classes) this.content.classList.add(...classes)
this.content.style.position = "absolute"
this.content.style.display = "none"

this.element.appendChild(this.content)

this.trigger = document.createElement("div")
this.element.appendChild(this.trigger)

this.trigger.addEventListener("contextmenu", e => {
e.preventDefault()
this.content.style.left = `${e.clientX}px`
this.content.style.top = `${e.clientY}px`
this.content.style.display = ""
const hide = () => {
this.content.style.display = "none"
window.removeEventListener("click", hide)
}
window.addEventListener("click", hide)
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,31 @@ import { type Post, type Message as APIMessage } from "@j0code/threadlet-api/v0/
import { api } from "../main"
import Component from "./Component"
import Message from "./Message"
import { MatrixEvent } from "matrix-js-sdk"
import { renderEvent } from "./events/Event"

export default class MessageList extends Component {
export default class EventList extends Component {

constructor() {
super("div", { id: `messages` })
super("div", { id: `events` })
}

async reset(post: Post) {
async reset(events: MatrixEvent[]) {
for (let child of Array.from(this.element.children)) {
child.remove()
}

const msgs = await api.getMessages(post.forum_id, post.id)

for (let msg of msgs) {
this.pushMessage(msg)
for (let event of events) {
this.pushMessage(event)
}

this.element.scrollTop = this.element.scrollHeight
}

pushMessage(msg: APIMessage) {
pushMessage(event: MatrixEvent) {
const autoscroll = this.element.scrollTop + this.element.clientHeight >= this.element.scrollHeight - 10

const comp = new Message(msg)
const comp = renderEvent(event)
this.element.appendChild(comp.element)

if (autoscroll) {
Expand Down
29 changes: 29 additions & 0 deletions client/src/comps/FormCheckbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Component from "./Component"
import EmojiButton from "./EmojiButton"
Comment thread
cfpwastaken marked this conversation as resolved.

export default class FormCheckbox extends Component {

private readonly input: HTMLInputElement
private readonly label: HTMLSpanElement

constructor(id: string, label: string) {
super("div", { id, classes: ["form-input", "form-checkbox-input"] })

this.input = document.createElement("input")
this.input.type = "checkbox"
this.element.append(this.input)

this.label = document.createElement("span")
this.label.textContent = label
this.element.append(this.label)
}

get value() {
return this.input.checked
}

clear() {
this.input.checked = false
}

}
28 changes: 26 additions & 2 deletions client/src/comps/ForumTab.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
import { Room } from "matrix-js-sdk";
import { matrix } from "../matrix";
import { twemojiParse } from "../md";
import Component from "./Component";
import ContextMenu from "./ContextMenu";
import { app, views } from "../main";
import App from "./App";

export default class ForumTab extends Component {

constructor(forum: any) {
readonly tab: HTMLElement

constructor(forum: Room) {
super("div", { classes: ["list-tab"] })

this.element.innerHTML = twemojiParse(forum.name)
const ctxMenu = new ContextMenu("div", { classes: ["forum-tab-menu"] })

const leaveButton = document.createElement("div")
leaveButton.textContent = "Leave"
leaveButton.addEventListener("click", async () => {
if(!confirm(`Are you sure you want to leave ${forum.name}?`)) return
await matrix.leave(forum.roomId)
let _app = app as App
_app.updateChannelList()
_app.renderView(undefined)
_app.updateMemberList(null)
})
ctxMenu.content.appendChild(leaveButton)

this.tab = ctxMenu.trigger
ctxMenu.trigger.innerHTML = twemojiParse(forum.name)

this.element.appendChild(ctxMenu.element)
}

}
Loading