Skip to content
Snippets Groups Projects
Commit 27eeb3ac authored by insert's avatar insert
Browse files

Add Redux and reducers.

Load i18n files and add dayjs.
parent 0cba2b36
No related merge requests found
Showing
with 892 additions and 43 deletions
......@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
<title>REVOLT</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/entrypoints/main.tsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
......@@ -3,7 +3,7 @@
"scripts": {
"dev": "vite",
"build": "rimraf build && tsc && vite build",
"serve": "vite preview",
"preview": "vite preview",
"lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
"fmt": "prettier --write 'src/**/*.{js,jsx,ts,tsx}'"
},
......@@ -30,14 +30,22 @@
"@styled-icons/feather": "^10.34.0",
"@types/node": "^15.12.3",
"@types/preact-i18n": "^2.3.0",
"@types/react-helmet": "^6.1.1",
"@types/styled-components": "^5.1.10",
"@typescript-eslint/eslint-plugin": "^4.27.0",
"@typescript-eslint/parser": "^4.27.0",
"dayjs": "^1.10.5",
"eslint": "^7.28.0",
"eslint-config-preact": "^1.1.4",
"localforage": "^1.9.0",
"preact-i18n": "^2.4.0-preactx",
"prettier": "^2.3.1",
"react-device-detect": "^1.17.0",
"react-helmet": "^6.1.0",
"react-overlapping-panels": "1.1.2-patch.0",
"react-redux": "^7.2.4",
"redux": "^4.1.0",
"revolt.js": "4.2.0-alpha.3-patch.0",
"rimraf": "^3.0.2",
"sass": "^1.35.1",
"styled-components": "^5.3.0",
......
import { Text } from "preact-i18n";
import Context from "./context";
import dayjs from "dayjs";
import localeData from 'dayjs/plugin/localeData';
dayjs.extend(localeData)
export function App() {
return (
<>
<h1>REVOLT</h1>
</>
<Context>
<h1><Text id="general.about" /></h1>
<h3>{ dayjs.locale() }</h3>
<h2>{ dayjs.months() }</h2>
</Context>
);
}
import { IntlProvider } from "preact-i18n";
import { connectState } from "../redux/connector";
import definition from "../../external/lang/en.json";
import { useEffect, useState } from "preact/hooks";
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
import update from "dayjs/plugin/updateLocale";
import format from "dayjs/plugin/localizedFormat";
dayjs.extend(calendar);
dayjs.extend(format);
dayjs.extend(update);
export enum Language {
ENGLISH = "en",
ARABIC = "ar",
AZERBAIJANI = "az",
CZECH = "cs",
GERMAN = "de",
SPANISH = "es",
FINNISH = "fi",
FRENCH = "fr",
HINDI = "hi",
CROATIAN = "hr",
HUNGARIAN = "hu",
INDONESIAN = "id",
LITHUANIAN = "lt",
MACEDONIAN = "mk",
DUTCH = "nl",
POLISH = "pl",
PORTUGUESE_BRAZIL = "pt_BR",
ROMANIAN = "ro",
RUSSIAN = "ru",
SERBIAN = "sr",
SWEDISH = "sv",
TURKISH = "tr",
UKRANIAN = "uk",
CHINESE_SIMPLIFIED = "zh_Hans",
OWO = "owo",
PIRATE = "pr",
BOTTOM = "bottom",
PIGLATIN = "piglatin",
HARDCORE = "hardcore"
}
export interface LanguageEntry {
display: string;
emoji: string;
i18n: string;
dayjs?: string;
rtl?: boolean;
}
export const Languages: { [key in Language]: LanguageEntry } = {
en: {
display: "English (Traditional)",
emoji: "🇬🇧",
i18n: "en",
dayjs: "en-gb"
},
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
pt_BR: {
display: "Português (do Brasil)",
emoji: "🇧🇷",
i18n: "pt_BR",
dayjs: "pt-br"
},
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
zh_Hans: {
display: "中文 (简体)",
emoji: "🇨🇳",
i18n: "zh_Hans",
dayjs: "zh"
},
owo: { display: "OwO", emoji: "🐱", i18n: "owo", dayjs: "en-gb" },
pr: { display: "Pirate", emoji: "🏴‍☠️", i18n: "pr", dayjs: "en-gb" },
bottom: { display: "Bottom", emoji: "🥺", i18n: "bottom", dayjs: "en-gb" },
piglatin: { display: "Pig Latin", emoji: "🐖", i18n: "piglatin", dayjs: "en-gb" },
hardcore: {
display: "Hardcore Mode",
emoji: "🔥",
i18n: "hardcore",
dayjs: "en-gb"
}
};
interface Props {
children: JSX.Element | JSX.Element[];
locale: Language;
}
export default function Locale({ children }: Props) {
return <IntlProvider definition={definition}>{children}</IntlProvider>;
function Locale({ children, locale }: Props) {
const [defns, setDefinition] = useState(definition);
const lang = Languages[locale];
useEffect(() => {
if (locale === 'en') {
setDefinition(definition);
dayjs.locale('en');
return;
}
if (lang.i18n === "hardcore") {
setDefinition({} as any);
return;
}
import(
`../../external/lang/${lang.i18n}.json`
).then(async lang_file => {
let defn = lang_file.default;
let target = lang.dayjs ?? lang.i18n;
let dayjs_locale = await import(/* @vite-ignore */ `/node_modules/dayjs/esm/locale/${target}.js`);
if (defn.dayjs) {
dayjs.updateLocale(target, { calendar: defn.dayjs });
}
dayjs.locale(dayjs_locale.default);
setDefinition(defn);
});
}, [locale]);
useEffect(() => {
document.body.style.direction = lang.rtl ? "rtl" : "";
}, [ lang.rtl ]);
return <IntlProvider definition={defns}>{children}</IntlProvider>;
}
export default connectState<Omit<Props, 'locale'>>(
Locale,
state => {
return {
locale: state.locale
};
},
true
);
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import { createGlobalStyle } from "styled-components";
import { Children } from "../types/Preact";
import { Helmet } from "react-helmet";
// ! TEMP START
const a = {
light: false,
accent: "#FD6671",
background: "#191919",
foreground: "#F6F6F6",
block: "#2D2D2D",
"message-box": "#363636",
mention: "rgba(251, 255, 0, 0.06)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.1)",
"sidebar-active": "#FD6671",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#242424",
"primary-header": "#363636",
"secondary-background": "#1E1E1E",
"secondary-foreground": "#C8C8C8",
"secondary-header": "#2D2D2D",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#848484",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
export type Variables =
| "accent"
| "background"
| "foreground"
| "block"
| "message-box"
| "mention"
| "success"
| "warning"
| "error"
| "hover"
| "sidebar-active"
| "scrollbar-thumb"
| "scrollbar-track"
| "primary-background"
| "primary-header"
| "secondary-background"
| "secondary-foreground"
| "secondary-header"
| "tertiary-background"
| "tertiary-foreground"
| "status-online"
| "status-away"
| "status-busy"
| "status-streaming"
| "status-invisible";
export type Theme = {
[variable in Variables]: string;
} & {
light?: boolean;
css?: string;
};
export interface ThemeOptions {
preset?: string;
custom?: Partial<Theme>;
}
// Generated from https://gitlab.insrt.uk/revolt/community/themes
export const PRESETS: { [key: string]: Theme } = {
light: {
light: true,
accent: "#FD6671",
background: "#F6F6F6",
foreground: "#101010",
block: "#414141",
"message-box": "#F1F1F1",
mention: "rgba(251, 255, 0, 0.40)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.2)",
"sidebar-active": "#FD6671",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#FFFFFF",
"primary-header": "#F1F1F1",
"secondary-background": "#F1F1F1",
"secondary-foreground": "#888888",
"secondary-header": "#F1F1F1",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#646464",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
dark: {
light: false,
accent: "#FD6671",
background: "#191919",
foreground: "#F6F6F6",
block: "#2D2D2D",
"message-box": "#363636",
mention: "rgba(251, 255, 0, 0.06)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.1)",
"sidebar-active": "#FD6671",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#242424",
"primary-header": "#363636",
"secondary-background": "#1E1E1E",
"secondary-foreground": "#C8C8C8",
"secondary-header": "#2D2D2D",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#848484",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
};
export const GlobalTheme = createGlobalStyle`
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
:root {
${Object.keys(a).map((key) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return `--${key}: ${(a as any)[key]};`;
})}
${(props) =>
(Object.keys(props.theme) as Variables[]).map((key) => {
return `--${key}: ${props.theme[key]};`;
})}
}
`;
// ! TEMP END
interface Props {
children: Children;
}
export default function Theme(props: Props) {
const theme = PRESETS.dark;
return (
<>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["primary-header"]
: theme["tertiary-background"]
}
/>
</Helmet>
<GlobalTheme theme={theme} />
{props.children}
</>
);
}
import State from "../redux/State";
import { Children } from "../types/Preact";
import Locale from "./Locale";
import Theme from "./Theme";
export default function Context({ children }: { children: Children }) {
return (
<State>
<Locale>
<Theme>{children}</Theme>
</Locale>
</State>
);
}
import { Client } from 'revolt.js';
export enum ClientStatus {
LOADING,
READY,
OFFLINE,
DISCONNECTED,
CONNECTING,
RECONNECTING,
ONLINE
}
export const RevoltJSClient = new Client({
autoReconnect: false,
apiURL: process.env.API_SERVER,
debug: process.env.NODE_ENV === "development",
// Match sw.js#13
// db: new Db("state", 3, ["channels", "servers", "users", "members"])
});
import { Message } from "revolt.js/dist/api/objects";
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date
} as MessageObject;
}
import { isDesktop, isMobile, isTablet } from "react-device-detect";
export const isTouchscreenDevice =
isDesktop && !isTablet
? false
: (typeof window !== "undefined"
? navigator.maxTouchPoints > 0
: false) || isMobile;
import { render } from "preact";
import "../styles/index.scss";
import "./styles/index.scss";
import { App } from "./app";
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
......
import { store } from ".";
import localForage from "localforage";
import { Provider } from 'react-redux';
import { Children } from "../types/Preact";
import { useEffect, useState } from "preact/hooks";
async function loadState() {
const state = await localForage.getItem("state");
if (state) {
store.dispatch({ type: "__INIT", state });
}
}
interface Props {
children: Children
}
export default function State(props: Props) {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
loadState().then(() => setLoaded(true));
}, []);
if (!loaded) return null;
return (
<Provider store={store}>
{ props.children }
</Provider>
)
}
import { State } from ".";
import { h } from "preact";
//import { memo } from "preact/compat";
import { connect, ConnectedComponent } from "react-redux";
export function connectState<T>(
component: (props: any) => h.JSX.Element | null,
mapKeys: (state: State, props: T) => any,
useDispatcher?: boolean
): ConnectedComponent<(props: any) => h.JSX.Element | null, T> {
return (
useDispatcher
? connect(mapKeys, dispatcher => { return { dispatcher } })
: connect(mapKeys)
)(component);//(memo(component));
}
import { createStore } from "redux";
import rootReducer from "./reducers";
import localForage from "localforage";
import { Typing } from "./reducers/typing";
import { Drafts } from "./reducers/drafts";
import { AuthState } from "./reducers/auth";
import { Language } from "../context/Locale";
import { Unreads } from "./reducers/unreads";
import { SyncOptions } from "./reducers/sync";
import { Settings } from "./reducers/settings";
import { QueuedMessage } from "./reducers/queue";
import { ExperimentOptions } from "./reducers/experiments";
export type State = {
locale: Language;
auth: AuthState;
settings: Settings;
unreads: Unreads;
queue: QueuedMessage[];
typing: Typing;
drafts: Drafts;
sync: SyncOptions;
experiments: ExperimentOptions;
};
export const store = createStore((state: any, action: any) => {
if (process.env.NODE_ENV === "development") {
console.debug("State Update:", action);
}
if (action.type === "__INIT") {
return action.state;
}
return rootReducer(state, action);
});
// Save state using localForage.
store.subscribe(() => {
const {
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments
} = store.getState() as State;
localForage.setItem("state", {
locale,
auth,
settings,
unreads,
queue,
drafts,
sync,
experiments
});
});
import { Auth } from "revolt.js/dist/api/objects";
export interface AuthState {
accounts: {
[key: string]: {
session: Auth.Session;
};
};
active?: string;
}
export type AuthAction =
| { type: undefined }
| {
type: "LOGIN";
session: Auth.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":
const { [action.channel]: _, ...newState } = state;
return newState;
case "RESET":
return {};
default:
return state;
}
}
export type Experiments = never;
export const AVAILABLE_EXPERIMENTS: Experiments[] = [ ];
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 { settings, SettingsAction } from "./settings";
import { locale, LocaleAction } from "./locale";
import { auth, AuthAction } from "./auth";
import { unreads, UnreadsAction } from "./unreads";
import { queue, QueueAction } from "./queue";
import { typing, TypingAction } from "./typing";
import { drafts, DraftAction } from "./drafts";
import { sync, SyncAction } from "./sync";
import { experiments, ExperimentsAction } from "./experiments";
export default combineReducers({
locale,
auth,
settings,
unreads,
queue,
typing,
drafts,
sync,
experiments
});
export type Action =
| LocaleAction
| AuthAction
| SettingsAction
| UnreadsAction
| QueueAction
| TypingAction
| DraftAction
| SyncAction
| ExperimentsAction
| { type: "__INIT"; state: any };
export type WithDispatcher = { dispatcher: (action: Action) => void };
export function filter(obj: any, keys: string[]) {
const newObj: any = {};
for (const key of keys) {
const v = obj[key];
if (v) newObj[key] = v;
}
return newObj;
}
import { Language } from "../../context/Locale";
import { SyncData, SyncKeys, 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];
for (const key of Object.keys(Language)) {
const value = (Language as any)[key];
if (value.startsWith(code)) {
return value;
}
}
for (const key of Object.keys(Language).reverse()) {
const value = (Language as any)[key];
if (value.startsWith(short)) {
return value;
}
}
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 { MessageObject } from "../../context/revoltjs/messages";
export enum QueueStatus {
SENDING = "sending",
ERRORED = "errored"
}
export interface QueuedMessage {
id: string;
channel: string;
data: MessageObject;
status: QueueStatus;
error?: string;
}
export type QueueAction =
| { type: undefined }
| {
type: "QUEUE_ADD";
nonce: string;
channel: string;
message: MessageObject;
}
| {
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;
}
}
import { filter } from ".";
import { SyncUpdateAction } from "./sync";
import { Theme, ThemeOptions } from "../../context/Theme";
export interface NotificationOptions {
desktopEnabled?: boolean;
soundEnabled?: boolean;
outgoingSoundEnabled?: boolean;
}
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: {
...filter(state.theme, [ 'custom', 'preset' ]),
...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: {
...filter(state.appearance, [ 'emojiPack' ]),
...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;
}
}
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment