mirror of
https://github.com/thelounge/thelounge
synced 2024-11-22 12:03:11 +00:00
414 lines
11 KiB
TypeScript
414 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 = (): 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,
|
|
});
|
|
|
|
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;
|
|
}
|