0ebc3a574c
Prior to this, the search is still racy but one tends to notice this only when the DB is large or network is involved. The user can initiate a search, get bored, navigate to another chan issue a different search. Now however, the results of the first search come back in and hilarity ensues as we are now confused with the state. To avoid this, keep track of the last search done and any result that comes in that isn't equal to the active query is garbage and can be dropped.
416 lines
11 KiB
TypeScript
416 lines
11 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-shadow */
|
|
|
|
import {ActionContext, createStore, Store, useStore as baseUseStore} from "vuex";
|
|
import {createSettingsStore} from "./store-settings";
|
|
import storage from "./localStorage";
|
|
import type {
|
|
ClientChan,
|
|
ClientConfiguration,
|
|
ClientNetwork,
|
|
InitClientChan,
|
|
NetChan,
|
|
ClientMessage,
|
|
ClientMention,
|
|
} from "./types";
|
|
import type {InjectionKey} from "vue";
|
|
|
|
import {SettingsState} from "./settings";
|
|
import {SearchQuery} from "../../server/plugins/messageStorage/types";
|
|
|
|
const appName = document.title;
|
|
|
|
enum DesktopNotificationState {
|
|
Unsupported = "unsupported",
|
|
Blocked = "blocked",
|
|
NoHttps = "nohttps",
|
|
Granted = "granted",
|
|
}
|
|
|
|
function detectDesktopNotificationState(): DesktopNotificationState {
|
|
if (!("Notification" in window)) {
|
|
return DesktopNotificationState.Unsupported;
|
|
} else if (Notification.permission === DesktopNotificationState.Granted) {
|
|
return DesktopNotificationState.Granted;
|
|
} else if (!window.isSecureContext) {
|
|
return DesktopNotificationState.NoHttps;
|
|
}
|
|
|
|
return DesktopNotificationState.Blocked;
|
|
}
|
|
|
|
export type ClientSession = {
|
|
current: boolean;
|
|
active: number;
|
|
lastUse: number;
|
|
ip: string;
|
|
agent: string;
|
|
token: string;
|
|
};
|
|
|
|
export type State = {
|
|
appLoaded: boolean;
|
|
activeChannel?: NetChan;
|
|
currentUserVisibleError: string | null;
|
|
desktopNotificationState: DesktopNotificationState;
|
|
isAutoCompleting: boolean;
|
|
isConnected: boolean;
|
|
networks: ClientNetwork[];
|
|
// TODO: type
|
|
mentions: ClientMention[];
|
|
hasServiceWorker: boolean;
|
|
pushNotificationState: string;
|
|
serverConfiguration: ClientConfiguration | null;
|
|
sessions: ClientSession[];
|
|
sidebarOpen: boolean;
|
|
sidebarDragging: boolean;
|
|
userlistOpen: boolean;
|
|
versionData:
|
|
| null
|
|
| undefined
|
|
| {
|
|
latest?: {
|
|
version: string;
|
|
prerelease: boolean;
|
|
url: string;
|
|
};
|
|
current?: {
|
|
version: string;
|
|
prerelease: boolean;
|
|
url: string;
|
|
changelog?: string;
|
|
};
|
|
};
|
|
versionStatus: "loading" | "new-version" | "new-packages" | "up-to-date" | "error";
|
|
versionDataExpired: boolean;
|
|
serverHasSettings: boolean;
|
|
messageSearchResults: {
|
|
results: ClientMessage[];
|
|
} | null;
|
|
messageSearchPendingQuery: SearchQuery | null;
|
|
searchEnabled: boolean;
|
|
};
|
|
|
|
const state = () =>
|
|
({
|
|
appLoaded: false,
|
|
activeChannel: undefined,
|
|
currentUserVisibleError: null,
|
|
desktopNotificationState: detectDesktopNotificationState(),
|
|
isAutoCompleting: false,
|
|
isConnected: false,
|
|
networks: [],
|
|
mentions: [],
|
|
hasServiceWorker: false,
|
|
pushNotificationState: "unsupported",
|
|
serverConfiguration: null,
|
|
sessions: [],
|
|
sidebarOpen: false,
|
|
sidebarDragging: false,
|
|
userlistOpen: storage.get("thelounge.state.userlist") !== "false",
|
|
versionData: null,
|
|
versionStatus: "loading",
|
|
versionDataExpired: false,
|
|
serverHasSettings: false,
|
|
messageSearchResults: null,
|
|
messageSearchPendingQuery: null,
|
|
searchEnabled: false,
|
|
} as State);
|
|
|
|
type Getters = {
|
|
findChannelOnCurrentNetwork: (state: State) => (name: string) => ClientChan | undefined;
|
|
findChannelOnNetwork: (state: State) => (
|
|
networkUuid: string,
|
|
channelName: string
|
|
) => {
|
|
network: ClientNetwork;
|
|
channel: ClientChan;
|
|
} | null;
|
|
findChannel: (state: State) => (id: number) => {
|
|
network: ClientNetwork;
|
|
channel: ClientChan;
|
|
} | null;
|
|
findNetwork: (state: State) => (uuid: string) => ClientNetwork | null;
|
|
highlightCount(state: State): number;
|
|
title(state: State, getters: Omit<Getters, "title">): string;
|
|
initChannel: () => (channel: InitClientChan) => ClientChan;
|
|
};
|
|
|
|
// getters without the state argument
|
|
export type CallableGetters = {
|
|
[K in keyof Getters]: ReturnType<Getters[K]>;
|
|
};
|
|
|
|
const getters: Getters = {
|
|
findChannelOnCurrentNetwork: (state) => (name: string) => {
|
|
name = name.toLowerCase();
|
|
return state.activeChannel?.network.channels.find((c) => c.name.toLowerCase() === name);
|
|
},
|
|
findChannelOnNetwork: (state) => (networkUuid: string, channelName: string) => {
|
|
for (const network of state.networks) {
|
|
if (network.uuid !== networkUuid) {
|
|
continue;
|
|
}
|
|
|
|
for (const channel of network.channels) {
|
|
if (channel.name === channelName) {
|
|
return {network, channel};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
findChannel: (state) => (id: number) => {
|
|
for (const network of state.networks) {
|
|
for (const channel of network.channels) {
|
|
if (channel.id === id) {
|
|
return {network, channel};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
findNetwork: (state) => (uuid: string) => {
|
|
for (const network of state.networks) {
|
|
if (network.uuid === uuid) {
|
|
return network;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
highlightCount(state) {
|
|
let highlightCount = 0;
|
|
|
|
for (const network of state.networks) {
|
|
for (const channel of network.channels) {
|
|
if (channel.muted) {
|
|
continue;
|
|
}
|
|
|
|
highlightCount += channel.highlight;
|
|
}
|
|
}
|
|
|
|
return highlightCount;
|
|
},
|
|
title(state, getters) {
|
|
const alertEventCount = getters.highlightCount
|
|
? `(${getters.highlightCount.toString()}) `
|
|
: "";
|
|
const channelname = state.activeChannel ? `${state.activeChannel.channel.name} — ` : "";
|
|
|
|
return alertEventCount + channelname + appName;
|
|
},
|
|
initChannel: () => (channel: InitClientChan) => {
|
|
// TODO: This should be a mutation
|
|
channel.pendingMessage = "";
|
|
channel.inputHistoryPosition = 0;
|
|
|
|
channel.inputHistory = [""].concat(
|
|
channel.messages
|
|
.filter((m) => m.self && m.text && m.type === "message")
|
|
.map((m) => m.text)
|
|
.reverse()
|
|
.slice(0, 99)
|
|
);
|
|
channel.historyLoading = false;
|
|
channel.scrolledToBottom = true;
|
|
channel.editTopic = false;
|
|
|
|
channel.moreHistoryAvailable = channel.totalMessages! > channel.messages.length;
|
|
delete channel.totalMessages;
|
|
|
|
if (channel.type === "channel") {
|
|
channel.usersOutdated = true;
|
|
}
|
|
|
|
return channel as ClientChan;
|
|
},
|
|
};
|
|
|
|
type Mutations = {
|
|
appLoaded(state: State): void;
|
|
activeChannel(state: State, netChan: State["activeChannel"]): void;
|
|
currentUserVisibleError(state: State, error: State["currentUserVisibleError"]): void;
|
|
refreshDesktopNotificationState(state: State): void;
|
|
isAutoCompleting(state: State, isAutoCompleting: State["isAutoCompleting"]): void;
|
|
isConnected(state: State, payload: State["isConnected"]): void;
|
|
networks(state: State, networks: State["networks"]): void;
|
|
mentions(state: State, mentions: State["mentions"]): void;
|
|
|
|
removeNetwork(state: State, networkUuid: string): void;
|
|
sortNetworks(
|
|
state: State,
|
|
sortFn: (a: State["networks"][0], b: State["networks"][0]) => number
|
|
): void;
|
|
hasServiceWorker(state: State): void;
|
|
pushNotificationState(
|
|
state: State,
|
|
pushNotificationState: State["pushNotificationState"]
|
|
): void;
|
|
serverConfiguration(state: State, serverConfiguration: State["serverConfiguration"]): void;
|
|
sessions(state: State, payload: State["sessions"]): void;
|
|
sidebarOpen(state: State, payload: State["sidebarOpen"]): void;
|
|
sidebarDragging(state: State, payload: State["sidebarDragging"]): void;
|
|
toggleSidebar(state: State): void;
|
|
toggleUserlist(state: State): void;
|
|
userlistOpen(state: State, payload: State["userlistOpen"]): void;
|
|
versionData(state: State, payload: State["versionData"]): void;
|
|
versionStatus(state: State, payload: State["versionStatus"]): void;
|
|
versionDataExpired(state: State, payload: State["versionDataExpired"]): void;
|
|
serverHasSettings(state: State, value: State["serverHasSettings"]): void;
|
|
messageSearchPendingQuery(state: State, value: State["messageSearchPendingQuery"]): void;
|
|
messageSearchResults(state: State, value: State["messageSearchResults"]): void;
|
|
addMessageSearchResults(state: State, value: NonNullable<State["messageSearchResults"]>): void;
|
|
};
|
|
|
|
const mutations: Mutations = {
|
|
appLoaded(state) {
|
|
state.appLoaded = true;
|
|
},
|
|
activeChannel(state, netChan) {
|
|
state.activeChannel = netChan;
|
|
},
|
|
currentUserVisibleError(state, error) {
|
|
state.currentUserVisibleError = error;
|
|
},
|
|
refreshDesktopNotificationState(state) {
|
|
state.desktopNotificationState = detectDesktopNotificationState();
|
|
},
|
|
isAutoCompleting(state, isAutoCompleting) {
|
|
state.isAutoCompleting = isAutoCompleting;
|
|
},
|
|
isConnected(state, payload) {
|
|
state.isConnected = payload;
|
|
},
|
|
networks(state, networks) {
|
|
state.networks = networks;
|
|
},
|
|
mentions(state, mentions) {
|
|
state.mentions = mentions;
|
|
},
|
|
removeNetwork(state, networkId) {
|
|
state.networks.splice(
|
|
state.networks.findIndex((n) => n.uuid === networkId),
|
|
1
|
|
);
|
|
},
|
|
sortNetworks(state, sortFn) {
|
|
state.networks.sort(sortFn);
|
|
},
|
|
hasServiceWorker(state) {
|
|
state.hasServiceWorker = true;
|
|
},
|
|
pushNotificationState(state, pushNotificationState) {
|
|
state.pushNotificationState = pushNotificationState;
|
|
},
|
|
serverConfiguration(state, serverConfiguration) {
|
|
state.serverConfiguration = serverConfiguration;
|
|
},
|
|
sessions(state, payload) {
|
|
state.sessions = payload;
|
|
},
|
|
sidebarOpen(state, payload) {
|
|
state.sidebarOpen = payload;
|
|
},
|
|
sidebarDragging(state, payload) {
|
|
state.sidebarDragging = payload;
|
|
},
|
|
toggleSidebar(state) {
|
|
state.sidebarOpen = !state.sidebarOpen;
|
|
},
|
|
toggleUserlist(state) {
|
|
state.userlistOpen = !state.userlistOpen;
|
|
},
|
|
userlistOpen(state, payload) {
|
|
state.userlistOpen = payload;
|
|
},
|
|
versionData(state, payload) {
|
|
state.versionData = payload;
|
|
},
|
|
versionStatus(state, payload) {
|
|
state.versionStatus = payload;
|
|
},
|
|
versionDataExpired(state, payload) {
|
|
state.versionDataExpired = payload;
|
|
},
|
|
serverHasSettings(state, value) {
|
|
state.serverHasSettings = value;
|
|
},
|
|
messageSearchPendingQuery(state, value) {
|
|
state.messageSearchPendingQuery = value;
|
|
},
|
|
messageSearchResults(state, value) {
|
|
state.messageSearchResults = value;
|
|
},
|
|
addMessageSearchResults(state, value) {
|
|
// Append the search results and add networks and channels to new messages
|
|
if (!state.messageSearchResults) {
|
|
state.messageSearchResults = {results: []};
|
|
}
|
|
|
|
if (!value) {
|
|
return;
|
|
}
|
|
|
|
const results = [...value.results, ...state.messageSearchResults.results];
|
|
|
|
state.messageSearchResults = {
|
|
results,
|
|
};
|
|
},
|
|
};
|
|
|
|
export type TypedCommit = <T extends keyof Mutations>(
|
|
type: T,
|
|
payload?: Parameters<Mutations[T]>[1] | null,
|
|
options?: {root?: boolean}
|
|
) => ReturnType<Mutations[T]>;
|
|
type TypedActionContext = Omit<ActionContext<State, State>, "commit"> & {
|
|
commit: TypedCommit;
|
|
};
|
|
|
|
type Actions = {
|
|
partChannel(context: TypedActionContext, payload: NetChan): void;
|
|
};
|
|
|
|
const actions: Actions = {
|
|
partChannel({commit, state}, netChan) {
|
|
const mentions = state.mentions.filter((msg) => !(msg.chanId === netChan.channel.id));
|
|
commit("mentions", mentions);
|
|
},
|
|
};
|
|
|
|
const storePattern = {
|
|
state,
|
|
mutations,
|
|
actions,
|
|
getters,
|
|
};
|
|
|
|
// https://vuex.vuejs.org/guide/typescript-support.html#typing-usestore-composition-function
|
|
export const key: InjectionKey<Store<State>> = Symbol();
|
|
|
|
// vuex types getters as any
|
|
export type TypedStore = Omit<Store<State>, "getters" | "commit"> & {
|
|
getters: CallableGetters;
|
|
commit: TypedCommit;
|
|
state: State & {
|
|
settings: SettingsState;
|
|
};
|
|
};
|
|
|
|
export const store = createStore(storePattern) as TypedStore;
|
|
|
|
const settingsStore = createSettingsStore(store);
|
|
|
|
// Settings module is registered dynamically because it benefits
|
|
// from a direct reference to the store
|
|
store.registerModule("settings", settingsStore);
|
|
|
|
export function useStore() {
|
|
return baseUseStore(key) as TypedStore;
|
|
}
|