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.threadFormTemplate = this.$(".threadFormTemplate").html(); this.commentFormTemplate = this.$(".commentFormTemplate").html(); this.onClickLink = this.onClickLink.bind(this); this.onClickFilename = this.onClickFilename.bind(this); this.forms = {}; }, /** * Events */ 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.currentTarget).data("action"); const thread = $(event.currentTarget) .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.onShowCommentForm(event, thread); break; } }, onPlay: function (event, thread) { // $.get(`/api/stream?thread=${thread}`).then((response) => { console.log(response); let settings; try { settings = JSON.parse(response.threads[0].settings); } catch (error) {} audio.index( this.sortFiles(response.files, settings ? settings.sort : null) ); 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 expandedThread = { 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, }; const $thread = this.renderThread(expandedThread); $(event.target).closest(".thread").replaceWith($thread); this.state.threadLookup[thread] = expandedThread; this.state.expandedThreads[thread] = true; }.bind(this) ); }, onShowCommentForm: function (event, thread) { if (this.forms[thread]) { this.forms[thread].destroy(); this.forms[thread] = null; return; } const $threadTitle = $(event.target).closest(".threadTitle"); const $form = $(this.commentFormTemplate); $form.data("thread", thread); $form.insertAfter($threadTitle); $form.find("textarea").focus(); const commentForm = new CommentForm({ parent: this }); commentForm.action = `/api/thread/${thread}/comment`; commentForm.setElement($form); commentForm.initialize({ parent: this, onSubmit: this.onSubmitComment.bind(this), thread, }); this.forms[thread] = commentForm; }, onSubmitComment: function (data, form) { const { thread } = form.opt; form.destroy(); this.forms[thread] = null; const current = this.state.threadLookup[thread]; let files, images; if (data.files) { const fileIndex = data.files.map((file) => [ IMAGE_REGEXP.test(file.filename), file, ]); const images = fileIndex.filter((pair) => pair[0]).map((pair) => pair[1]); const non_images = fileIndex .filter((pair) => !pair[0]) .map((pair) => pair[1]); files = [non_images, ...current.files]; images = [images, ...current.images]; } else { files = current.files; images = current.images; } const comments = data.comment ? [data.comment, ...current.comments] : current.comments; this.state.threadLookup = { ...this.state.threadLookup, [thread]: { ...current, thread: [ { ...current.thread[0], file_count: current.file_count + (data.files ? data.files.length : 0), comment_count: current.comment_count + (data.comment ? 1 : 0), lastmodified: Date.now() / 1000, }, ], comments, images, files, }, }; this.build(this.state.filters); }, /** * Loader */ load: function (data, filters) { this.state = { ...this.agglutinate(data), data, filters, expandedThreads: {}, }; this.build(filters); }, build: function (filters) { this.state.filters = 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.$el.toggleClass("streamThread", !!data.query.thread); this.$el.toggleClass("streamKeyword", !!data.query.username); this.$el.toggleClass("streamUser", !!data.query.keyword); 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; const isExpanded = this.state.expandedThreads[thread.id] === true; const isCompleteThread = !!( thread.file_count === files.length + images.length && thread.comment_count === comments.length ); // 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", icon: "play" }, { action: "post", label: "post", icon: "edit" }, !isViewingThread && !isCompleteThread && { action: "expand", label: "expand", icon: "expand", }, ] .filter((action) => !!action) .map( ({ action, icon, label }) => `
${ICONS[icon]}
` ) .join(""); /** * Assemble the components that we're going to render */ const threadTitle = { 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), }; const topImages = isViewingThread || postedToday ? filteredImages : filteredImages.slice(0, 6); const fileList = isViewingThread || postedToday ? sortedFiles : sortedFiles.slice(0, 10); const firstPost = { hoots: comments.slice(0, 1).map( trimComment({ isViewingThread, lines: 5, snippetSize: 512, cropSize: 256, }) ), className: "first_post", }; const restOfComments = { 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, }) ), }; return [ "
", this.renderHoot(threadTitle), this.renderImages(topImages), this.renderFiles(fileList), ...this.renderHoots(firstPost), ...this.renderHoots(restOfComments), "
", ]; }, 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: [] } ), });