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) {
const isOpen = !!this.forms[thread];
// console.log("comment", thread, !!isOpen);
if (isOpen) {
this.onHideCommentForm(event, thread);
return;
}
$(event.target)
.closest(".actions")
.find(".addIcon")
.replaceWith($(ICONS.remove));
const $threadTitle = $(event.currentTarget)
.closest(".thread")
.find(".threadTitle");
const $form = $(this.commentFormTemplate);
$form.data("thread", thread);
$form.insertAfter($threadTitle);
$form.find("textarea").focus();
console.log($threadTitle);
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;
},
onHideCommentForm: function (event, thread) {
this.forms[thread].destroy();
this.forms[thread] = null;
$(event.target)
.closest(".actions")
.find(".removeIcon")
.replaceWith($(ICONS.add));
},
onSubmitComment: function (data, form) {
const { thread } = form.opt;
form.$el
.closest(".thread")
.find(".actions")
.find(".removeIcon")
.replaceWith($(ICONS.add));
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,
age_string: verbose_date(date).join(" "),
...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: ICONS.play,
},
{
action: "post",
label: "post",
icon: ICONS.add,
},
!isViewingThread &&
!isCompleteThread && {
action: "expand",
label: "expand",
icon: ICONS.expand,
},
]
.filter((action) => !!action)
.map(
({ action, icon, label }) =>
`
${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,
age_string: verbose_date(thread.lastmodified).join(" "),
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),
age_string: verbose_date(file.date).join(" "),
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: [] }
),
});