import gateway, { GatewayEventType, GatewayPayloadType, GatewayPresenceStatus } from "./gateway"; import logger from "./logging"; import request from "./request"; import { apiRoute, getItem, setItem } from "./storage"; const storeLog = logger("Store"); class Store { constructor(value=null, name="[no name]") { this._handlers = []; this.value = value; this.name = name; } // like subscribe, but without initially calling the handler watch(handler) { const handlerIndex = this._handlers.push(handler) - 1; return () => { this._handlers.splice(handlerIndex, 1); }; } subscribe(handler) { const handlerIndex = this._handlers.push(handler) - 1; storeLog(`(${this.name}) (subscribe/initial)`, this.value); handler(this.value); return () => { this._handlers.splice(handlerIndex, 1); }; } set(value) { if (value === this.value) return; this.value = value; this.updated(); } updated() { storeLog(`(${this.name}) (updated) Calling all (${this._handlers.length}) handlers`, this.value); for (let i = this._handlers.length - 1; i >= 0; i--) { this._handlers[i](this.value); } } } class StorageItemStore extends Store { constructor(key) { super(getItem(key), `StorageItemStore[key=${key}]`); this.watch(e => setItem(key, e)); } } class ChannelsStore extends Store { constructor() { super(gateway.channels || [], "ChannelsStore"); gateway.subscribe(GatewayEventType.Ready, ({ channels }) => { this.value = channels; if (getItem("ui:stateful:presistSelectedChannel")) { selectedChannel.value.id = getItem("state:openChannelId"); } if (channels.length >= 1) { if (!selectedChannel.value || selectedChannel.value.id === -1) { selectedChannel.set(channels[0]); } else { // if a channel id is already selected, we'll populate it with the data we just got from the gateway const index = this.value.findIndex(e => e.id === selectedChannel.value.id); if (index !== -1) selectedChannel.set(this.value[index]); else // if the channel doesn't exist, just select the first one selectedChannel.set(channels[0]); } } 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(); }); gateway.subscribe(GatewayEventType.MessageCreate, ({ channel_id }) => { const index = this.value.findIndex(e => e.id === channel_id); if (index === -1 || !this.value[index] || selectedChannel.value.id === channel_id) return; this.value[index]._hasUnreads = true; this.updated(); }); selectedChannel.subscribe(({ id }) => { const index = this.value.findIndex(e => e.id === id); if (index === -1 || !this.value[index] || !this.value[index]._hasUnreads) return; this.value[index]._hasUnreads = false; 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(); }); } } class MessageStore extends Store { constructor(channelId) { super([], `MessageStore[channelId=${channelId}]`); this.channelId = channelId; this.isCollectingOldMessages = true; this.didDoInitialLoad = false; } setMessage(id, message) { const index = this.value.findIndex(e => e.id === id); if (index === -1) return; this.value[index] = message; this.updated(); } addMessage(message) { this.value.push(message); // 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] = message; this.updated(); } deleteMessage({ id }) { const index = this.value.findIndex(e => e.id === id); if (index === -1) return; this.value.splice(index, 1); this.updated(); } collectOldMessages() { if (!this.isCollectingOldMessages) return false; const target = 50; const delta = this.value.length - target; if (delta >= 1) { this.value.splice(0, delta); this.updated(); return true; } else { return false; } } setIsCollectingOldMessages(isCollectingOldMessages) { this.isCollectingOldMessages = isCollectingOldMessages; this.collectOldMessages(); } async loadOlderMessages(beforeCommitToStore=null) { if (this.channelId === -1) return; const oldestMessage = this.value[0]; const endpoint = oldestMessage ? `channels/${this.channelId}/messages/?before=${oldestMessage.id}` : `channels/${this.channelId}/messages`; const res = await request("GET", apiRoute(endpoint), true, null); if (res.success && res.ok && res.json) { if (res.json.length < 1) return; if (beforeCommitToStore) beforeCommitToStore(res.json); res.json.reverse(); this.value = res.json.concat(this.value); this.updated(); } else { overlayStore.open("toast", { message: "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) => { // we currently don't care about our own messages if (gateway.user && message.author_id === gateway.user.id) return; 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); } } class OverlayStore extends Store { constructor() { super({ createChannel: null, editChannel: null, toast: null, login: null, createAccount: null, editMessage: null, settings: null, prompt: null, }, "OverlayStore"); } open(name, props={}) { if (this.value[name] === undefined) throw new Error(`OverlayStore.open: tried to open unknown overlay with name '${name}' (undefined in overlay map)`); this.value[name] = props; this.updated(); } close(name) { if (!this.value[name]) return; this.value[name] = null; this.updated(); } } class TypingStore extends Store { constructor() { super([], "TypingStore"); this.timeouts = new Map(); this.ownTimeout = null; this.ownNeedsUpdate = true; 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) return; this.startedTyping(userInfoStore.value, selectedChannel.value.id, 6500); if (this.ownNeedsUpdate) { this.ownNeedsUpdate = false; await request("POST", apiRoute(`channels/${selectedChannel.value.id}/typing`), true, {}); } } } class PresenceStore extends Store { constructor() { super([], "PresenceStore"); gateway.subscribe(GatewayEventType.Ready, ({ presence }) => { this.ingestPresenceUpdate(presence); }); gateway.subscribe(GatewayEventType.PresenceUpdate, (data) => { this.ingestPresenceUpdate(data); }); } 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 }); } }); this.updated(); } } export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel"); 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 doAnimations = new StorageItemStore("ui:doAnimations"); export const channels = new ChannelsStore(); 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 allStores = { selectedChannel, showSidebar, showPresenceSidebar, showChannelView, smallViewport, theme, doAnimations, channels, gatewayStatus, messagesStoreProvider, userInfoStore, overlayStore, typingStore, }; selectedChannel.watch((newSelectedChannel) => { if (getItem("ui:stateful:presistSelectedChannel")) { setItem("state:openChannelId", newSelectedChannel.id); } });