waffle/frontend/src/stores.js
2023-07-22 18:01:22 +03:00

928 lines
28 KiB
JavaScript

import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway";
import logger from "./logging";
import { methods, remoteCall, remoteSignal } from "./request";
import { getItem, setItem } from "./storage";
const storeLog = logger("Store");
let storeCallbackQueue = [];
class Store {
constructor(value=null, name="[no name]") {
this._handlers = new Set();
this._pipes = new Set();
this.value = value;
this.name = name;
this._isInBatch = false;
}
log(...e) {
return storeLog(`[${this.name}]`, ...e);
}
// like subscribe, but without initially calling the handler
on(handler) {
storeLog(`[Watch] (${this.name})`, "handler:", handler);
this._handlers.add(handler);
return () => {
storeLog(`[Unsubscribe] (${this.name})`);
this._handlers.delete(handler);
};
}
subscribe(handler) {
storeLog(`[Subscribe] (${this.name})`, "handler:", handler, "value:", this.value);
this._handlers.add(handler);
handler(this.value);
return () => {
storeLog(`[Unsubscribe] (${this.name})`);
this._handlers.delete(handler);
};
}
pipe(handler) {
storeLog(`[Pipe] (${this.name})`, "handler:", handler);
this._pipes.add(handler);
return () => {
storeLog(`[Remove Pipe] (${this.name})`);
this._pipes.delete(handler);
};
}
set(value) {
if (value === this.value)
return;
this.value = value;
this.updated();
}
// like set(), but without checking if the value is the same
emit(value) {
this.value = value;
this.updated();
}
flushCallbackQueue() {
const queueCount = storeCallbackQueue.length;
storeLog(`[Flush] Flushing ${queueCount} callbacks from queue`, storeCallbackQueue);
const start = performance.now();
let count = 0;
for (let i = 0; i < storeCallbackQueue.length; i++) {
storeCallbackQueue[i][0](storeCallbackQueue[i][1]);
count++;
}
storeCallbackQueue.length = 0;
const delta = performance.now() - start;
storeLog(`[Flush] Flushed ${count} callbacks from queue, took ${delta}ms`);
}
startBatch() {
this._isInBatch = true;
}
endBatch() {
if (this._isInBatch) {
this._isInBatch = false;
this.updated();
}
}
updated() {
if (this._isInBatch) {
return;
}
this.value = this._applyPipes(this.value);
if (!this._handlers.size) return;
storeLog(`[Update] (${this.name}) Will queue ${this._handlers.size} handlers`, "value:", this.value, "handlers:", this._handlers);
const isRootNode = storeCallbackQueue.length === 0;
for (const handler of this._handlers) {
storeCallbackQueue.push([handler, this.value]);
}
if (isRootNode) {
this.flushCallbackQueue();
}
}
_applyPipes(value) {
this._pipes.forEach(p => {
value = p(value);
});
return value;
}
}
function createAction(name, handler) {
const store = new Store(null, name);
store.on(handler);
return store;
}
class StorageItemStore extends Store {
constructor(key) {
super(getItem(key), `StorageItemStore[key=${key}]`);
this.on(e => setItem(key, e));
}
}
class ChannelsStore extends Store {
constructor() {
super(gateway.channels || [], "ChannelsStore");
gateway.subscribe(GatewayEventType.Ready, ({ channels }) => {
this.value = channels;
this.updated();
});
gateway.subscribe(GatewayEventType.ChannelCreate, (channel) => {
this.value.push(channel);
this.updated();
});
gateway.subscribe(GatewayEventType.ChannelDelete, ({ id }) => {
const index = this.value.findIndex(e => e.id === id);
if (index === -1)
return;
this.value.splice(index, 1);
this.updated();
});
gateway.subscribe(GatewayEventType.ChannelUpdate, (data) => {
const index = this.value.findIndex(e => e.id === data.id);
if (index === -1)
return;
if (!this.value[index])
return;
this.value[index] = data;
this.updated();
});
}
}
class GatewayStatusStore extends Store {
constructor() {
super({ ready: gateway.authenticated }, "GatewayStatusStore");
gateway.subscribe(GatewayEventType.Close, () => {
this.value.ready = false;
this.updated();
});
gateway.subscribe(GatewayEventType.Ready, () => {
this.value.ready = true;
this.updated();
});
}
}
class UserInfoStore extends Store {
constructor() {
super(null, "UserInfoStore");
gateway.subscribe(GatewayEventType.Ready, ({ user }) => {
this.value = user;
this.updated();
});
gateway.subscribe(GatewayEventType.UserUpdate, (user) => {
if (this.value && this.value.id === user.id) {
this.value = user;
this.updated();
}
});
}
}
class MessageStore extends Store {
constructor(channelId) {
super([], `MessageStore[channelId=${channelId}]`);
this.channelId = channelId;
this.isCollectingOldMessages = true;
this.didDoInitialLoad = false;
}
_recomputeMessages() {
const start = performance.now();
for (let i = 0; i < this.value.length; i++) {
this.value[i] = this._processMessage(this.value[i], this.value[i - 1]);
}
const delta = performance.now() - start;
this.log(`[_recomputeMessages] Computed ${this.value.length} messages in ${delta}ms`);
}
_processMessage(message, previous=null) {
message._createdAtDate = message._createdAtDate || new Date(parseInt(message.created_at));
message._createdAtTimeString = message._createdAtTimeString || new Intl.DateTimeFormat(getItem("ui:locale"), { hour: "numeric", minute: "numeric" }).format(message._createdAtDate);
message._createdAtDateString = message._createdAtDateString || message._createdAtDate.toLocaleDateString();
message._mentions = false;
message._editable = false;
message._clumped = false;
message._aboveDateMarker = null;
message._viaBadge = null;
message._effectiveAuthor = message.author_username;
if (message.nick_username && message.nick_username !== "") {
message._effectiveAuthor = message.nick_username;
message._viaBadge = message.author_username;
}
if (userInfoStore.value && message.content.includes("@" + userInfoStore.value.username)) {
message._mentions = true;
}
if (userInfoStore.value && (message.author_id === userInfoStore.value.id || userInfoStore.value.is_superuser)) {
message._editable = true;
}
if (previous && (message._createdAtDate.getTime() - previous._createdAtDate.getTime()) <= 100 * 1000 && message.author_id === previous.author_id && message._effectiveAuthor === previous._effectiveAuthor && message._viaBadge === previous._viaBadge) {
message._clumped = true;
}
if (!previous || (previous._createdAtDateString !== message._createdAtDateString && !message._aboveDateMarker)) {
message._aboveDateMarker = new Intl.DateTimeFormat(getItem("ui:locale"), { month: "long", day: "numeric", year: "numeric" }).format(message._createdAtDate);
}
return message;
}
setMessage(id, message) {
const index = this.value.findIndex(e => e.id === id);
if (index === -1)
return;
this.value[index] = this._processMessage(message, this.value[index - 1]);
this.updated();
}
addMessage(message) {
if (message.optimistic_id) {
const index = this.value.findIndex(e => e.id === message.optimistic_id);
if (index !== -1) {
this.value[index] = this._processMessage(message, this.value[index - 1]);
this.updated();
return;
}
}
this.value.push(this._processMessage(message, this.value[this.value.length - 1]));
// only dispatch update if collectOldMessages didn't
if (!this.collectOldMessages()) {
this.updated();
}
}
updateId(oldId, newId) {
const index = this.value.findIndex(e => e.id === oldId);
if (index === -1)
return;
this.value[index].id = newId;
this.updated();
}
updateMessage(message) {
const index = this.value.findIndex(e => e.id === message.id);
if (index === -1)
return;
this.value[index] = this._processMessage(message, this.value[index - 1]);
this.updated();
}
deleteMessage({ id }) {
const index = this.value.findIndex(e => e.id === id);
if (index === -1)
return;
this.value.splice(index, 1);
this._recomputeMessages();
this.updated();
}
collectOldMessages() {
if (!this.isCollectingOldMessages)
return false;
const target = 25;
const delta = this.value.length - target;
if (delta >= 1) {
this.value.splice(0, delta);
this._recomputeMessages();
this.updated();
return true;
} else {
return false;
}
}
setIsCollectingOldMessages(isCollectingOldMessages) {
this.isCollectingOldMessages = isCollectingOldMessages;
this.collectOldMessages();
}
async loadOlderMessages(beforeCommitToStore=null) {
if (!getItem("ui:online:loadMessageHistory") || this.channelId === -1)
return;
const oldestMessage = this.value[0];
const res = await remoteCall(methods.getChannelMessages, this.channelId, null, oldestMessage ? oldestMessage.id : null);
if (res.ok) {
if (res.json.length < 1)
return;
if (beforeCommitToStore)
beforeCommitToStore(res.json);
res.json.reverse();
this.value = res.json.concat(this.value);
this._recomputeMessages();
this.updated();
} else {
overlayStore.toast("Messages failed to load");
}
}
async doInitialLoad() {
if (this.channelId === -1 || !getItem("auth:token") || getItem("auth:token").length < 1)
return;
await this.loadOlderMessages();
this.didDoInitialLoad = true;
}
}
class MessagesStoreProvider {
constructor() {
this.storeByChannel = new Map();
gateway.subscribe(GatewayEventType.MessageCreate, (message) => {
const store = this.getStoreOrNull(message.channel_id);
if (store)
store.addMessage(message);
});
gateway.subscribe(GatewayEventType.MessageUpdate, (message) => {
const store = this.getStoreOrNull(message.channel_id);
if (store)
store.updateMessage(message);
});
gateway.subscribe(GatewayEventType.MessageDelete, (message) => {
const store = this.getStoreOrNull(message.channel_id);
if (store)
store.deleteMessage(message);
});
}
getStoreOrNull(channelId) {
return this.storeByChannel.get(channelId);
}
getStore(channelId) {
if (!this.storeByChannel.get(channelId)) {
const store = new MessageStore(channelId);
store.doInitialLoad();
this.storeByChannel.set(channelId, store);
}
return this.storeByChannel.get(channelId);
}
}
export const OverlayType = {
CreateChannel: 0,
EditChannel: 1,
Toast: 2,
Login: 3,
CreateAccount: 4,
EditMessage: 5,
Settings: 6,
Prompt: 7,
UserInfo: 8,
AddCommunity: 9
};
class OverlayStore extends Store {
constructor() {
super([], "OverlayStore");
}
push(type, props={}) {
const id = Math.floor(Math.random() * 9999999);
props = {
...props,
close: () => {
this.popId(id);
}
}
this.value.push({
type,
props,
id
});
this.updated();
}
pop() {
this.value.pop();
this.updated();
}
popType(type) {
for (let i = this.value.length - 1; i >= 0; i--) {
if (this.value[i].type === type) {
this.value.splice(i, 1);
this.updated();
return;
}
}
}
popId(id) {
for (let i = this.value.length - 1; i >= 0; i--) {
if (this.value[i].id === id) {
this.value.splice(i, 1);
this.updated();
return;
}
}
}
toast(message) {
this.push(OverlayType.Toast, { message });
}
}
class TypingStore extends Store {
constructor() {
super([], "TypingStore");
this.timeouts = new Map();
this.ownTimeout = null;
this.ownNeedsUpdate = true;
if (getItem("ui:online:processRemoteTypingEvents")) {
gateway.subscribe(GatewayPayloadType.TypingStart, ({ user, channel, time }) => {
if (userInfoStore && user.id === userInfoStore.value.id)
return;
this.startedTyping(user, channel.id, time);
});
// assume someone has stopped typing once they send a message
gateway.subscribe(GatewayPayloadType.MessageCreate, ({ author_id }) => {
this.stoppedTyping(author_id);
});
}
}
stoppedTyping(id) {
const index = this.value.findIndex(e => e.user.id === id);
this.value.splice(index, 1);
if (this.timeouts.get(id)) {
clearTimeout(this.timeouts.get(id));
this.timeouts.delete(id);
}
if (userInfoStore.value && id === userInfoStore.value.id) {
clearTimeout(this.ownTimeout);
this.ownTimeout = null;
this.ownNeedsUpdate = true;
}
this.updated();
}
startedTyping(user, channelId, time) {
if (this.timeouts.get(user.id)) {
clearTimeout(this.timeouts.get(user.id));
}
this.timeouts.set(user.id, setTimeout(() => {
this.stoppedTyping(user.id);
}, time));
if (userInfoStore.value && user.id === userInfoStore.value.id && !this.ownTimeout) {
this.ownTimeout = setTimeout(() => {
this.ownNeedsUpdate = true;
this.ownTimeout = null;
}, time);
}
const index = this.value.findIndex(e => e.user.id === user.id);
if (index === -1) {
this.value.push({
user,
channelId
});
this.updated();
} else if (this.value[index].channelId !== channelId) { // user just switched the channel they're typing in
this.value[index].channelId = channelId;
this.updated();
}
}
async didInputKey() {
if (!userInfoStore.value || !sendTypingUpdatesItemStore.value)
return;
this.startedTyping(userInfoStore.value, selectedChannel.value.id, 6500);
if (this.ownNeedsUpdate) {
this.ownNeedsUpdate = false;
await remoteSignal(methods.putChannelTyping, selectedChannel.value.id);
}
}
}
class PresenceStore extends Store {
constructor() {
super([], "PresenceStore");
if (getItem("ui:online:processRemotePresenceEvents")) {
gateway.subscribe(GatewayEventType.Ready, ({ presence }) => {
this.ingestPresenceUpdate(presence);
});
gateway.subscribe(GatewayEventType.PresenceUpdate, (data) => {
this.ingestPresenceUpdate(data);
});
gateway.subscribe(GatewayEventType.UserUpdate, (data) => {
const entry = this.entryIndexByUserId(data.id);
if (entry === -1) return;
this.value[entry].user.username = data.username;
this.value[entry].user.avatar = data.avatar;
this.updated();
});
}
}
entryIndexByUserId(userId) {
return this.value.findIndex(a => a.user.id === userId);
}
ingestPresenceUpdate(payload) {
payload.forEach((entry) => {
const existingEntry = this.entryIndexByUserId(entry.user.id);
if (existingEntry !== -1 && entry.status === GatewayPresenceStatus.Offline) {
this.value.splice(existingEntry, 1);
} else if (existingEntry !== -1 && entry.status !== GatewayPresenceStatus.Offline) {
this.value[existingEntry] = entry;
} else {
// don't need to push the status, since we remove offline members from the presence list
this.value.push({
user: entry.user,
bridgesTo: entry.bridgesTo,
privacy: entry.privacy,
terms: entry.terms
});
}
});
this.updated();
}
}
class UnreadStore extends Store {
constructor() {
super(new Map(), "UnreadStore");
gateway.subscribe(GatewayEventType.MessageCreate, ({ channel_id: channelId }) => {
if (selectedChannel.value.id !== channelId || window.document.visibilityState !== "visible") {
this.value.set(channelId, (this.value.get(channelId) || 0) + 1);
this.updated();
}
});
selectedChannel.subscribe(({ id }) => {
if (this.value.get(id)) {
this.value.delete(id);
this.updated();
}
});
window.document.addEventListener("visibilitychange", () => {
if (window.document.visibilityState === "visible" && selectedChannel.value) {
if (this.value.get(selectedChannel.value.id)) {
this.value.delete(selectedChannel.value.id);
this.updated();
}
}
});
}
}
class PluginStore extends Store {
constructor() {
super([], "PluginStore");
}
getPluginContext(_plugin) {
return window.__waffle;
}
loadPlugin(plugin) {
plugin.id = Math.random();
plugin.main(this.getPluginContext(plugin));
this.value.push(plugin);
this.updated();
return plugin.id;
}
unloadPlugin(id) {
const index = this.value.findIndex(e => e.id === id);
if (index !== -1) {
this.value[index].dispose();
this.value.splice(index, 1);
return true;
}
return false;
}
consumePluginLoaders() {
window.__wafflePluginLoaders = window.__wafflePluginLoaders || [];
const loaders = window.__wafflePluginLoaders;
this.startBatch();
loaders.forEach(async (load) => this.loadPlugin(await load()));
loaders.length = 0;
loaders.push = async (load) => {
this.loadPlugin(await load());
};
this.endBatch();
return true;
}
}
class CommunitiesStore extends Store {
constructor() {
super(gateway.communities || [], "CommunitiesStore");
gateway.subscribe(GatewayEventType.Ready, ({ communities }) => {
this.value = communities;
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityCreate, (community) => {
this.value.push(community);
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
const index = this.value.findIndex(e => e.id === id);
if (index === -1)
return;
this.value.splice(index, 1);
this.updated();
});
gateway.subscribe(GatewayEventType.CommunityUpdate, (data) => {
const index = this.value.findIndex(e => e.id === data.id);
if (index === -1)
return;
if (!this.value[index])
return;
this.value[index] = data;
this.updated();
});
}
}
const noneChannel = {id: -1, name: "none", creator_id: -1}
class SelectedChannelStore extends Store {
constructor() {
super(noneChannel, "SelectedChannelStore");
this.communityIdToSelectedChannel = new Map();
filteredChannelsStore.subscribe((channels) => {
let channel = this.communityIdToSelectedChannel.get(selectedCommunity.value.id);
if (!channel && channels.length) {
channel = channels[0];
}
this.value = channel || noneChannel;
this.updated();
});
gateway.subscribe(GatewayEventType.Ready, ({ channels }) => {
this.communityIdToSelectedChannel.clear();
const savedMap = getItem("state:selectedChannels");
for (const [communityId, channelId] of Object.entries(savedMap)) {
const channel = channels.find(c => c.id === channelId);
if (channel) {
this.communityIdToSelectedChannel.set(parseInt(communityId), channel);
}
}
this.updateSavedMap();
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
if (this.communityIdToSelectedChannel.delete(id) && this.value && this.value.id === id) {
this.value = noneChannel;
this.updateSavedMap();
this.updated();
}
});
gateway.subscribe(GatewayEventType.ChannelDelete, ({ id }) => {
this.communityIdToSelectedChannel.forEach((channel, communityId) => {
if (channel.id === id) {
if (this.communityIdToSelectedChannel.delete(communityId) && this.value && this.value.id === id) {
this.value = noneChannel;
this.updateSavedMap();
this.updated();
return;
}
}
});
});
gateway.subscribe(GatewayEventType.ChannelUpdate, (data) => {
if (this.value && this.value.id === data.id) {
this.value = data;
this.communityIdToSelectedChannel.set(selectedCommunity.value.id, data);
this.updateSavedMap();
this.updated();
}
});
}
updateSavedMap() {
if (getItem("ui:stateful:presistSelection")) {
const value = {};
this.communityIdToSelectedChannel.forEach((channel, communityId) => {
if (!channel || channel.id === -1) return;
value[communityId] = channel.id;
});
setItem("state:selectedChannels", value);
}
}
set(value) {
if (value === this.value)
return;
this.value = value;
this.communityIdToSelectedChannel.set(selectedCommunity.value.id, value);
this.updateSavedMap();
this.updated();
}
}
class SelectedCommunityStore extends Store {
constructor() {
super({ id: -1, name: "none", creator_id: -1, created_at: 0, avatar: null }, "SelectedCommunityStore");
gateway.subscribe(GatewayEventType.Ready, ({ communities }) => {
if (getItem("ui:stateful:presistSelection")) {
this.value.id = getItem("state:openCommunityId");
}
if (communities.length > 0 && this.value.id !== -1) {
const index = communities.findIndex(e => e.id === this.value.id);
if (index !== -1) {
this.set(communities[index]);
}
}
});
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
if (this.value.id === id) {
this.clear();
}
});
gateway.subscribe(GatewayEventType.CommunityUpdate, (data) => {
if (data.id === this.value.id) {
this.value = data;
this.updated();
}
});
this.on(value => {
if (getItem("ui:stateful:presistSelection")) {
setItem("state:openCommunityId", value.id);
}
});
}
clear() {
this.value = { id: -1, name: "none", creator_id: -1, created_at: 0, avatar: null };
this.updated();
}
}
class FilteredChannelsStore extends Store {
constructor() {
super([], "FilteredChannelsStore");
channels.on(() => this.update());
selectedCommunity.on(() => this.update());
this.update();
}
update() {
if (selectedCommunity.value.id === -1) {
this.value = channels.value.filter(n => n.community_id === null);
} else {
this.value = channels.value.filter(n => n.community_id === selectedCommunity.value.id);
}
this.updated();
}
}
export const selectedCommunity = new SelectedCommunityStore();
export const channels = new ChannelsStore();
export const filteredChannelsStore = new FilteredChannelsStore();
export const selectedChannel = new SelectedChannelStore();
export const showSidebar = new Store(true, "showSidebar");
export const showPresenceSidebar = new Store(false, "showPresenceSidebar");
export const smallViewport = new Store(false, "smallViewport");
export const showChannelView = new Store(true, "showChannelView");
export const theme = new StorageItemStore("ui:theme");
export const sendTypingUpdatesItemStore = new StorageItemStore("ui:online:sendTypingUpdates");
export const doAnimations = new StorageItemStore("ui:doAnimations");
export const communities = new CommunitiesStore();
export const gatewayStatus = new GatewayStatusStore();
export const messagesStoreProvider = new MessagesStoreProvider();
export const userInfoStore = new UserInfoStore();
export const overlayStore = new OverlayStore();
export const typingStore = new TypingStore();
export const presenceStore = new PresenceStore();
export const unreadStore = new UnreadStore();
export const pluginStore = new PluginStore();
export const totalUnreadsStore = new Store(0, "TotalUnreadsStore");
export const statusBarStore = new Store(null, "statusBarStore");
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
export const sendMessageAction = createAction("sendMessageAction", async ({channelId, content}) => {
if (content.trim() === "" || !userInfoStore.value)
return;
// optimistically add message to store
const optimisticMessageId = Math.floor(Math.random() * 999999);
const optimisticMessage = {
id: optimisticMessageId,
content: content,
channel_id: channelId,
author_id: userInfoStore.value.id,
author_username: userInfoStore.value.username,
created_at: Date.now().toString(),
_isPending: true
};
const messagesStoreForChannel = messagesStoreProvider.getStore(channelId);
messagesStoreForChannel.addMessage(optimisticMessage);
const res = await remoteSignal(methods.createChannelMessage, channelId, content, optimisticMessageId, null);
if (!res.ok) {
messagesStoreForChannel.deleteMessage({
id: optimisticMessageId
});
overlayStore.toast("Couldn't send message");
}
});
export const allStores = {
selectedChannel,
showSidebar,
showPresenceSidebar,
smallViewport,
showChannelView,
theme,
doAnimations,
channels,
gatewayStatus,
messagesStoreProvider,
userInfoStore,
overlayStore,
typingStore,
presenceStore,
unreadStore,
pluginStore,
setMessageInputEvent,
sendMessageAction,
};
unreadStore.subscribe(() => {
let totalUnreads = 0;
unreadStore.value.forEach(count => totalUnreads += count);
totalUnreadsStore.set(totalUnreads);
});
const updateTitle = () => {
let channelSuffix = "";
if (selectedChannel.value && selectedChannel.value.id !== -1 && selectedChannel.value.name) {
channelSuffix = ` | #${selectedChannel.value.name}`;
}
if (totalUnreadsStore.value > 0) {
window.document.title = `(${totalUnreadsStore.value}) waffle${channelSuffix}`;
} else {
window.document.title = `waffle${channelSuffix}`;
}
};
totalUnreadsStore.subscribe(updateTitle);
selectedChannel.subscribe(updateTitle);