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);