refactor: first pass on getting the client back into working order

(still broken, but loading as a baseline)
This commit is contained in:
Andy Burke 2025-11-08 17:15:26 -08:00
parent a5707e2f81
commit afeb6f75e8
23 changed files with 358 additions and 322 deletions

View file

@ -7,7 +7,7 @@ Bringing the BBS back.
These are in no particular order. Pull requests updating this section welcome for These are in no particular order. Pull requests updating this section welcome for
feature discussions. feature discussions.
- [X] should everything be an event in a topic? - [X] the core is a stream of events
- [X] get a first-pass podman/docker setup up - [X] get a first-pass podman/docker setup up
- [X] sign up - [X] sign up
- [X] check for logged in user session - [X] check for logged in user session
@ -21,14 +21,14 @@ feature discussions.
- [X] logout button - [X] logout button
- [ ] profile editing - [ ] profile editing
- [X] avatar uploads - [X] avatar uploads
- [X] chat topics - [X] chat channels
- [X] chat messages - [X] chat messages
- [ ] membership and presence - [ ] membership and presence
- [ ] add memberships to topics - [ ] add memberships to channels
- [ ] join to get notifications - [ ] join to get notifications
- [ ] join for additional permissions - [ ] join for additional permissions
- [ ] filters for allowing joining a topic based on criteria on the user? - [ ] filters for allowing joining a channel based on criteria on the user?
- [ ] display topic members somehwere - [ ] display channel members somehwere
- [ ] emit presence events on join/leave - [ ] emit presence events on join/leave
- [ ] display user presence - [ ] display user presence
- [ ] chat message actions - [ ] chat message actions
@ -88,7 +88,7 @@ feature discussions.
- [ ] if web notifications are enabled, emit on events - [ ] if web notifications are enabled, emit on events
- [ ] ability to mute - [ ] ability to mute
- [ ] users - [ ] users
- [ ] topics - [ ] channels
- [ ] tags (#tags?) - [ ] tags (#tags?)
- [ ] admin panel - [ ] admin panel
- [ ] add invite code generation - [ ] add invite code generation

View file

@ -0,0 +1,28 @@
# /api/channels
Interact with channels.
## POST /api/channels
Create a new channel.
```
export type CHANNEL = {
id: string; // unique id for this channel
name: string; // the name of the channel (max 128 characters)
icon?: string; // optional url for a channel icon
topic?: string; // optional channel topic
tags?: string[]; // optional tags for the channel
meta?: Record<string, any>; // optional metadata
limits: {
users: number;
user_messages_per_minute: number;
};
creator_id: string; // user_id of the topic creator
emojis: Record<string, string>; // either: string: emoji eg: { 'rofl: 🤣, ... } or { 'rofl': 🤣, 'blap': 'https://somewhere.someplace/image.jpg' }
};
```
## GET /api/channels
Get channels.

View file

@ -19,7 +19,7 @@ export async function GET(_req: Request, meta: Record<string, any>): Promise<Res
const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100); const limit = Math.min(parseInt(meta.query.limit ?? '100'), 100);
const channels = (await CHANNELS.all({ const channels = (await CHANNELS.all({
limit limit
})).map((topic_entry) => topic_entry.load()); })).map((channel_entry) => channel_entry.load());
return Response.json(channels, { return Response.json(channels, {
status: 200 status: 200

View file

@ -0,0 +1,7 @@
## PUT /api/events/:event_id
Update an event.
## DELETE /api/events/:event_id
Delete an event.

View file

@ -1,15 +1,11 @@
# /api/topics/:topic_id/events # /api/events
Interact with a events for a topic. Interact with events.
## GET /api/topics/:topic_id/events ## GET /api/events
Get events for the given topic. Get events.
## PUT /api/topics/:topic_id/events/:event_id ## POST /api/events
Update an event. Create an event.
## DELETE /api/topics/:topic_id/events/:event_id
Delete an event.

View file

@ -5,7 +5,7 @@ import parse_body from '../../../../../../utils/bodyparser.ts';
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
// PUT /api/users/:user_id/watches/:watch_id - Update topic // PUT /api/users/:user_id/watches/:watch_id - Update watch
PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => { PRECHECKS.PUT = [get_session, get_user, require_user, async (_req: Request, meta: Record<string, any>): Promise<Response | undefined> => {
const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? ''; const watch_id: string = meta.params?.watch_id?.toLowerCase().trim() ?? '';
@ -69,7 +69,7 @@ PRECHECKS.DELETE = [
return CANNED_RESPONSES.not_found(); return CANNED_RESPONSES.not_found();
} }
meta.topic = watch; meta.watch = watch;
const user_owns_watch = watch.creator_id === meta.user.id; const user_owns_watch = watch.creator_id === meta.user.id;
if (!user_owns_watch) { if (!user_owns_watch) {

View file

@ -4,7 +4,7 @@ import * as CANNED_RESPONSES from '../../../../../utils/canned_responses.ts';
import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts'; import { get_session, get_user, PRECHECK_TABLE, require_user } from '../../../../../utils/prechecks.ts';
import parse_body from '../../../../../utils/bodyparser.ts'; import parse_body from '../../../../../utils/bodyparser.ts';
import lurid from '@andyburke/lurid'; import lurid from '@andyburke/lurid';
import { TOPICS } from '../../../../../models/topic.ts'; import { CHANNELS } from '../../../../../models/channel.ts';
export const PRECHECKS: PRECHECK_TABLE = {}; export const PRECHECKS: PRECHECK_TABLE = {};
@ -99,34 +99,18 @@ export async function POST(req: Request, meta: Record<string, any>): Promise<Res
} }
}; };
const topic = await TOPICS.get(watch.topic_id); if (watch.channel) {
if (!topic) { const channel = await CHANNELS.get(watch.channel);
return Response.json({ if (!channel) {
errors: [{ return Response.json({
cause: 'invalid_topic_id', errors: [{
message: 'Could not find a topic with id: ' + watch.topic_id cause: 'invalid_channel_id',
}] message: 'Could not find a channel with id: ' + watch.channel
}, { }]
status: 400 }, {
}); status: 400
} });
}
const existing_watch: WATCH | undefined = (await WATCHES.find({
creator_id: meta.user.id,
topic_id: topic.id
}, {
limit: 1
})).shift()?.load();
if (existing_watch) {
return Response.json({
errors: [{
cause: 'existing_watch',
message: 'You already have a watch for this topic.'
}]
}, {
status: 400
});
} }
await WATCHES.create(watch); await WATCHES.create(watch);

View file

@ -12,6 +12,8 @@ import { INVITE_CODE, INVITE_CODES } from '../../../models/invites.ts';
// TODO: figure out a better solution for doling out permissions // TODO: figure out a better solution for doling out permissions
const DEFAULT_USER_PERMISSIONS: string[] = [ const DEFAULT_USER_PERMISSIONS: string[] = [
'channels.read',
'events.create.blurb', 'events.create.blurb',
'events.create.chat', 'events.create.chat',
'events.create.essay', 'events.create.essay',

View file

@ -362,21 +362,21 @@ button.primary {
body[data-perms*="self.read"] [data-requires-permission="self.read"], body[data-perms*="self.read"] [data-requires-permission="self.read"],
body[data-perms*="self.write"] [data-requires-permission="self.write"], body[data-perms*="self.write"] [data-requires-permission="self.write"],
body[data-perms*="topics.create"] [data-requires-permission="topics.create"], body[data-perms*="channels.create"] [data-requires-permission="channels.create"],
body[data-perms*="topics.read"] [data-requires-permission="topics.read"], body[data-perms*="channels.read"] [data-requires-permission="channels.read"],
body[data-perms*="topics.write"] [data-requires-permission="topics.write"], body[data-perms*="channels.write"] [data-requires-permission="channels.write"],
body[data-perms*="topics.blurbs.create"] [data-requires-permission="topics.blurbs.create"], body[data-perms*="events.create.blurb"] [data-requires-permission="events.create.blurb"],
body[data-perms*="topics.blurbs.read"] [data-requires-permission="topics.blurbs.read"], body[data-perms*="events.read.blurb"] [data-requires-permission="events.read.blurb"],
body[data-perms*="topics.blurbs.write"] [data-requires-permission="topics.blurbs.write"], body[data-perms*="events.write.blurb"] [data-requires-permission="events.write.blurb"],
body[data-perms*="topics.chat.create"] [data-requires-permission="topics.chat.create"], body[data-perms*="events.create.chat"] [data-requires-permission="events.create.chat"],
body[data-perms*="topics.chat.read"] [data-requires-permission="topics.chat.read"], body[data-perms*="events.read.chat"] [data-requires-permission="events.read.chat"],
body[data-perms*="topics.chat.write"] [data-requires-permission="topics.chat.write"], body[data-perms*="events.write.chat"] [data-requires-permission="events.write.chat"],
body[data-perms*="topics.essays.create"] [data-requires-permission="topics.essays.create"], body[data-perms*="events.create.essay"] [data-requires-permission="events.create.essay"],
body[data-perms*="topics.essays.read"] [data-requires-permission="topics.essays.read"], body[data-perms*="events.read.essay"] [data-requires-permission="events.read.essay"],
body[data-perms*="topics.essays.write"] [data-requires-permission="topics.essays.write"], body[data-perms*="events.write.essay"] [data-requires-permission="events.write.essay"],
body[data-perms*="topics.posts.create"] [data-requires-permission="topics.posts.create"], body[data-perms*="events.create.post"] [data-requires-permission="events.create.post"],
body[data-perms*="topics.posts.read"] [data-requires-permission="topics.posts.read"], body[data-perms*="events.read.post"] [data-requires-permission="events.read.post"],
body[data-perms*="topics.posts.write"] [data-requires-permission="topics.posts.write"], body[data-perms*="events.write.post"] [data-requires-permission="events.write.post"],
body[data-perms*="users.read"] [data-requires-permission="users.read"], body[data-perms*="users.read"] [data-requires-permission="users.read"],
body[data-perms*="users.write"] [data-requires-permission="users.write"], body[data-perms*="users.write"] [data-requires-permission="users.write"],
body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] { body[data-perms*="files.write.own"] [data-requires-permission="files.write.own"] {

View file

@ -1,5 +1,5 @@
const HASH_EXTRACTOR = /^\#\/topic\/(?<topic_id>[A-Za-z\-]+)\/?(?<view>\w+)?/gm; const HASH_EXTRACTOR = /^\#\/(?<view>\w+)(?:\/channel\/(?<channel_id>[A-Za-z\-]+)\/?)?/gm;
const UPDATE_TOPICS_FREQUENCY = 60_000; const UPDATE_CHANNELS_FREQUENCY = 60_000;
const APP = { const APP = {
user: undefined, user: undefined,
@ -52,7 +52,7 @@ const APP = {
extract_url_hash_info: async function () { extract_url_hash_info: async function () {
HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls HASH_EXTRACTOR.lastIndex = 0; // ugh, need this to have this work on multiple exec calls
const { const {
groups: { topic_id, view }, groups: { view, channel_id },
} = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? { } = HASH_EXTRACTOR.exec(window.location.hash ?? "") ?? {
groups: {}, groups: {},
}; };
@ -61,36 +61,36 @@ const APP = {
console.dir({ console.dir({
url: window.location.href, url: window.location.href,
hash: window.location.hash, hash: window.location.hash,
topic_id,
view, view,
channel_id,
}); });
*/ */
if (!document.body.dataset.topic || document.body.dataset.topic !== topic_id) { if (!document.body.dataset.channel || document.body.dataset.channel !== channel_id) {
const previous = document.body.dataset.topic; const previous = document.body.dataset.channel;
/* /*
console.dir({ console.dir({
topic_changed: { channel_changed: {
detail: { detail: {
previous, previous,
topic_id, channel_id,
}, },
}, },
}); });
*/ */
document.body.dataset.topic = topic_id; document.body.dataset.channel = channel_id;
this._emit( 'topic_changed', { this._emit( 'channel_changed', {
previous, previous,
topic_id channel_id
}); });
if (!topic_id) { if (!channel_id) {
const first_topic_id = this.TOPICS.TOPIC_LIST[0]?.id; const first_channel_id = this.CHANNELS.CHANNEL_LIST[0]?.id;
if (first_topic_id) { if (first_channel_id) {
window.location.hash = `/topic/${first_topic_id}/chat`; // TODO: allow a different default than chat window.location.hash = `/chat/channel/${first_channel_id}`; // TODO: allow a different default than chat
} }
} }
} }
@ -149,7 +149,7 @@ const APP = {
} }
window.addEventListener("locationchange", this.extract_url_hash_info.bind( this )); window.addEventListener("locationchange", this.extract_url_hash_info.bind( this ));
window.addEventListener("locationchange", this.TOPICS.update ); window.addEventListener("locationchange", this.CHANNELS.update );
this.check_if_logged_in(); this.check_if_logged_in();
this.extract_url_hash_info(); this.extract_url_hash_info();
@ -162,7 +162,7 @@ const APP = {
document.body.dataset.user = JSON.stringify(user); document.body.dataset.user = JSON.stringify(user);
document.body.dataset.perms = user.permissions.join(":"); document.body.dataset.perms = user.permissions.join(":");
this.TOPICS.update(); this.CHANNELS.update();
this.user_servers = []; this.user_servers = [];
try { try {
@ -231,56 +231,56 @@ const APP = {
}, },
}, },
TOPICS: { CHANNELS: {
_last_topic_update: undefined, _last_channel_update: undefined,
_update_topics_timeout: undefined, _update_channels_timeout: undefined,
TOPIC_LIST: [], CHANNEL_LIST: [],
update: async () => { update: async () => {
const now = new Date(); const now = new Date();
const time_since_last_update = now - (APP.TOPICS._last_topic_update ?? 0); const time_since_last_update = now - (APP.CHANNELS._last_channel_update ?? 0);
if (time_since_last_update < UPDATE_TOPICS_FREQUENCY / 2) { if (time_since_last_update < UPDATE_CHANNELS_FREQUENCY / 2) {
return; return;
} }
if (APP.TOPICS._update_topics_timeout) { if (APP.CHANNELS._update_channels_timeout) {
clearTimeout(APP.TOPICS._update_topics_timeout); clearTimeout(APP.CHANNELS._update_channels_timeout);
APP.TOPICS._update_topics_timeout = undefined; APP.CHANNELS._update_channels_timeout = undefined;
} }
try { try {
const topics_response = await api.fetch("/api/topics"); const channels_response = await api.fetch("/api/channels");
if (topics_response.ok) { if (channels_response.ok) {
const new_topics = await topics_response.json(); const new_channels = await channels_response.json();
const has_differences = const has_differences =
APP.TOPICS.TOPIC_LIST.length !== new_topics.length || APP.CHANNELS.CHANNEL_LIST.length !== new_channels.length ||
new_topics.some((topic, index) => { new_channels.some((channel, index) => {
return ( return (
APP.TOPICS.TOPIC_LIST[index]?.id !== topic.id || APP.CHANNELS.CHANNEL_LIST[index]?.id !== channel.id ||
APP.TOPICS.TOPIC_LIST[index]?.name !== topic.name APP.CHANNELS.CHANNEL_LIST[index]?.name !== channel.name
); );
}); });
if (has_differences) { if (has_differences) {
APP.TOPICS.TOPIC_LIST = [...new_topics]; APP.CHANNELS.CHANNEL_LIST = [...new_channels];
APP._emit( 'topics_updated', { APP._emit( 'channels_updated', {
topics: APP.TOPICS.TOPIC_LIST channels: APP.CHANNELS.CHANNEL_LIST
}); });
} }
APP.TOPICS._last_topic_update = now; APP.CHANNELS._last_channel_update = now;
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
APP.TOPICS._update_topics_timeout = setTimeout( APP.CHANNELS._update_channels_timeout = setTimeout(
APP.TOPICS.update, APP.CHANNELS.update,
UPDATE_TOPICS_FREQUENCY, UPDATE_CHANNELS_FREQUENCY,
); );
// now that we have topics, make sure our url is all good // now that we have channels, make sure our url is all good
APP.extract_url_hash_info(); APP.extract_url_hash_info();
}, },
}, },

View file

@ -139,6 +139,7 @@ document.addEventListener("DOMContentLoaded", () => {
<div class="icon close" onclick="clear_reactions_popup()"></div> <div class="icon close" onclick="clear_reactions_popup()"></div>
<form <form
id="reactions-selection-form" id="reactions-selection-form"
action="/api/events"
data-smart="true" data-smart="true"
method="POST" method="POST"
on_reply="async (event) => { clear_reactions_popup(); await document.querySelectorAll( '[data-feed]' ).forEach((feed) => feed.__render(event)); }" on_reply="async (event) => { clear_reactions_popup(); await document.querySelectorAll( '[data-feed]' ).forEach((feed) => feed.__render(event)); }"
@ -167,6 +168,12 @@ document.addEventListener("DOMContentLoaded", () => {
generator="() => { return APP.user?.id; }" generator="() => { return APP.user?.id; }"
/> />
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input <input
type="hidden" type="hidden"
name="timestamps.created" name="timestamps.created"
@ -198,12 +205,6 @@ document.addEventListener("DOMContentLoaded", () => {
document.body.appendChild(reactions_popup); document.body.appendChild(reactions_popup);
reactions_popup_form = document.getElementById("reactions-selection-form"); reactions_popup_form = document.getElementById("reactions-selection-form");
APP.on("topic_changed", ({ topic_id }) => {
const reaction_topic_id = topic_id ?? document.body.dataset.topic;
reactions_popup_form.action = reaction_topic_id
? `/api/topics/${reaction_topic_id}/events`
: "";
});
reactions_popup_search_input = document.getElementById("reactions-search-input"); reactions_popup_search_input = document.getElementById("reactions-search-input");
reactions_popup_parent_id_input = reactions_popup_form.querySelector('[name="parent_id"]'); reactions_popup_parent_id_input = reactions_popup_form.querySelector('[name="parent_id"]');

View file

@ -1,38 +1,4 @@
<script> <script>
APP.on("topics_updated", ({ topics }) => {
const topic_list = document.getElementById("topic-list");
topic_list.innerHTML = "";
for (const topic of topics.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${topic.id}" class="topic" data-topic-selector-for="${topic.id}"><a href="#/topic/${topic.id}/chat">${topic.name}</a></li>`,
);
}
});
function update_topic_indicators(event) {
document
.querySelectorAll("[data-topic-selector-for]")
.forEach((element) => element.classList.remove("active"));
const new_topic_id = event?.detail?.topic_id ?? document.body.dataset.topic;
if (new_topic_id) {
document
.querySelectorAll(`[data-topic-selector-for="${new_topic_id}"]`)
.forEach((element) => element.classList.add("active"));
}
for ( const watch of APP.user_watches ) {
// find the topic indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("topics_updated", update_topic_indicators);
APP.on("topic_changed", update_topic_indicators);
APP.on("user_logged_in", update_topic_indicators);
function clear_invite_popup() { function clear_invite_popup() {
document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove()); document.body.querySelectorAll(".invitepopover").forEach((element) => element.remove());
} }
@ -106,50 +72,6 @@
<button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button> <button onclick="( () => document.querySelectorAll( '.invitepopover' ).forEach( (element) => element.remove() ) )()">Done</button>
</div>`; </div>`;
} }
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const topic_selector = event.target.closest("li.topic");
if (!topic_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: topic_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
</script> </script>
<style type="text/css"> <style type="text/css">
@ -284,46 +206,6 @@
line-height: 2rem; line-height: 2rem;
} }
#sidebar #topic-creation-container {
margin-top: 0.5rem;
}
#sidebar #topic-creation-container #toggle-topic-creation-form-button {
transform: scale(0.8);
}
#sidebar .topic-list {
list-style-type: none;
margin-left: 1rem;
}
#sidebar .topic-list > li.topic a:before {
position: absolute;
left: -1.75rem;
top: 0;
font-weight: bold;
font-size: x-large;
content: "#";
color: var(--text);
}
#sidebar .topic-list > li.topic a {
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
line-height: 1.5rem;
font-weight: bold;
font-size: large;
margin-left: 1.75rem;
text-decoration: none;
margin-bottom: 0.75rem;
}
#sidebar .topic-list > li.topic.active a {
color: var(--accent);
}
.invitepopover { .invitepopover {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
@ -760,69 +642,7 @@
<button class="primary">Log Out</button> <button class="primary">Log Out</button>
</form> </form>
<div class="topics-container"> <div id="sidebar-dynamic-container">
<div style="margin-bottom: 1rem">
<span class="title">topics</span>
</div>
<ul id="topic-list" class="topic-list"></ul>
<div id="topic-creation-container" data-requires-permission="topics.create">
<button
id="toggle-topic-creation-form-button"
onclick="((event) => {
event.preventDefault();
const topic_create_form = document.getElementById( 'topic-create' );
topic_create_form.style[ 'height' ] = topic_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="topic-create"
data-smart="true"
action="/api/topics"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-topic-name-input"
type="text"
name="name"
value=""
placeholder="new topic"
/>
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const topic_create_form = document.getElementById("topic-create");
const new_topic_name_input =
document.getElementById("new-topic-name-input");
form.on_reply = (new_topic) => {
const topic_list = document.getElementById("topic-list");
topic_list.querySelectorAll( 'li' ).forEach( (li) => li.classList.remove( 'active' ) );
topic_list.insertAdjacentHTML(
"beforeend",
`<li id="topic-selector-${new_topic.id}" class="topic active"><a href="#/topic/${new_topic.id}">${new_topic.name}</a></li>`,
);
new_topic_name_input.value = "";
window.location.hash = `/topic/${new_topic.id}/chat`;
topic_create_form.style["height"] = "0";
};
}
</script>
</form>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -148,8 +148,8 @@
<div <div
id="blurbs-list" id="blurbs-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.blurbs.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.blurb' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=blurb,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=blurb,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -159,7 +159,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {

View file

@ -25,7 +25,7 @@
display: inline-block; display: inline-block;
} }
</style> </style>
<div class="new-blurb-container" data-requires-permission="topics.blurbs.create"> <div class="new-blurb-container" data-requires-permission="events.create.blurb">
<label> <label>
<input type="checkbox" collapse-toggle /> <input type="checkbox" collapse-toggle />
<i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i> <i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i>
@ -33,6 +33,7 @@
</label> </label>
<form <form
data-smart="true" data-smart="true"
action="/api/events"
method="POST" method="POST"
class="blurb-creation-form collapsible" class="blurb-creation-form collapsible"
style=" style="
@ -40,7 +41,6 @@
width: 100%; width: 100%;
transition: all 0.5s; transition: all 0.5s;
" "
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'blurbs-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >

View file

@ -1,3 +1,3 @@
# Calendar # Calendar
The calendar should help people coordinate events around a topic. The calendar should help people coordinate events.

View file

@ -0,0 +1,202 @@
<script>
APP.on("channels_updated", ({ channels }) => {
const channel_list = document.getElementById("channel-list");
channel_list.innerHTML = "";
for (const channel of channels.sort((lhs, rhs) => lhs.name.localeCompare(rhs.name))) {
channel_list.insertAdjacentHTML(
"beforeend",
`<li id="channel-selector-${channel.id}" class="channel" data-channel-selector-for="${channel.id}"><a href="#/channel/${channel.id}/chat">${channel.name}</a></li>`,
);
}
});
function update_channel_indicators(event) {
document
.querySelectorAll("[data-channel-selector-for]")
.forEach((element) => element.classList.remove("active"));
const new_channel_id = event?.detail?.channel_id ?? document.body.dataset.channel;
if (new_channel_id) {
document
.querySelectorAll(`[data-channel-selector-for="${new_channel_id}"]`)
.forEach((element) => element.classList.add("active"));
}
for ( const watch of APP.user_watches ) {
// find the channel indicator for this watch
// if there is new stuff - TODO implement a HEAD for getting latest event id?
// add a class of 'new-content'
}
}
APP.on("channels_updated", update_channel_indicators);
APP.on("channel_changed", update_channel_indicators);
APP.on("user_logged_in", update_channel_indicators);
document.addEventListener(
"contextmenu",
(event) => {
if (!event.target?.closest("#sidebar")) {
return;
}
const channel_selector = event.target.closest("li.channel");
if (!channel_selector) {
return;
}
event.preventDefault();
const context_menu = document.getElementById("sidebar-context-menu");
context_menu.dataset.prepare = true;
const position = get_best_coords_for_popup({
target_element: channel_selector,
popup: {
width: context_menu.getBoundingClientRect().width,
height: context_menu.getBoundingClientRect().height,
},
offset: {
x: 4,
y: 4,
},
});
context_menu.style.left = position.x + "px";
context_menu.style.top = position.y + "px";
context_menu.dataset.show = true;
},
false,
);
document.addEventListener("click", (event) => {
if (!event.target?.closest("#sidebar-context-menu")) {
const context_menu = document.getElementById("sidebar-context-menu");
delete context_menu.dataset.show;
delete context_menu.dataset.prepare;
}
});
APP.on( 'view_changed', ( {view} ) => {
if ( !view === 'chat' ) {
return;
}
const sidebar_dynamic_container = document.getElementById( 'sidebar-dynamic-container');
if ( !sidebar_dynamic_container ) {
console.error( 'could not get #sidebar-dynamic-container' );
return;
}
const template = document.getElementById( 'channel-list-template');
sidebar_dynamic_container.innerHTML = template.innerHTML.trim();
APP.CHANNELS.update();
});
</script>
<style>
#channel-list-container #channel-creation-container {
margin-top: 0.5rem;
}
#channel-list-container #channel-creation-container #toggle-channel-creation-form-button {
transform: scale(0.8);
}
#channel-list-container .channel-list {
list-style-type: none;
margin-left: 1rem;
}
#channel-list-container .channel-list > li.channel a:before {
position: absolute;
left: -1.75rem;
top: 0;
font-weight: bold;
font-size: x-large;
content: "#";
color: var(--text);
}
#channel-list-container .channel-list > li.channel a {
position: relative;
display: block;
width: 100%;
min-height: 1.5rem;
line-height: 1.5rem;
font-weight: bold;
font-size: large;
margin-left: 1.75rem;
text-decoration: none;
margin-bottom: 0.75rem;
}
#channel-list-container .channel-list > li.channel.active a {
color: var(--accent);
}
</style>
<template id="channel-list-template">
<div id="channel-list-container">
<div style="margin-bottom: 1rem">
<span class="title">channels</span>
</div>
<ul id="channel-list" class="channel-list"></ul>
<div id="channel-creation-container" data-requires-permission="channels.create">
<button
id="toggle-channel-creation-form-button"
onclick="((event) => {
event.preventDefault();
const channel_create_form = document.getElementById( 'channel-create' );
channel_create_form.style[ 'height' ] = channel_create_form.style[ 'height' ] === '5rem' ? '0' : '5rem';
})(event)"
>
<div class="icon plus"></div>
</button>
<form
id="channel-create"
data-smart="true"
action="/api/channels"
method="POST"
style="
margin-top: 1rem;
width: 100%;
overflow: hidden;
height: 0;
overflow: hidden;
transition: all 0.5s;
"
>
<input
id="new-channel-name-input"
type="text"
name="name"
value=""
placeholder="new channel"
/>
<input type="submit" hidden />
<script>
{
const form = document.currentScript.closest("form");
const channel_create_form = document.getElementById("channel-create");
const new_channel_name_input =
document.getElementById("new-channel-name-input");
form.on_reply = (new_channel) => {
const channel_list = document.getElementById("channel-list");
channel_list.querySelectorAll( 'li' ).forEach( (li) => li.classList.remove( 'active' ) );
channel_list.insertAdjacentHTML(
"beforeend",
`<li id="channel-selector-${new_channel.id}" class="channel active"><a href="#/chat/channel/${new_channel.id}">${new_channel.name}</a></li>`,
);
new_channel_name_input.value = "";
window.location.hash = `/chat/channel/${new_channel.id}`;
channel_create_form.style["height"] = "0";
};
}
</script>
</form>
</div>
</div>
</template>

View file

@ -21,8 +21,8 @@
<div <div
id="chat-content" id="chat-content"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.chat.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.chat' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=chat,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=chat,reaction&channel=${ document.body.dataset.channel }&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="append" data-insert="append"
@ -32,7 +32,7 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); }); APP.on("channel_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
const time_tick_tock_timeout = 60_000; const time_tick_tock_timeout = 60_000;
@ -147,7 +147,8 @@
<form <form
id="chat-entry" id="chat-entry"
data-smart="true" data-smart="true"
data-requires-permission="topics.chat.write" action="/api/events"
data-requires-permission="events.write.chat"
method="POST" method="POST"
class="post-creation-form collapsible" class="post-creation-form collapsible"
style=" style="
@ -158,15 +159,6 @@
on_reply="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'chat-content' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >
<script>
{
const form = document.currentScript.closest("form");
APP.on( "topic_changed", ({ topic_id }) => {
form.action = topic_id ? `/api/topics/${topic_id}/events` : "";
});
}
</script>
<input type="hidden" name="type" value="chat" /> <input type="hidden" name="type" value="chat" />
<input <input
@ -188,6 +180,12 @@
generator="() => { return APP.user?.id; }" generator="() => { return APP.user?.id; }"
/> />
<input
type="hidden"
name="channel"
generator="() => { return document.body.dataset.channel; }"
/>
<input <input
type="hidden" type="hidden"
name="timestamps.created" name="timestamps.created"
@ -233,3 +231,4 @@
</div> </div>
</div> </div>
</div> </div>
<!-- #include file="./channel_sidebar.html" -->

View file

@ -115,8 +115,8 @@
<div <div
id="essays-list" id="essays-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.essay' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=essay,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=essay,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -126,7 +126,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {

View file

@ -22,7 +22,7 @@
display: inline-block; display: inline-block;
} }
</style> </style>
<div class="new-essay-container" data-requires-permission="topics.essays.create"> <div class="new-essay-container" data-requires-permission="events.create.essay">
<label> <label>
<input type="checkbox" collapse-toggle /> <input type="checkbox" collapse-toggle />
<i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i> <i class="icon plus" style="display: inline-block; margin-right: 0.5rem"></i>
@ -31,8 +31,8 @@
<form <form
data-smart="true" data-smart="true"
method="POST" method="POST"
action="/api/events"
class="essay-creation-form collapsible" class="essay-creation-form collapsible"
url="/api/topics/${ document.body.dataset.topic }/events"
style=" style="
margin-top: 1rem margin-top: 1rem
width: 100%; width: 100%;

View file

@ -145,8 +145,8 @@
<div <div
id="posts-list" id="posts-list"
data-feed data-feed
data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'topics.essays.read' ) !== -1" data-precheck="!!document.body.dataset.user && document.body.dataset.user.indexOf( 'events.read.post' ) !== -1"
data-source="/api/topics/${ document.body.dataset.topic }/events?type=post,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }" data-source="/api/events?type=post,reaction&limit=100&sort=newest&wait=true&after_id=${ feed.__newest_id ?? 'able-able-able-able-able-able-able-able-able-able' }"
data-longpolling="true" data-longpolling="true"
data-reverse="true" data-reverse="true"
data-insert="prepend" data-insert="prepend"
@ -156,7 +156,6 @@
{ {
const feed = document.currentScript.closest("[data-feed]"); const feed = document.currentScript.closest("[data-feed]");
APP.on("topic_changed", () => { feed.__reset && feed.__reset(); });
APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); }); APP.on("user_logged_in", () => { feed.__reset && feed.__reset(); });
feed.__target_element = (item) => { feed.__target_element = (item) => {

View file

@ -6,15 +6,15 @@
</label> </label>
<form <form
data-smart="true" data-smart="true"
data-requires-permission="topics.posts.create" data-requires-permission="events.create.post"
method="POST" method="POST"
action="/api/events"
class="post-creation-form collapsible" class="post-creation-form collapsible"
style=" style="
margin-top: 1rem margin-top: 1rem
width: 100%; width: 100%;
transition: all 0.5s; transition: all 0.5s;
" "
url="/api/topics/${ document.body.dataset.topic }/events"
on_reply="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }" on_reply="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.remove('sending'); }"
on_parsed="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }" on_parsed="async (event) => { await document.getElementById( 'posts-list' ).__render(event); document.getElementById(event.id)?.classList.add('sending'); }"
> >

View file

@ -1,3 +1,3 @@
# Resources # Resources
Resources should be a wiki for organizing community knowledge on a topic. Resources should be a wiki for organizing community knowledge.

View file

@ -14,7 +14,7 @@
const tab_selector = event.target; const tab_selector = event.target;
const view = tab_selector.dataset.view; const view = tab_selector.dataset.view;
if (view) { if (view) {
window.location.hash = `/topic/${document.body.dataset.topic}/${view}`; window.location.hash = `/${view}${ document.body.dataset.channel ? `/channel/${ document.body.dataset.channel }` : '' }`;
} }
}); });
} }