@ d043c3a7:ba24b89e
2025-01-28 13:50:16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>zchan</title>
<style>
body { font-family: Arial, sans-serif; background: #ffe }
h1 {
margin-top: 0;
}
#list, .replies {
list-style: none;
padding: 0;
}
.replies {
margin-top: -0.5em;
margin-bottom: 1em;
}
li, #download-link-container {
background: #F0E0D6;
padding: .3em;
}
#download-link-container {
font-weight: bold;
}
li {
position: relative;
}
textarea {
display: block;
width: calc(100% - 3em);
height: 7em;
}
#content {
position: relative;
}
.date {
color: #555;
color: #555;
position: absolute;
right: 0;
bottom: 0;
margin: .2em;
}
.date span {
margin-left: .6em;
}
img {
max-width: 100%;
max-height: 20vh;
}
.loading,
.loading * {
cursor: wait;
}
.container { max-width: 1000px; margin: auto; border: 2px solid #ddd; border-radius: 1em; padding: 1em; }
.category, .thread, .message { margin-bottom: 1em; word-wrap: break-word; }
.thread.reply, .more {
margin-left: 3em;
margin-bottom: 10px;
}
.thread {
padding-bottom: 1.5em;
}
.pagination { display: flex; justify-content: center; margin-top: 1em; }
.pagination a { margin: 0 5px; }
.new-thread-form, .new-message-form { display: none; margin-top: 1em; }
#download-link-container {
display: none;
}
.threadTitle {
display: block;
overflow: hidden;
text-overflow: ellipsis;
background: #EEAA88;
padding: .2em;
white-space: nowrap;
}
.imgLink {
display: inline-block;
margin-right: .2em;
}
h2 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#actions {
position: absolute;
right: 0;
}
</style>
</head>
<body>
<div class="container">
<h1>zchan</h1>
<div id="content">Loading...</div>
<p id="download-link-container">
Download <a id="download-link" download="chan.html" href="#">chan.html</a> to run this app locally
</p>
</div>
<script src="https://unpkg.com/nostr-tools@2.7.2/lib/nostr.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/image-blob-reduce@4.1.0/dist/image-blob-reduce.min.js"></script>
<script>
window.nostr = window.NostrTools
const reduce = new ImageBlobReduce()
const max_file_size = 12e4
const local = !location.href.startsWith("http")
const fileServers = [
"https://blossom.primal.net",
"https://nostr.download",
"https://blossom.f7z.io"
]
const fileRelays = [
"wss://nostr.swiss-enigma.ch",
"wss://nostr.cercatrova.me"
]
if(!local){
document.getElementById("download-link").href = location.pathname
document.getElementById("download-link-container").style.display = "block"
}
const maxWait = 1000
const catFreshDays = 10
const clientTags = []
let relays = []
let sockets = []
let queryIdCounter = 0
let activeThread
function init(){
relays.push("wss://nostr.swiss-enigma.ch")
relays.push("wss://nostr.cercatrova.me")
relays.push("wss://soloco.nl")
relays.push("wss://nostr.oxtr.dev")
relays.push("wss://relay.piazza.today")
relays.push("wss://junxingwang.org")
relays.push("wss://n.ok0.org")
relays.push("wss://nostr.data.haus")
relays.push("wss://bostr.bitcointxoko.com")
relays.push("wss://relaypag.es")
relays.push("wss://relay.nostr.watch")
relays.push("wss://relay.damus.io")
const contentDiv = document.getElementById('content')
contentDiv.innerText = "Connecting"
return new Promise(resolve => {
let openSockets = []
let resolved = false
for(const relay of relays){
const sock = new WebSocket(relay)
sock.onopen = function(){
if(resolved){
sock.close()
return
}
openSockets.push(this)
contentDiv.innerText = "Connected to " + openSockets.length + " relays"
if(openSockets.length >= 5){
sockets = openSockets
resolved = true
resolve()
}
}
sockets.push(sock)
}
})
}
function send(messageObject, limit, queryId, timeout) {
if(!timeout){
timeout = 1000
}
const st = new Date().getTime()
const query = JSON.stringify(messageObject)
const events = []
const activeSockets = [...sockets]
return new Promise(resolve => {
let resolved = false
for(const socket of sockets){
const sst = new Date().getTime()
socket.addEventListener("message", function(e){
if(resolved){
return
}
const data = JSON.parse(e.data)
if(data[0] == "EOSE" || data[0] == "OK"){
const sockIndex = activeSockets.indexOf(this)
if(sockIndex != -1){
activeSockets.splice(sockIndex, 1)
}
console.log("query @ " + this.url + " took ", new Date().getTime() - sst, "ms")
}
else if(data[0] == "EVENT" && data[1] == queryId && data.length > 2){
if(events.find(e => e.id == data[2].id)){
return
}
events.push(data[2])
}
if(activeSockets.length == 0){
console.log("send ok (2), query took ", new Date().getTime() - st, "ms", Array.from(events))
resolved = true
resolve(events)
return
}
})
socket.addEventListener("close", function(){
console.log("closed", this.url)
})
socket.send(query)
}
setTimeout(() => {
if(resolved){
return
}
console.log("send ok (3), query took ", new Date().getTime() - st, "ms")
console.log("timeouted relays", activeSockets.map(s => s.url))
resolved = true
resolve(Array.from(events))
}, timeout)
})
}
function text(str, multiline){
if(!str){
return str
}
str = str.replaceAll("<", "<").replaceAll("'", "‘").replaceAll("\"", """)
if(multiline){
str = str.replaceAll("\n", "<br/>")
}
return str
}
function showNewCategoryForm() {
const form = document.getElementById('new-category-form');
form.style.display = 'block';
}
function goToHashtag(){
const hashTags = document.querySelector("#hashtaginput").value.split(" ")
if(document.querySelector("#hashtaginput").value.length == 0){
return
}
const uri = hashTags.map(value => value.match(/^(#|)(.*)$/)[2]).join(",")
location.hash = "#tag/" + uri
}
function renderCategories() {
const usedNames = []
const contentDiv = document.getElementById('content')
const feedLink = localStorage.getItem("feed") && '<a href="#feed">view feed</a>' || ''
contentDiv.innerHTML = `
<h2>Topics</h2>
<form action="#" onsubmit="goToHashtag(); return false" autocomplete="on">
<p><input id="hashtaginput" type="text" placeholder="zchan aiart loli" required/></p>
<p><button>View threads</button></p>
</form>
${feedLink}
`
}
function saveFeed(){
localStorage.setItem("feed", location.hash.match("#tag/(.*)")[1])
location.hash = "#feed"
}
function createMessageElement(message, threadMessages, reply, reactions){
let threadTitleSafe = text(message.content)
const threadElement = document.createElement('li');
const date = text(new Date(message.created_at * 1000).toLocaleString("en-uk"))
threadElement.className = 'thread'
let repliesHtml = ""
if(reply){
threadElement.classList.add("reply")
}else{
repliesHtml = `
${reactions.length > 0 && `<span style="font-weight: bold">${reactions.length}</span> reactions` || ''}
<span style="${threadMessages.length > 1 ? "font-weight: bold": ""}">${threadMessages.length-1}</span> replies
`
}
threadElement.innerHTML = `
<a class="threadTitle" href="#thread/${text(threadMessages[0].id)}">${threadTitleSafe}</a> <span class="date">${date}${repliesHtml}</span>
<div class="messageContent">${links(text(message.content, false))}</div>
`
return threadElement
}
function escapeHref(uri){
return uri.replaceAll("'", "")
}
function makeUntilHash(categories, until){
if(location.hash.startsWith("#feed")){
return `#feed//${until}`
}
const tagsUri = categories.map(c => c.match(/^[^, ]+$/i)[0]).join(",")
return `#tag/${escapeHref(tagsUri)}/${until}`
}
function renderThreads(categories, threads, threadsMessages, threadsReactions) {
const categoriesHtml = makeCategoriesHtml(categories)
const categoriesValue = categories.map(c => c.match(/^[^, ]+$/i)[0]).join(" ")
const contentDiv = document.getElementById('content')
const feed = location.hash.startsWith("#feed") && localStorage.getItem("feed")
let feedLink = !feed && '<a href="#feed">view feed</a> ' || `<a href='#tag/${escapeHref(feed)}'>feed link</a>`
const feedButtonHtml = ! location.hash.startsWith("#feed") && '<button onclick="saveFeed()">Save feed</button>' || ''
let firstPageLinkHtml = ""
if(getUntil()){
firstPageLinkHtml = `<a href="${makeUntilHash(categories, "")}">first page</a>`
}
const lastTimestamp = Math.min(threads[threads.length-1].created_at, threadsMessages.length > 0 && threadsMessages[threadsMessages.length-1].created_at || Infinity) - 1
contentDiv.innerHTML = `
<h2><a href="#">Home</a> > ${categoriesHtml}</h2>
<div id="actions">${feedLink}${feedButtonHtml}</div>
<button onclick="showNewThreadForm('${categories[0]}', '${categories[0]}')">New Thread</button>
<div id="new-thread-form" class="new-thread-form">
<p><input type="text" id="new-thread-categories" placeholder="categories" value="${categoriesValue}"/>
<p><textarea id="new-thread-message" placeholder="First Message"></textarea></p>
<p><input id="new-thread-file" type="file"/> ${makeFileServerSelectionHtml()}</p>
<p><button onclick='createNewThread()'>Create Thread</button></p>
</div>
<ul id="list"></ul>
${firstPageLinkHtml}
<a onclick="window.scrollTo(0,0)" href="${location.origin + location.pathname + makeUntilHash(categories, lastTimestamp)}">next page >></a>
`
const list = document.getElementById("list")
threads.forEach(thread => {
let messages = [thread, ...threadsMessages.filter(m => m.tags.find(t => t[0] == "e" && t[1] == thread.id))]
let reactions = threadsReactions.filter(m => m.tags.find(t => t[0] == "e" && t[1] == thread.id))
const messageElement = createMessageElement(thread, messages, false, reactions)
list.appendChild(messageElement)
if(messages.length > 1){
const replyList = document.createElement("ul")
replyList.classList.add("replies")
const countDiff = messages.length - 4
if(countDiff > 0){
const el = document.createElement("li")
const link = document.createElement("a")
el.classList.add("more")
link.href = `#thread/${thread.id}`
link.innerText = `${countDiff} more messages`
el.append(link)
replyList.appendChild(el)
}
for(let message of messages.slice(1).slice(-3)){
//let reactions = threadsReactions.filter(m => m.tags.find(t => t[0] == "e" && t[1] == message.id))
const messageElement = createMessageElement(message, messages, true)
replyList.appendChild(messageElement)
}
list.append(replyList)
}
})
}
function showNewThreadForm(categoryId, categoryTitle) {
const form = document.getElementById('new-thread-form');
form.style.display = 'block';
}
async function sha256(blob) {
const uint8Array = new Uint8Array(await blob.arrayBuffer())
const hashBuffer = await crypto.subtle.digest('SHA-256', uint8Array)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((h) => h.toString(16).padStart(2, '0')).join('')
}
async function load_image(image_blob){
return new Promise((resolve) => {
let reader = new FileReader()
let data_url = reader.readAsDataURL(image_blob)
reader.onload = function(e){
const img = new Image()
img.src = e.target.result
img.onload = async () => resolve(img)
}
})
}
async function resize(max_size, quality, blob){
let resized_img = await reduce.toBlob(blob, { max: max_size })
return await reduce.pica.toBlob(await reduce.toCanvas(resized_img), 'image/webp', quality)
}
async function resizeImg(blob){
let quality = 0.90
let max_size = 3000
let min_size = 900
let min_quality = 0.75
let resized_img = null
while(resized_img == null || resized_img.size > max_file_size){
resized_img = await resize(max_size, quality, blob)
if(quality > min_quality){
quality = Math.max(min_quality, quality - 0.5)
}
else if(max_size > min_size){
max_size = Math.max(min_size, max_size - 500)
}
else {
break
}
}
let img = null
if(resized_img.size <= max_file_size){
img = await load_image(resized_img)
console.log("resized image file size: " + resized_img.size + " bytes, w: " + img.width + ", h: " + img.height + ", quality: " + quality)
}else{
throw "error: could not resize image within size limitations"
return
}
return resized_img
}
function blobToDataURL(blob, callback) {
return new Promise(resolve => {
var a = new FileReader()
a.onload = function(e) {
resolve(e.target.result)
}
a.readAsDataURL(blob)
})
}
async function uploadFile(fileBlob, privateKey){
const imgBlob = await resizeImg(fileBlob)
const fileDataUri = await blobToDataURL(imgBlob)
let fileEvent = {
"kind": 1063,
"content": "image",
"created_at": Math.floor(Date.now() / 1000),
"tags": [
["url", fileDataUri],
["m", imgBlob.type],
]
}
fileEvent = window.nostr.finalizeEvent(fileEvent, privateKey)
const fileId = fileEvent.id
await send(["EVENT", fileEvent], 1, "sendq", 5000)
const nevent_obj = Object.create(fileEvent)
nevent_obj.relays = fileRelays
const neventStr = window.nostr.nip19.neventEncode(nevent_obj)
return neventStr
}
async function uploadBlob(fileBlob, fileHash, privateKey, serverUrl){
let auth = {
"kind": 24242,
"content": "",
"created_at": Math.floor(new Date().getTime() / 1000), //Math.floor(new Date().getTime() / 1000 - Math.random() * 84000),
"tags": [
["t", "upload"],
["x", fileHash],
["expiration", (Math.floor(new Date().getTime() / 1000) + 10000).toString()]
]
}
auth = window.nostr.finalizeEvent(auth, privateKey)
const formData = new FormData()
formData.append("files", fileBlob)
const body = new Blob([fileBlob], { type: 'application/octet-stream' })
return await (await fetch(`${serverUrl}/upload`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': 'Nostr ' + btoa(JSON.stringify(auth)),
},
body
})).json()
}
async function uploadAnyFile(fileBlob, fileHash, fileServers, privateKey){
const fileServerType = document.getElementById('form-file-server').value
if(fileBlob && fileBlob.type.startsWith("image/") && fileServerType == "event"){
const neventStr = await uploadFile(fileBlob, privateKey)
return ["nostr:" + neventStr]
}
const fileUrls = []
for(let serverUrl of fileServers){
const match = fileBlob.name.match(/\.[a-z0-9]{1,10}/)
fileExtension = match && match[0] || ""
const uploadResult = await uploadBlob(fileBlob, fileHash, privateKey, serverUrl)
fileUrls.push(`${serverUrl}/${uploadResult.sha256}${fileExtension}`)
}
return fileUrls
}
async function createNewThread() {
const categories = document.querySelector("#new-thread-categories").value.split(" ")
const messageInput = document.getElementById('new-thread-message')
const fileBlob = document.getElementById('new-thread-file').files[0]
let firstMessage = messageInput.value
if (!firstMessage && !fileBlob){
alert("message or file required")
return
}
const privateKey = window.nostr.generateSecretKey()
const uploadResult = fileBlob && await handleFileUpload(fileBlob, privateKey)
if(uploadResult && uploadResult.urls && uploadResult.urls.length > 0){
firstMessage = uploadResult.urls[0] + "\n" + firstMessage
}
const fileTags = uploadResult && uploadResult.tags || []
let messageEvent = {
kind: 1,
tags: [
...categories.map(category => ["t", category]),
...fileTags
],
content: firstMessage,
created_at: Math.floor(Date.now() / 1000),
pubkey: window.nostr.getPublicKey(privateKey),
}
messageEvent.id = window.nostr.getEventHash(messageEvent)
messageEvent = window.nostr.finalizeEvent(messageEvent, privateKey)
await send(["EVENT", messageEvent])
loadThreads(categories)
}
function links(str){
const img = '<a class="imgLink" href="$1" target="_blank"><img src="$1"/></a>'
str = str.replace(/(http(s|):\/\/\S+\.(jpg|jpeg|webp|png|gif)|blob:http(s|):\/\/\S+)/gi, img)
const embedHtml = '<iframe width="560" height="315" src="https://www.youtube-nocookie.com/embed/$1" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>'
str = str.replace(/https:\/\/www.youtube.com\/watch\?v=([a-zA-Z0-9]{11})/g, embedHtml)
str = (" "+str).replace(/(<br\/>| )(http(s|):\/\/[a-z0-9-_.+?%/&]+)/gi, '$1<a href="$2">$2</a>')
str = str.substr(1)
str = str.replaceAll("\n", "<br/>")
return str
}
function makeCategoriesHtml(categories){
return categories.map(category => {
const prefix = "tag"
const match = category.match(/^(#|)(.*)$/)
return `<a href="#${prefix}/${text(match[2])}">${text(match[2])}</a>`
}).join(" / ")
}
function makeFileServerSelectionHtml(idServer, idCount){
return `preferred server
<select id="form-file-server">
<option value="-1">default</option>
<option value="blob">http server</option>
<option value="event">event (base64)</option>
</select>
no of servers
<select id="form-file-server-count">
<option value="2">2 (default)</option>
<option value="1">1</option>
</select>
`
}
function renderMessages(messages, categories) {
console.log("renderMessages")
const categoriesHtml = makeCategoriesHtml(categories)
const contentDiv = document.getElementById('content');
contentDiv.innerHTML = `
<h2><a href="#">Home</a> > ${categoriesHtml} > ${text(messages[0].content)}</h2>
<ul id="list"></ul>
`
const list = document.getElementById("list")
messages.forEach(message => {
console.log("message", message)
const messageElement = document.createElement('li');
messageElement.className = 'message'
messageElement.innerHTML = `<div>${links(text(message.content, false))}</div>`
list.appendChild(messageElement);
})
activeThread = messages[0]
contentDiv.innerHTML += `
<button onclick="showNewMessageForm()">New Message</button>
<div id="new-message-form" class="new-message-form">
<p><textarea id="new-message-content" placeholder="New Message"></textarea></p>
<p><input id="new-message-file" type="file"/> ${makeFileServerSelectionHtml()}</p>
<p><button onclick='createNewMessage()'>Post Message</button></p>
</div>
`
}
function displayHtmlError(htmlError){
const contentDiv = document.getElementById('content')
contentDiv.innerHTML = `
<h2><a href="#">Home</a> > error</h2>
<p>${htmlError}</p>
`
}
function showNewMessageForm() {
const form = document.getElementById('new-message-form');
form.style.display = 'block';
}
function pickFileServers(){
const fileServerCount = document.getElementById('form-file-server-count').value
const selectedFileServers = []
const myFileServers = [...fileServers]
for(let i = 0; i < fileServerCount; i++){
const fileServerIndex = Math.round(Math.random()*(myFileServers.length-1))
selectedFileServers.push(...myFileServers.splice(fileServerIndex, 1))
}
return selectedFileServers
}
async function handleFileUpload(fileBlob, privateKey){
const tags = []
let urls
if(fileBlob){
const fileHash = await sha256(fileBlob)
const fileServers = pickFileServers()
urls = await uploadAnyFile(fileBlob, fileHash, fileServers, privateKey)
tags.push(["x", fileHash])
for(let fileServer of fileServers){
tags.push(["server", fileServer])
}
}
return {
tags,
urls
}
}
async function createNewMessage() {
const thread = activeThread
const messageInput = document.getElementById('new-message-content');
let messageContent = messageInput.value;
const fileBlob = document.getElementById('new-message-file').files[0]
if (!messageContent && !fileBlob){
alert("message or file required")
return
}
const privateKey = window.nostr.generateSecretKey()
const uploadResult = await handleFileUpload(fileBlob, privateKey)
if(uploadResult && uploadResult.urls && uploadResult.urls.length > 0){
messageContent = uploadResult.urls[0] + "\n" + messageContent
}
if(fileBlob && uploadResult && (!uploadResult.urls || uploadResult.urls.length == 0)){
//console.log("error", uploadResult, fileBlob)
alert("error")
return
}
const fileTags = uploadResult && uploadResult.tags || []
let event = {
kind: 1,
tags: [
['p', thread.pubkey],
['e', thread.id, relays[0], "root"],
...fileTags,
...clientTags
],
content: messageContent,
created_at: Math.floor(Date.now() / 1000),
pubkey: window.nostr.getPublicKey(privateKey),
};
event.id = window.nostr.getEventHash(event);
event = window.nostr.finalizeEvent(event, privateKey);
await send(["EVENT", event])
loadMessages(thread)
}
function fetchLatestMessages(kinds, limit) {
const queryId = (++queryIdCounter).toString()
return send(["REQ", queryId, { kinds: kinds, limit: limit }], limit, queryId)
}
function threadIdsFromMessages(messages){
const idsUnique = new Set(messages.map(msg => {
const root = msg.tags.find((t, i) => {
return t[0] == "e"
})
return root && root[1]
}).filter(id => id !== undefined && id.match(/^[a-z0-9]{64}$/)))
return Array.from(idsUnique)
}
function findThreadUpdateTime(thread, threadsMessages){
let latestTimestamp = 0
for(let message of [thread, ...threadsMessages]){
if(message != thread && !message.tags.find(t => t[0] == "e" && t[1] == thread.id)){
continue
}
if(message.created_at >= latestTimestamp){
latestTimestamp = message.created_at
}
}
return latestTimestamp
}
async function loadThreads(categories, redirect, until) {
const filters = { limit: 50 }
filters.kinds = [1]
filters["#t"] = categories
if(until){
filters["until"] = until
}
const latestMessages = await fetchLatestMessages([1], 500);
let queryId = (++queryIdCounter).toString()
let threads = await send(["REQ", queryId, filters], filters.limit, queryId)
threads = threads.filter(t => !t.tags.find(tag => tag[0] == "e"))
filters["ids"] = threadIdsFromMessages(latestMessages)
queryId = (++queryIdCounter).toString()
threads.push(...await send(["REQ", queryId, filters], filters.limit, queryId))
const threadsIds = []
let threadsUnique = []
for(let thread of threads){
if(!threadsIds.includes(thread.id)){
threadsIds.push(thread.id)
threadsUnique.push(thread)
}
}
queryId = (++queryIdCounter).toString()
const threadsEvents = await send(["REQ", queryId, { kinds: [1, 7], '#e': threadsIds, limit: 1000 }], 1000, queryId)
const threadsMessages = threadsEvents.filter(e => e.kind == 1)
const threadsReactions = threadsEvents.filter(e => e.kind == 7)
threadsMessages.sort((a, b) => a.created_at - b.created_at)
threadsUnique.sort((a, b) => {
aTimestamp = findThreadUpdateTime(a, threadsMessages)
bTimestamp = findThreadUpdateTime(b, threadsMessages)
return bTimestamp - aTimestamp;
})
await resolveEmbeddedMedia([...threadsUnique, ...threadsMessages])
if(threadsUnique.length == 0){
const feedLinkHtml = localStorage.getItem("feed") && ' or <a href="#feed">feed</a>' || ''
displayHtmlError(`no threads. try <a href="#">going to home</a>${feedLinkHtml}`)
return
}
renderThreads(categories, threadsUnique, threadsMessages, threadsReactions)
if(redirect != false){
const prefix = "tag"
const untilStr = until && `/${until}` || ""
window.location.hash = `#${prefix}/${categories.join(",")}${untilStr}`
}
}
async function resolveEmbeddedMedia(messages){
const matches = messages.map(m => m.content).join().matchAll(/nostr:(nevent[a-z0-9]+)/g)
const embeddedEventIds = []
const eventMap = {}
for(let m of [...matches]){
const neventStr = m[1]
let id
try {
id = nostr.nip19.decode(neventStr).data.id
}catch(e){
console.log("invalid nevent", neventStr)
}
eventMap[id] = neventStr
embeddedEventIds.push(id)
}
let queryId = (++queryIdCounter).toString()
const embeddedEvents = await send(["REQ", queryId, { ids: embeddedEventIds }], 200, queryId, 3000)
for(let event of embeddedEvents){
const urlTag = event.tags.find(t => t[0] == "url")
if(!urlTag){
continue
}
const dataUri = urlTag[1]
const neventStr = eventMap[event.id]
for(let message of messages){
const blob = await (await fetch(dataUri)).blob()
let blobUrl = URL.createObjectURL(blob)
message.content = message.content.replaceAll("nostr:" + neventStr, blobUrl)
}
}
}
async function loadMessages(thread) {
let queryId = (++queryIdCounter).toString()
let messages = await send(["REQ", queryId, { kinds: [1], '#e': [thread.id], limit: 200 }], 200, queryId)
messages.sort((a, b) => a.created_at - b.created_at)
const categories = []
for(let tag of thread.tags){
if(tag[0] == "t"){
categories.push(tag[1])
}
}
messages = [thread, ...messages]
await resolveEmbeddedMedia(messages)
renderMessages(messages, categories);
window.location.hash = `#thread/${thread.id}`
}
async function getThread(threadId) {
let queryId = (++queryIdCounter).toString()
const threads = await send(["REQ", queryId, { ids: [threadId], limit: 1 }], 1, queryId)
return threads.length > 0 ? threads[0] : null;
}
function getUntil(){
const hash = decodeURIComponent(window.location.hash)
const hashParts = hash.split('/')
if(hashParts.length > 2 && hashParts[2].match(/^\d+$/)){
return parseInt(hashParts[2], 10)
}
}
async function initializeFromHash() {
const hash = decodeURIComponent(window.location.hash)
const hashParts = hash.split('/')
let until = getUntil()
if (hash.startsWith('#thread/')) {
const threadId = hashParts[1]
const thread = await getThread(threadId)
if(!thread){
displayHtmlError('could not load thread. try <a href="javascript:location.reload()">reloading</a>')
return
}
await loadMessages(thread)
}
else if (hash.startsWith('#tag/')) {
const hashTagsStr = hashParts[1]
const categories = hashTagsStr.split(",")
await loadThreads(categories, false, until)
}
else if (hash.startsWith('#feed')){
const hashTagsStr = localStorage.getItem("feed")
const categories = hashTagsStr.split(",")
await loadThreads(categories, false, until)
}
else {
await renderCategories();
}
}
onhashchange = async function(){
document.body.classList.add("loading")
await initializeFromHash()
document.body.classList.remove("loading")
}
document.addEventListener('DOMContentLoaded', async () => {
await init()
await initializeFromHash();
});
</script>
</body>
</html>