Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 1051 additions and 12 deletions
import localForage from "localforage";
import { createStore } from "redux";
import { RevoltConfiguration } from "revolt-api/types/Core";
import { Language } from "../context/Locale";
import rootReducer, { Action } from "./reducers";
import { AuthState } from "./reducers/auth";
import { Drafts } from "./reducers/drafts";
import { ExperimentOptions } from "./reducers/experiments";
import { LastOpened } from "./reducers/last_opened";
import { Notifications } from "./reducers/notifications";
import { QueuedMessage } from "./reducers/queue";
import { SectionToggle } from "./reducers/section_toggle";
import { Settings } from "./reducers/settings";
import { SyncOptions } from "./reducers/sync";
import { Unreads } from "./reducers/unreads";
export type State = {
config: RevoltConfiguration;
locale: Language;
auth: AuthState;
settings: Settings;
unreads: Unreads;
queue: QueuedMessage[];
drafts: Drafts;
sync: SyncOptions;
experiments: ExperimentOptions;
lastOpened: LastOpened;
notifications: Notifications;
sectionToggle: SectionToggle;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const store = createStore((state: any, action: any) => {
if (import.meta.env.DEV) {
console.debug("State Update:", action);
}
if (action.type === "__INIT") {
return action.state;
}
return rootReducer(state, action);
});
// Save state using localForage.
store.subscribe(() => {
const {
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
} = store.getState() as State;
localForage.setItem("state", {
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
});
});
export function dispatch(action: Action) {
store.dispatch(action);
}
export function getState(): State {
return store.getState();
}
import { Session } from "revolt-api/types/Auth";
export interface AuthState {
accounts: {
[key: string]: {
session: Session;
};
};
active?: string;
}
export type AuthAction =
| { type: undefined }
| {
type: "LOGIN";
session: Session;
}
| {
type: "LOGOUT";
user_id?: string;
};
export function auth(
state = { accounts: {} } as AuthState,
action: AuthAction,
): AuthState {
switch (action.type) {
case "LOGIN":
return {
accounts: {
...state.accounts,
[action.session.user_id]: {
session: action.session,
},
},
active: action.session.user_id,
};
case "LOGOUT": {
const accounts = Object.assign({}, state.accounts);
action.user_id && delete accounts[action.user_id];
return {
accounts,
};
}
default:
return state;
}
}
export type Drafts = { [key: string]: string };
export type DraftAction =
| { type: undefined }
| {
type: "SET_DRAFT";
channel: string;
content: string;
}
| {
type: "CLEAR_DRAFT";
channel: string;
}
| {
type: "RESET";
};
export function drafts(state: Drafts = {}, action: DraftAction): Drafts {
switch (action.type) {
case "SET_DRAFT":
return {
...state,
[action.channel]: action.content,
};
case "CLEAR_DRAFT": {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [action.channel]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}
export type Experiments = "search";
export const AVAILABLE_EXPERIMENTS: Experiments[] = ["search"];
export const EXPERIMENTS: {
[key in Experiments]: { title: string; description: string };
} = {
search: {
title: "Search",
description: "Allows you to search for messages in channels.",
},
};
export interface ExperimentOptions {
enabled?: Experiments[];
}
export type ExperimentsAction =
| { type: undefined }
| {
type: "EXPERIMENTS_ENABLE";
key: Experiments;
}
| {
type: "EXPERIMENTS_DISABLE";
key: Experiments;
};
export function experiments(
state = {} as ExperimentOptions,
action: ExperimentsAction,
): ExperimentOptions {
switch (action.type) {
case "EXPERIMENTS_ENABLE":
return {
...state,
enabled: [
...(state.enabled ?? [])
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x))
.filter((v) => v !== action.key),
action.key,
],
};
case "EXPERIMENTS_DISABLE":
return {
...state,
enabled: state.enabled
?.filter((v) => v !== action.key)
.filter((x) => AVAILABLE_EXPERIMENTS.includes(x)),
};
default:
return state;
}
}
import { combineReducers } from "redux";
import { State } from "..";
import { auth, AuthAction } from "./auth";
import { drafts, DraftAction } from "./drafts";
import { experiments, ExperimentsAction } from "./experiments";
import { lastOpened, LastOpenedAction } from "./last_opened";
import { locale, LocaleAction } from "./locale";
import { notifications, NotificationsAction } from "./notifications";
import { queue, QueueAction } from "./queue";
import { sectionToggle, SectionToggleAction } from "./section_toggle";
import { config, ConfigAction } from "./server_config";
import { settings, SettingsAction } from "./settings";
import { sync, SyncAction } from "./sync";
import { unreads, UnreadsAction } from "./unreads";
export default combineReducers({
config,
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments,
lastOpened,
notifications,
sectionToggle,
});
export type Action =
| ConfigAction
| LocaleAction
| AuthAction
| SettingsAction
| UnreadsAction
| QueueAction
| DraftAction
| SyncAction
| ExperimentsAction
| LastOpenedAction
| NotificationsAction
| SectionToggleAction
| { type: "__INIT"; state: State };
export interface LastOpened {
[key: string]: string;
}
export type LastOpenedAction =
| { type: undefined }
| {
type: "LAST_OPENED_SET";
parent: string;
child: string;
}
| {
type: "RESET";
};
export function lastOpened(
state = {} as LastOpened,
action: LastOpenedAction,
): LastOpened {
switch (action.type) {
case "LAST_OPENED_SET": {
return {
...state,
[action.parent]: action.child,
};
}
case "RESET":
return {};
default:
return state;
}
}
import { Language } from "../../context/Locale";
import type { SyncUpdateAction } from "./sync";
export type LocaleAction =
| { type: undefined }
| {
type: "SET_LOCALE";
locale: Language;
}
| SyncUpdateAction;
export function findLanguage(lang?: string): Language {
if (!lang) {
if (typeof navigator === "undefined") {
lang = Language.ENGLISH;
} else {
lang = navigator.language;
}
}
const code = lang.replace("-", "_");
const short = code.split("_")[0];
const values = [];
for (const key in Language) {
const value = Language[key as keyof typeof Language];
values.push(value);
if (value.startsWith(code)) {
return value as Language;
}
}
for (const value of values.reverse()) {
if (value.startsWith(short)) {
return value as Language;
}
}
return Language.ENGLISH;
}
export function locale(state = findLanguage(), action: LocaleAction): Language {
switch (action.type) {
case "SET_LOCALE":
return action.locale;
case "SYNC_UPDATE":
return (action.update.locale?.[1] ?? state) as Language;
default:
return state;
}
}
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import type { SyncUpdateAction } from "./sync";
export type NotificationState = "all" | "mention" | "none" | "muted";
export type Notifications = {
[key: string]: NotificationState;
};
export const DEFAULT_STATES: {
[key in Channel["channel_type"]]: NotificationState;
} = {
SavedMessages: "all",
DirectMessage: "all",
Group: "all",
TextChannel: "mention",
VoiceChannel: "mention",
};
export function getNotificationState(
notifications: Notifications,
channel: Channel,
) {
return notifications[channel._id] ?? DEFAULT_STATES[channel.channel_type];
}
export function shouldNotify(
state: NotificationState,
message: Message,
user_id: string,
) {
switch (state) {
case "muted":
case "none":
return false;
case "mention": {
if (!message.mention_ids?.includes(user_id)) return false;
}
}
return true;
}
export type NotificationsAction =
| { type: undefined }
| {
type: "NOTIFICATIONS_SET";
key: string;
state: NotificationState;
}
| {
type: "NOTIFICATIONS_REMOVE";
key: string;
}
| SyncUpdateAction
| {
type: "RESET";
};
export function notifications(
state = {} as Notifications,
action: NotificationsAction,
): Notifications {
switch (action.type) {
case "NOTIFICATIONS_SET":
return {
...state,
[action.key]: action.state,
};
case "NOTIFICATIONS_REMOVE": {
const { [action.key]: _, ...newState } = state;
return newState;
}
case "SYNC_UPDATE":
return action.update.notifications?.[1] ?? state;
case "RESET":
return {};
default:
return state;
}
}
export enum QueueStatus {
SENDING = "sending",
ERRORED = "errored",
}
export interface Reply {
id: string;
mention: boolean;
}
export type QueuedMessageData = {
_id: string;
author: string;
channel: string;
content: string;
replies: Reply[];
};
export interface QueuedMessage {
id: string;
channel: string;
data: QueuedMessageData;
status: QueueStatus;
error?: string;
}
export type QueueAction =
| { type: undefined }
| {
type: "QUEUE_ADD";
nonce: string;
channel: string;
message: QueuedMessageData;
}
| {
type: "QUEUE_FAIL";
nonce: string;
error: string;
}
| {
type: "QUEUE_START";
nonce: string;
}
| {
type: "QUEUE_REMOVE";
nonce: string;
}
| {
type: "QUEUE_DROP_ALL";
}
| {
type: "QUEUE_FAIL_ALL";
}
| {
type: "RESET";
};
export function queue(
state: QueuedMessage[] = [],
action: QueueAction,
): QueuedMessage[] {
switch (action.type) {
case "QUEUE_ADD": {
return [
...state.filter((x) => x.id !== action.nonce),
{
id: action.nonce,
data: action.message,
channel: action.channel,
status: QueueStatus.SENDING,
},
];
}
case "QUEUE_FAIL": {
const entry = state.find(
(x) => x.id === action.nonce,
) as QueuedMessage;
return [
...state.filter((x) => x.id !== action.nonce),
{
...entry,
status: QueueStatus.ERRORED,
error: action.error,
},
];
}
case "QUEUE_START": {
const entry = state.find(
(x) => x.id === action.nonce,
) as QueuedMessage;
return [
...state.filter((x) => x.id !== action.nonce),
{
...entry,
status: QueueStatus.SENDING,
},
];
}
case "QUEUE_REMOVE":
return state.filter((x) => x.id !== action.nonce);
case "QUEUE_FAIL_ALL":
return state.map((x) => {
return {
...x,
status: QueueStatus.ERRORED,
};
});
case "QUEUE_DROP_ALL":
case "RESET":
return [];
default:
return state;
}
}
export interface SectionToggle {
[key: string]: boolean;
}
export type SectionToggleAction =
| { type: undefined }
| {
type: "SECTION_TOGGLE_SET";
id: string;
state: boolean;
}
| {
type: "SECTION_TOGGLE_UNSET";
id: string;
}
| {
type: "RESET";
};
export function sectionToggle(
state = {} as SectionToggle,
action: SectionToggleAction,
): SectionToggle {
switch (action.type) {
case "SECTION_TOGGLE_SET": {
return {
...state,
[action.id]: action.state,
};
}
case "SECTION_TOGGLE_UNSET": {
const { [action.id]: _, ...newState } = state;
return newState;
}
case "RESET":
return {};
default:
return state;
}
}
import type { RevoltConfiguration } from "revolt-api/types/Core";
export type ConfigAction =
| { type: undefined }
| {
type: "SET_CONFIG";
config: RevoltConfiguration;
};
export function config(
state = {} as RevoltConfiguration,
action: ConfigAction,
): RevoltConfiguration {
switch (action.type) {
case "SET_CONFIG":
return action.config;
default:
return state;
}
}
import type { Theme, ThemeOptions } from "../../context/Theme";
import { setEmojiPack } from "../../components/common/Emoji";
import type { Sounds } from "../../assets/sounds/Audio";
import type { SyncUpdateAction } from "./sync";
export type SoundOptions = {
[key in Sounds]?: boolean;
};
export const DEFAULT_SOUNDS: SoundOptions = {
message: true,
outbound: false,
call_join: true,
call_leave: true,
};
export interface NotificationOptions {
desktopEnabled?: boolean;
sounds?: SoundOptions;
}
export type EmojiPacks = "mutant" | "twemoji" | "noto" | "openmoji";
export interface AppearanceOptions {
emojiPack?: EmojiPacks;
}
export interface Settings {
theme?: ThemeOptions;
appearance?: AppearanceOptions;
notification?: NotificationOptions;
}
export type SettingsAction =
| { type: undefined }
| {
type: "SETTINGS_SET_THEME";
theme: ThemeOptions;
}
| {
type: "SETTINGS_SET_THEME_OVERRIDE";
custom?: Partial<Theme>;
}
| {
type: "SETTINGS_SET_NOTIFICATION_OPTIONS";
options: NotificationOptions;
}
| {
type: "SETTINGS_SET_APPEARANCE";
options: Partial<AppearanceOptions>;
}
| SyncUpdateAction
| {
type: "RESET";
};
export function settings(
state = {} as Settings,
action: SettingsAction,
): Settings {
setEmojiPack(state.appearance?.emojiPack ?? "mutant");
switch (action.type) {
case "SETTINGS_SET_THEME":
return {
...state,
theme: {
...state.theme,
...action.theme,
},
};
case "SETTINGS_SET_THEME_OVERRIDE":
return {
...state,
theme: {
...state.theme,
custom: {
...state.theme?.custom,
...action.custom,
},
},
};
case "SETTINGS_SET_NOTIFICATION_OPTIONS":
return {
...state,
notification: {
...state.notification,
...action.options,
},
};
case "SETTINGS_SET_APPEARANCE":
return {
...state,
appearance: {
...state.appearance,
...action.options,
},
};
case "SYNC_UPDATE":
return {
...state,
appearance: action.update.appearance?.[1] ?? state.appearance,
theme: action.update.theme?.[1] ?? state.theme,
};
case "RESET":
return {};
default:
return state;
}
}
import type { Language } from "../../context/Locale";
import type { ThemeOptions } from "../../context/Theme";
import type { Notifications } from "./notifications";
import type { AppearanceOptions } from "./settings";
export type SyncKeys = "theme" | "appearance" | "locale" | "notifications";
export interface SyncData {
locale?: Language;
theme?: ThemeOptions;
appearance?: AppearanceOptions;
notifications?: Notifications;
}
export const DEFAULT_ENABLED_SYNC: SyncKeys[] = [
"theme",
"appearance",
"locale",
"notifications",
];
export interface SyncOptions {
disabled?: SyncKeys[];
revision?: {
[key: string]: number;
};
}
export type SyncUpdateAction = {
type: "SYNC_UPDATE";
update: { [key in SyncKeys]?: [number, SyncData[key]] };
};
export type SyncAction =
| { type: undefined }
| {
type: "SYNC_ENABLE_KEY";
key: SyncKeys;
}
| {
type: "SYNC_DISABLE_KEY";
key: SyncKeys;
}
| {
type: "SYNC_SET_REVISION";
key: SyncKeys;
timestamp: number;
}
| SyncUpdateAction;
export function sync(
state = {} as SyncOptions,
action: SyncAction,
): SyncOptions {
switch (action.type) {
case "SYNC_DISABLE_KEY":
return {
...state,
disabled: [
...(state.disabled ?? []).filter((v) => v !== action.key),
action.key,
],
};
case "SYNC_ENABLE_KEY":
return {
...state,
disabled: state.disabled?.filter((v) => v !== action.key),
};
case "SYNC_SET_REVISION":
return {
...state,
revision: {
...state.revision,
[action.key]: action.timestamp,
},
};
case "SYNC_UPDATE": {
const revision = { ...state.revision };
for (const key of Object.keys(action.update)) {
const value = action.update[key as SyncKeys];
if (value) {
revision[key] = value[0];
}
}
return {
...state,
revision,
};
}
default:
return state;
}
}
import type { ChannelUnread } from "revolt-api/types/Sync";
export interface Unreads {
[key: string]: Partial<Omit<ChannelUnread, "_id">>;
}
export type UnreadsAction =
| { type: undefined }
| {
type: "UNREADS_MARK_READ";
channel: string;
message: string;
}
| {
type: "UNREADS_SET";
unreads: ChannelUnread[];
}
| {
type: "UNREADS_MENTION";
channel: string;
message: string;
}
| {
type: "RESET";
};
export function unreads(state = {} as Unreads, action: UnreadsAction): Unreads {
switch (action.type) {
case "UNREADS_MARK_READ":
return {
...state,
[action.channel]: {
last_id: action.message,
},
};
case "UNREADS_SET": {
const obj: Unreads = {};
for (const entry of action.unreads) {
const { _id, ...v } = entry;
obj[_id.channel] = v;
}
return obj;
}
case "UNREADS_MENTION": {
const obj = state[action.channel];
return {
...state,
[action.channel]: {
...obj,
mentions: [...(obj?.mentions ?? []), action.message],
},
};
}
case "RESET":
return {};
default:
return state;
}
}
/* eslint-disable */
// Strings needs to be explictly stated here as they can cause type issues elsewhere.
export const REPO_URL: string =
"https://gitlab.insrt.uk/revolt/revite/-/commit";
export const GIT_REVISION: string = "__GIT_REVISION__";
export const GIT_BRANCH: string = "__GIT_BRANCH__";
.preact-context-menu .context-menu {
z-index: 5000;
min-width: 190px;
padding: 6px 8px;
user-select: none;
font-size: .875rem;
color: var(--secondary-foreground);
border-radius: var(--border-radius);
background: var(--primary-background) !important;
box-shadow: 0px 0px 8px 8px rgba(0, 0, 0, 0.05);
> span, .main > span {
gap: 6px;
margin: 2px 0;
display: flex;
padding: 6px 8px;
border-radius: 3px;
align-items: center;
white-space: nowrap;
&:not([data-disabled="true"]) {
cursor: pointer;
&:hover {
background: var(--secondary-background);
}
}
.tip {
flex-grow: 1;
font-size: .650rem;
text-align: right;
color: var(--tertiary-foreground);
}
}
}
.context-menu.Status {
.header {
gap: 8px;
display: flex;
padding: 6px 8px;
font-weight: 600;
align-items: center;
color: var(--foreground);
.main {
flex-grow: 1;
display: flex;
flex-direction: column;
.username {
> div {
cursor: pointer;
width: fit-content;
}
}
}
.status {
cursor: pointer;
max-width: 132px;
font-size: .625rem;
color: var(--secondary-foreground);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
&.online {
background: var(--status-online);
}
&.idle {
background: var(--status-away);
}
&.busy {
background: var(--status-busy);
}
&.invisible {
background: var(--status-invisible);
}
}
}
:disabled {
opacity: 0.5;
pointer-events: none;
}
::-webkit-scrollbar {
width: 3px;
height: 3px;
......@@ -11,6 +16,10 @@
background: var(--scrollbar-thumb);
}
::-webkit-scrollbar-corner {
background: transparent;
}
::selection {
background: var(--accent);
color: var(--foreground);
......@@ -39,3 +48,7 @@ hr {
height: 1px;
flex-grow: 1;
}
foreignObject > svg {
vertical-align: top !important;
}
@import "@fontsource/open-sans/300.css";
@import "@fontsource/open-sans/400.css";
@import "@fontsource/open-sans/600.css";
@import "@fontsource/open-sans/700.css";
@import "@fontsource/open-sans/300-italic.css";
@import "@fontsource/open-sans/400-italic.css";
@import "@fontsource/open-sans/600-italic.css";
@import "@fontsource/open-sans/700-italic.css";
......@@ -8,15 +8,19 @@
}
html {
contain: content;
background: var(--background);
// contain: content;
background-size: cover !important;
background-repeat: no-repeat !important;
background-color: var(--background) !important;
}
html,
body {
font-family: "Open Sans", sans-serif;
margin: 0;
height: 100%;
font-family: var(--font), sans-serif;
font-variant-ligatures: var(--ligatures);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
caret-color: var(--accent);
......@@ -29,3 +33,11 @@ body {
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
#app {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
:root {
/**
* Appearance
*/
--ligatures: none;
--text-size: 14px;
--font: "Open Sans";
--codeblock-font: "Fira Code";
--sidebar-active: var(--secondary-background);
/**
* Native
*/
--titlebar-height: 29px;
--titlebar-action-padding: 8px;
--titlebar-logo-color: var(--secondary-foreground);
/**
* Layout
*/
--app-height: 100vh;
--border-radius: 6px;
--input-border-width: 2px;
--textarea-padding: 16px;
--textarea-line-height: 20px;
--message-box-padding: 14px 14px 14px 0;
--attachment-max-width: 480px;
--attachment-max-height: 640px;
--attachment-default-width: 400px;
--attachment-max-text-width: 800px;
--bottom-navigation-height: 50px;
/**
* Experimental
*/
--background-rgb: (
25,
25,
25
); //THIS IS SO THAT WE CAN HAVE CUSTOM BACKGROUNDS FOR THE CLIENT, CONVERTS THE HEX TO AN RGB VALUE FROM --background
--background-rgba: rgba(
var(--background-rgb),
0.8
); //make the opacity also customizable
}