576 lines
18 KiB
JavaScript
576 lines
18 KiB
JavaScript
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();
|
|
}
|
|
|
|
// like set(), but without checking if the value is the same
|
|
update(value) {
|
|
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();
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
_recomputeMessages() {
|
|
this.value = this.value.map((e, i) => this._processMessage(e, this.value[i - 1]));
|
|
}
|
|
|
|
_processMessage(message, previous=null) {
|
|
message._createdAtDate = new Date(parseInt(message.created_at));
|
|
message._createdAtTimeString = new Intl.DateTimeFormat(getItem("ui:locale"), { hour: "numeric", minute: "numeric" }).format(message._createdAtDate);
|
|
message._createdAtDateString = message._createdAtDate.toLocaleDateString();
|
|
message._mentions = false;
|
|
message._editable = false;
|
|
message._clumped = false;
|
|
message._showDateMarkerAbove = false;
|
|
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._clumped = true;
|
|
}
|
|
if (previous && (previous._createdAtDateString !== message._createdAtDateString)) {
|
|
message._showDateMarkerAbove = true;
|
|
}
|
|
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 = 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 (!getItem("ui:online:loadMessageHistory") || 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();
|
|
for (let i = 0; i < res.json.length; i++) {
|
|
const message = res.json[i];
|
|
const previous = res.json[i - 1];
|
|
res.json[i] = this._processMessage(message, previous);
|
|
}
|
|
this.value = res.json.concat(this.value);
|
|
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,
|
|
};
|
|
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 || !getItem("ui:online:sendTypingUpdates"))
|
|
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");
|
|
|
|
if (getItem("ui:online:processRemotePresenceEvents")) {
|
|
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();
|
|
}
|
|
}
|
|
|
|
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 }) => {
|
|
this.value.delete(id);
|
|
this.updated();
|
|
});
|
|
|
|
window.document.addEventListener("visibilitychange", () => {
|
|
if (window.document.visibilityState === "visible" && selectedChannel.value) {
|
|
this.value.delete(selectedChannel.value.id);
|
|
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 unreadStore = new UnreadStore();
|
|
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
|
|
|
|
export const allStores = {
|
|
selectedChannel,
|
|
showSidebar,
|
|
showPresenceSidebar,
|
|
smallViewport,
|
|
showChannelView,
|
|
theme,
|
|
doAnimations,
|
|
channels,
|
|
gatewayStatus,
|
|
messagesStoreProvider,
|
|
userInfoStore,
|
|
overlayStore,
|
|
typingStore,
|
|
presenceStore,
|
|
unreadStore,
|
|
setMessageInputEvent,
|
|
};
|
|
|
|
selectedChannel.watch((newSelectedChannel) => {
|
|
if (getItem("ui:stateful:presistSelectedChannel")) {
|
|
setItem("state:openChannelId", newSelectedChannel.id);
|
|
}
|
|
});
|
|
|
|
unreadStore.subscribe(() => {
|
|
let totalUnreads = 0;
|
|
unreadStore.value.forEach(count => totalUnreads += count);
|
|
|
|
if (totalUnreads > 0) {
|
|
window.document.title = `(${totalUnreads}) waffle`;
|
|
} else {
|
|
window.document.title = "waffle";
|
|
}
|
|
});
|