var HootStream = View.extend({ el: "#hootstream", events: { "click a": "onClickLink", "click .filename": "onClickFilename", "click .action": "onClickAction", }, initialize: function ({ parent }) { this.parent = parent; this.$hootevents = this.$("#hootevents"); this.hootTemplate = this.$(".hootTemplate").html(); this.threadTemplate = this.$(".threadTemplate").html(); this.lastlogTemplate = this.$(".lastlogTemplate").html(); this.fileTemplate = this.$(".fileTemplate").html(); this.imageTemplate = this.$(".imageTemplate").html(); this.onClickLink = this.onClickLink.bind(this); this.onClickFilename = this.onClickFilename.bind(this); }, onClickLink: function (event) { if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { return; } if (!event.target.href) { return; } // console.log(event.target.className, event.target.href); const url = new URL(event.target.href); switch (event.target.className) { case "file": console.log(url.pathname); if (url.pathname.match(/(mp3|wav|ogg|opus|flac)$/i)) { event.preventDefault(); audio.play(event.target.dataset.index); // index set in audio.js } break; case "userLink": case "threadLink": case "keywordLink": case "readMore": event.preventDefault(); console.log(">>", url.pathname); app.router.pushState(url.pathname); app.router.go(url.pathname); break; } // this.parent.onKeyword(keyword) }, onClickFilename: function (event) { const linkTag = event.target.querySelector("a.file"); if (linkTag) { this.onClickLink({ preventDefault: event.preventDefault, target: linkTag, }); } }, onClickAction: function (event) { const action = $(event.target).data("action"); const thread = $(event.target).closest(".threadTitle").data("thread"); console.log(action, thread); switch (action) { case "play": this.onPlay(event, thread); break; case "expand": this.onExpand(event, thread); break; case "post": this.onShowForm(event, thread); break; } }, onPlay: function (event, thread) { // $.get(`/api/stream?thread=${thread}`).then((response) => { console.log(response); let sort; try { const settings = JSON.parse(response.threads[0].settings); sort = settings.sort; } catch (error) { // console.error(error); sort = null; } audio.index(this.sortFiles(response.files, sort)); audio.play(0); }); }, onExpand: function (event, thread) { $(event.target).hide(); $(event.target).prev("div").hide(); $.get(`/api/stream?thread=${thread}`).then( function (response) { console.log(response); const $thread = this.renderThread({ query: response.query, thread: response.threads, images: response.files.filter((file) => file.filename.match(IMAGE_REGEXP) ), files: response.files.filter( (file) => !file.filename.match(IMAGE_REGEXP) ), comments: response.comments, }); console.log($thread); $(event.target).closest(".thread").replaceWith($thread); }.bind(this) ); }, onPost: function (thread) { // }, load: function (data, filters) { this.state = { ...this.agglutinate(data), data, filters, }; this.build(filters); }, build: function (filters) { const { data, order, threadLookup } = this.state; const threads = Object.values(threadLookup); let sortedOrder; if (filters.sort === "name") { sortedOrder = threads .reduce((names, values) => { if (values.thread.length) { return names.concat([ [values.thread[0].title, values.thread[0].id], ]); } return names; }, []) .sort( filters.order === "asc" ? (a, b) => a[0].localeCompare(b[0]) : (a, b) => b[0].localeCompare(a[0]) ) .map(([, thread_id]) => ({ type: "thread", thread_id, })); } else if (filters.sort === "date") { sortedOrder = filters.order === "desc" ? order : [...order].reverse(); } else { sortedOrder = order; } // console.log(sortedOrder, threadLookup); // Filter the elements that we're going to display const $els = sortedOrder .filter(({ type, thread_id }) => { if (type === "hoot" || type === "lastlog") { return filters.hoots; } const thread = threadLookup[thread_id]; if (filters.hoots && thread.comments && thread.comments.length) { return true; } if ( filters.music && thread.files.some((file) => AUDIO_REGEXP.test(file.filename)) ) { return true; } if ( filters.files && thread.files.some((file) => !AUDIO_REGEXP.test(file.filename)) ) { return true; } if ( (filters.images && thread.images.length) || thread.comments.some((comment) => CONTAINS_IMAGE_URL_REGEXP.test(comment.comment) ) ) { return true; } return false; }) .map( function ({ type, thread_id, data: itemData }) { // console.log(type, thread_id); if (type === "thread") { // Filter thread contents const thread = threadLookup[thread_id]; const threadData = { ...thread, images: filters.images ? thread.images : [], files: filters.music ? thread.files.filter((file) => { if (AUDIO_REGEXP.test(file.filename)) { return filters.music; } return filters.files; }) : [], comments: filters.hoots ? thread.comments : filters.images ? thread.comments.filter((comment) => CONTAINS_IMAGE_URL_REGEXP.test(comment.comment) ) : [], query: data.query, }; if ( threadData.images.length || threadData.files.length || threadData.comments.length ) { return this.renderThread(threadData).reduce( ($el, $item) => $el.append($item), $("
") ); } else { return $(); } } return type === "hoot" ? this.renderHoot(itemData) : type === "lastlog" ? this.renderLastlog(itemData) : "Unknown item"; }.bind(this) ); this.$hootevents.empty(); this.$hootevents.append($els); audio.init(); }, /** Render Methods */ render: (template, object) => { if (!template) { console.error("No template", object); return $("
No template
"); } const rendered = Object.entries(object).reduce( (newTemplate, [key, value]) => newTemplate.replace(new RegExp(`{{${key}}}`, "g"), value), template ); return $(rendered); }, renderLastlog: function ({ username, lastseen }) { const age = get_age(lastseen); const age_ago = age === "now" ? age : `${age} ago`; return this.render(this.lastlogTemplate, { className: "hoot streamLastlog", username, age, opacity: 0.6, showAvatar: 0, hoot: "last seen " + age_ago, }); }, renderHoot: function ({ id, thread, date, username, hoot, comment, hidden, className, showAvatar, template, ...options }) { // console.log(hoot, comment); const age_opacity = get_age_opacity(date); return this.render(template || this.hootTemplate, { id, thread, username, className: className ? `hoot ${className}` : "hoot", image: profile_image(username), showAvatar: showAvatar === false ? 0 : 1, hoot: hoot || "
" + tidy_urls(comment, true) + "
", age: get_age(date), hoot_opacity: className === "first_post" ? 1.0 : age_opacity, age_opacity: age_opacity, ...options, }); }, renderHoots: function ({ hoots, className }) { const els = []; for (hoot of hoots) { els.push(this.renderHoot({ ...hoot, className })); } return els; }, renderThread: function ({ query, thread, comments, files, images }) { if (!thread || !thread.length) { console.error("Missing thread"); console.error(thread, comments, files, images); return ["
Missing thread!
"]; } thread = thread[0]; const isViewingKeyword = query.keyword === thread.keyword; const isViewingThread = query.thread === thread.id; // console.log(thread, comments, files, images); const postedToday = +new Date() / 1000 - thread.lastmodified < 86400; const age_opacity = get_age_opacity(thread.lastmodified); const thread_opacity = clamp( get_age_opacity(thread.lastmodified) + 0.2, 0.0, 1.0 ); const hasAudio = files.some((file) => AUDIO_REGEXP.test(file.filename)); const filteredImages = images .map((image) => [image, new RegExp(encodeURIComponent(image.filename))]) .filter( ([, filenameRegexp]) => !comments.some((comment) => filenameRegexp.test(comment.comment)) ) .map(([image]) => image); // console.log(filteredImages); // console.log(thread); const sortedFiles = this.sortFiles(files, thread.settings?.sort); const actions = [ hasAudio && { action: "play", label: "Play music" }, !isViewingThread && { action: "expand", label: "Expand" }, { action: "post", label: "Post" }, ] .filter((action) => !!action) .map( ({ action, label }) => "
·
" + `
${label}
` ) .join(""); return [ "
", this.renderHoot({ template: this.threadTemplate, thread: thread.id, hoot: `${thread.title}`, thread_opacity, actions, keyword_link: thread.keyword ? `${thread.keyword}` : "", username: thread.username, className: postedToday ? "isRecent" : "", date: thread.lastmodified, file_count: `${thread.file_count || 0} f.`, file_opacity: age_opacity * get_size_opacity(thread.file_count), comment_count: `${thread.comment_count || 0} c.`, comment_opacity: age_opacity * get_size_opacity(thread.comment_count), }), this.renderImages( isViewingThread || postedToday ? filteredImages : filteredImages.slice(0, 6) ), this.renderFiles( isViewingThread || postedToday ? sortedFiles : sortedFiles.slice(0, 10) ), ...this.renderHoots({ hoots: comments.slice(0, 1).map( trimComment({ isViewingThread, lines: 5, snippetSize: 512, cropSize: 256, }) ), className: "first_post", }), ...this.renderHoots({ hoots: isViewingThread || postedToday ? comments.slice(1).map( trimComment({ isViewingThread, lines: 1, snippetSize: 256, cropSize: 128, }) ) : comments .slice(1) .slice(-5) .map( trimComment({ isViewingThread, lines: 1, snippetSize: 256, cropSize: 128, }) ), }), "
", ]; }, renderImages: function (images) { if (!images.length) { return null; } const $table = $("
"); for (const image of images) { const $el = this.renderFile(this.imageTemplate, image); $table.append($el); } return $table; }, sortFiles: function (files, sort) { if (!files.length) { return []; } switch (sort) { case "name_asc": return files .map((file) => [file.filename.toLowerCase(), file]) .sort((a, b) => a[0].localeCompare(b[0])) .map(([, file]) => file); break; case "name_desc": return files .map((file) => [file.filename.toLowerCase(), file]) .sort((a, b) => b[0].localeCompare(a[0])) .map(([, file]) => file); break; case "date_asc": return files .map((file) => [file.date, file]) .sort((a, b) => a[0] - b[0]) .map(([, file]) => file); break; default: case "date_desc": return files .map((file) => [file.date, file]) .sort((a, b) => b[0] - a[0]) .map(([, file]) => file); break; case "size_asc": return files .map((file) => [file.size, file]) .sort((a, b) => a[0] - b[0]) .map(([, file]) => file); break; case "size_desc": return files .map((file) => [file.size, file]) .sort((a, b) => b[0] - a[0]) .map(([, file]) => file); break; } }, renderFiles: function (files) { const $table = $("
"); for (const file of files) { const $el = this.renderFile(this.fileTemplate, file); $table.append($el); } return $table; }, renderFile: function (template, file) { var size = hush_size(file.size); var datetime = verbose_date(file.date, true); var date_class = carbon_date(file.date); var link = make_link(file); return this.render(template, { id: file.id, username: file.username, link, filename: file.filename, age: get_age(file.date), age_opacity: get_age_opacity(file.date), date_class, date: datetime[0], // time: datetime[1], // size_class: size[0], size: size[1], }); }, agglutinate: ({ query, threads, comments, files, hootbox, lastlog }) => [ ...threads.map((thread) => [ thread.id, thread.createdate, "thread", thread, ]), ...comments .filter((comment) => comment.thread !== 1) .map((comment) => [comment.thread, comment.date, "comments", comment]), ...files.map((file) => [ file.thread, file.date, IMAGE_REGEXP.test(file.filename) ? "images" : "files", file, ]), ...(query.has_query ? [] : hootbox).map((hoot) => [ 1, hoot.date, "hoot", hoot, ]), ...(query.has_query ? [] : lastlog).map((user) => [ 1, user.lastseen, "lastlog", user, ]), ] .sort((a, b) => b[1] - a[1]) .reduce( ({ threadLookup, order }, [thread_id, date, type, data]) => { if (type === "hoot") { order.push({ type: "hoot", date, data }); } else if (type === "lastlog") { order.push({ type: "lastlog", date, data }); } else if (thread_id !== 1) { if (!(thread_id in threadLookup)) { threadLookup[thread_id] = { thread: [], comments: [], files: [], images: [], }; order.push({ type: "thread", date, thread_id }); } threadLookup[thread_id][type].push(data); } return { threadLookup, order }; }, { threadLookup: {}, order: [] } ), });