1
0
Fork 0
mirror of https://github.com/mat-1/variance.git synced 2025-08-02 15:26:04 +00:00

prepare to start adding custom themes

This commit is contained in:
mat 2023-10-21 21:27:24 -05:00
parent 2e55b739b8
commit 8c803d4c45
8 changed files with 246 additions and 120 deletions

View file

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import PropTypes from 'prop-types';
import './SegmentedControls.scss';
import { blurOnBubbling } from '../button/script';
@ -7,26 +6,38 @@ import { blurOnBubbling } from '../button/script';
import Text from '../text/Text';
import RawIcon from '../system-icons/RawIcon';
function SegmentedControls({ selected, segments, onSelect }) {
const [select, setSelect] = useState(selected);
function SegmentedControls({
selectedId,
segments,
onSelect,
}: {
selectedId: string;
segments: {
iconSrc?: string;
text?: string;
id: string;
}[];
onSelect: (id: string) => void;
}) {
const [select, setSelect] = useState(selectedId);
function selectSegment(segmentIndex) {
setSelect(segmentIndex);
onSelect(segmentIndex);
function selectSegment(segmentId: string) {
setSelect(segmentId);
onSelect(segmentId);
}
useEffect(() => {
setSelect(selected);
}, [selected]);
setSelect(selectedId);
}, [selectedId]);
return (
<div className="segmented-controls">
{segments.map((segment, index) => (
{segments.map((segment) => (
<button
key={Math.random().toString(20).substr(2, 6)}
className={`segment-btn${select === index ? ' segment-btn--active' : ''}`}
key={Math.random().toString(20).slice(2, 6)}
className={`segment-btn${select === segment.id ? ' segment-btn--active' : ''}`}
type="button"
onClick={() => selectSegment(index)}
onClick={() => selectSegment(segment.id)}
onMouseUp={(e) => blurOnBubbling(e, '.segment-btn')}
>
<div className="segment-btn__base">
@ -39,15 +50,4 @@ function SegmentedControls({ selected, segments, onSelect }) {
);
}
SegmentedControls.propTypes = {
selected: PropTypes.number.isRequired,
segments: PropTypes.arrayOf(
PropTypes.shape({
iconSrc: PropTypes.string,
text: PropTypes.string,
}),
).isRequired,
onSelect: PropTypes.func.isRequired,
};
export default SegmentedControls;

View file

@ -128,14 +128,14 @@ function RoomMembers({ roomId }) {
searchMembers ? `Found — ${mList.length}` : members.length
} members`}</MenuHeader>
<SegmentedControls
selected={(() => {
const getSegmentIndex = { join: 0, invite: 1, ban: 2 };
return getSegmentIndex[membership];
})()}
segments={[{ text: 'Joined' }, { text: 'Invited' }, { text: 'Banned' }]}
onSelect={(index) => {
const memberships = ['join', 'invite', 'ban'];
setMembership(memberships[index]);
selectedId={membership}
segments={[
{ text: 'Joined', id: 'join' },
{ text: 'Invited', id: 'invite' },
{ text: 'Banned', id: 'ban' },
]}
onSelect={(id) => {
setMembership(id);
}}
/>
</div>

View file

@ -98,17 +98,16 @@ function AppearanceSection() {
title="Theme"
content={
<SegmentedControls
selected={settings.useSystemTheme ? -1 : settings.getThemeIndex()}
segments={[
{ text: 'Light' },
{ text: 'Silver' },
{ text: 'Dark' },
{ text: 'Butter' },
{ text: 'Ayu' },
]}
onSelect={(index) => {
selectedId={settings.getThemeSettings().getThemeId()}
segments={Array.from(settings.getThemeSettings().themeIdToName).map(
([themeId, themeName]) => ({
text: themeName,
id: themeId,
}),
)}
onSelect={(themeId: string) => {
if (settings.useSystemTheme) toggleSystemTheme();
settings.setTheme(index);
settings.setThemeId(themeId);
updateState({});
}}
/>

View file

@ -2,14 +2,15 @@ import EventEmitter from 'events';
import appDispatcher from '../dispatcher';
import cons from './cons';
import { ThemeSettings } from './themes';
function getSettings() {
function getSettings(): Record<string, unknown> | null {
const settings = localStorage.getItem('settings');
if (settings === null) return null;
return JSON.parse(settings);
}
function setSettings(key, value) {
function setSettings(key: string, value: unknown) {
let settings = getSettings();
if (settings === null) settings = {};
settings[key] = value;
@ -19,11 +20,7 @@ function setSettings(key, value) {
class Settings extends EventEmitter {
isTouchScreenDevice: boolean;
themes: string[];
themeIndex: number;
useSystemTheme: boolean;
themeSettings: ThemeSettings;
isMarkdown: boolean;
@ -56,10 +53,8 @@ class Settings extends EventEmitter {
this.isTouchScreenDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
this.themes = ['', 'silver-theme', 'dark-theme', 'butter-theme', 'ayu-theme'];
this.themeIndex = this.getThemeIndex();
this.themeSettings = this.getThemeSettings();
this.useSystemTheme = this.getUseSystemTheme();
this.isMarkdown = this.getIsMarkdown();
this.isPeopleDrawer = this.getIsPeopleDrawer();
this.hideMembershipEvents = this.getHideMembershipEvents();
@ -75,88 +70,65 @@ class Settings extends EventEmitter {
this.clearUrls = this.getClearUrls();
}
getThemeIndex() {
if (typeof this.themeIndex === 'number') return this.themeIndex;
getThemeSettings(): ThemeSettings {
if (this.themeSettings !== undefined) return this.themeSettings;
const settings = getSettings();
if (settings === null) return 0;
if (typeof settings.themeIndex === 'undefined') return 0;
// eslint-disable-next-line radix
return parseInt(settings.themeIndex);
const themeSettings = ThemeSettings.fromSettings(settings ?? {});
return themeSettings;
}
getThemeName() {
return this.themes[this.themeIndex];
}
updateThemeSettings(): void {
const settings = this.getThemeSettings().toSettings();
_clearTheme() {
document.body.classList.remove('system-theme');
this.themes.forEach((themeName) => {
if (themeName === '') return;
document.body.classList.remove(themeName);
Object.entries(settings).forEach(([key, value]) => {
setSettings(key, value);
});
}
applyTheme() {
this._clearTheme();
if (this.useSystemTheme) {
document.body.classList.add('system-theme');
} else if (this.themes[this.themeIndex]) {
document.body.classList.add(this.themes[this.themeIndex]);
}
}
setTheme(themeIndex) {
this.themeIndex = themeIndex;
setSettings('themeIndex', this.themeIndex);
this.applyTheme();
setThemeId(themeId: string): void {
this.themeSettings.setThemeId(themeId);
this.updateThemeSettings();
this.themeSettings.applyTheme();
}
toggleUseSystemTheme() {
this.useSystemTheme = !this.useSystemTheme;
setSettings('useSystemTheme', this.useSystemTheme);
this.applyTheme();
this.updateThemeSettings();
this.themeSettings.applyTheme();
this.emit(cons.events.settings.SYSTEM_THEME_TOGGLED, this.useSystemTheme);
}
getUseSystemTheme() {
if (typeof this.useSystemTheme === 'boolean') return this.useSystemTheme;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.useSystemTheme === 'undefined') return true;
return settings.useSystemTheme;
}
getIsMarkdown() {
getIsMarkdown(): boolean {
if (typeof this.isMarkdown === 'boolean') return this.isMarkdown;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.isMarkdown === 'undefined') return true;
if (typeof settings.isMarkdown !== 'boolean') return true;
return settings.isMarkdown;
}
getHideMembershipEvents() {
getHideMembershipEvents(): boolean {
if (typeof this.hideMembershipEvents === 'boolean') return this.hideMembershipEvents;
const settings = getSettings();
if (settings === null) return false;
if (typeof settings.hideMembershipEvents === 'undefined') return false;
if (typeof settings.hideMembershipEvents !== 'boolean') return false;
return settings.hideMembershipEvents;
}
getHideNickAvatarEvents() {
getHideNickAvatarEvents(): boolean {
if (typeof this.hideNickAvatarEvents === 'boolean') return this.hideNickAvatarEvents;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.hideNickAvatarEvents === 'undefined') return true;
if (typeof settings.hideNickAvatarEvents !== 'boolean') return true;
return settings.hideNickAvatarEvents;
}
getSendOnEnter() {
getSendOnEnter(): boolean {
if (typeof this.sendMessageOnEnter === 'boolean') return this.sendMessageOnEnter;
const settings = getSettings();
@ -164,48 +136,48 @@ class Settings extends EventEmitter {
const defaultSendOnEnter = !this.isTouchScreenDevice;
if (settings === null) return defaultSendOnEnter;
if (typeof settings.sendMessageOnEnter === 'undefined') return defaultSendOnEnter;
if (typeof settings.sendMessageOnEnter !== 'boolean') return defaultSendOnEnter;
return settings.sendMessageOnEnter;
}
getOnlyAnimateOnHover() {
getOnlyAnimateOnHover(): boolean {
if (typeof this.onlyAnimateOnHover === 'boolean') return this.onlyAnimateOnHover;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.onlyAnimateOnHover === 'undefined') return true;
if (typeof settings.onlyAnimateOnHover !== 'boolean') return true;
return settings.onlyAnimateOnHover;
}
getIsPeopleDrawer() {
getIsPeopleDrawer(): boolean {
if (typeof this.isPeopleDrawer === 'boolean') return this.isPeopleDrawer;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.isPeopleDrawer === 'undefined') return true;
if (typeof settings.isPeopleDrawer !== 'boolean') return true;
return settings.isPeopleDrawer;
}
get showNotifications() {
get showNotifications(): boolean {
if (window.Notification?.permission !== 'granted') return false;
return this._showNotifications;
}
getShowNotifications() {
getShowNotifications(): boolean {
if (typeof this._showNotifications === 'boolean') return this._showNotifications;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.showNotifications === 'undefined') return true;
if (typeof settings.showNotifications !== 'boolean') return true;
return settings.showNotifications;
}
getIsNotificationSounds() {
getIsNotificationSounds(): boolean {
if (typeof this.isNotificationSounds === 'boolean') return this.isNotificationSounds;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.isNotificationSounds === 'undefined') return true;
if (typeof settings.isNotificationSounds !== 'boolean') return true;
return settings.isNotificationSounds;
}
@ -216,12 +188,12 @@ class Settings extends EventEmitter {
this.emit(cons.events.settings.SHOW_ROOM_LIST_AVATAR_TOGGLED, this.showRoomListAvatar);
}
getShowRoomListAvatar() {
getShowRoomListAvatar(): boolean {
if (typeof this.showRoomListAvatar === 'boolean') return this.showRoomListAvatar;
const settings = getSettings();
if (settings === null) return false;
if (typeof settings.showRoomListAvatar === 'undefined') return false;
if (typeof settings.showRoomListAvatar !== 'boolean') return false;
return settings.showRoomListAvatar;
}
@ -232,12 +204,12 @@ class Settings extends EventEmitter {
this.emit(cons.events.settings.SHOW_YOUTUBE_EMBED_PLAYER_TOGGLED, this.showYoutubeEmbedPlayer);
}
getShowYoutubeEmbedPlayer() {
getShowYoutubeEmbedPlayer(): boolean {
if (typeof this.showYoutubeEmbedPlayer === 'boolean') return this.showYoutubeEmbedPlayer;
const settings = getSettings();
if (settings === null) return false;
if (typeof settings.showYoutubeEmbedPlayer === 'undefined') return false;
if (typeof settings.showYoutubeEmbedPlayer !== 'boolean') return false;
return settings.showYoutubeEmbedPlayer;
}
@ -248,30 +220,30 @@ class Settings extends EventEmitter {
this.emit(cons.events.settings.SHOW_URL_PREVIEW_TOGGLED, this.showUrlPreview);
}
getShowUrlPreview() {
getShowUrlPreview(): boolean {
if (typeof this.showUrlPreview === 'boolean') return this.showUrlPreview;
const settings = getSettings();
if (settings === null) return false;
if (typeof settings.showUrlPreview === 'undefined') return false;
if (typeof settings.showUrlPreview !== 'boolean') return false;
return settings.showUrlPreview;
}
getSendReadReceipts() {
getSendReadReceipts(): boolean {
if (typeof this.sendReadReceipts === 'boolean') return this.sendReadReceipts;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.sendReadReceipts === 'undefined') return true;
if (typeof settings.sendReadReceipts !== 'boolean') return true;
return settings.sendReadReceipts;
}
getClearUrls() {
getClearUrls(): boolean {
if (typeof this.clearUrls === 'boolean') return this.clearUrls;
const settings = getSettings();
if (settings === null) return true;
if (typeof settings.clearUrls === 'undefined') return true;
if (typeof settings.clearUrls !== 'boolean') return true;
return settings.clearUrls;
}

153
src/client/state/themes.ts Normal file
View file

@ -0,0 +1,153 @@
interface ThemeStyle {
'bg-surface': string;
}
const DEFAULT_THEME_STYLE: ThemeStyle = {
'bg-surface': '#000000',
};
const DEFAULT_DARK_THEME_ID = 'ayu';
const DEFAULT_LIGHT_THEME_ID = 'light';
const DEFAULT_THEME_ID_TO_NAME = {
ayu: 'Ayu',
classic: 'Classic',
butter: 'Butter',
light: 'Light',
silver: 'Silver',
// custom: 'Custom',
} as const;
interface ThemeSettingsOpts {
customThemeName: string | null;
customTheme: Partial<ThemeStyle>;
themeId: string;
useSystemTheme: boolean;
}
export class ThemeSettings {
themeIdToName: Map<string, string>;
customThemeStyle: ThemeStyle;
themeId: string;
useSystemTheme: boolean;
constructor(opts: ThemeSettingsOpts) {
this.themeIdToName = new Map(Object.entries(DEFAULT_THEME_ID_TO_NAME));
// if (opts.customThemeName) this.themeIdToName.set('custom', opts.customThemeName);
this.customThemeStyle = {
...DEFAULT_THEME_STYLE,
...opts.customTheme,
};
this.themeId = opts.themeId;
this.useSystemTheme = opts.useSystemTheme;
// detect when the system theme changes and apply
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
mediaQuery.addEventListener('change', () => {
this.applyTheme();
});
}
static fromSettings(settings: Record<string, unknown>): ThemeSettings {
const customThemeName =
typeof settings.customThemeName === 'string' ? settings.customThemeName : null;
const customTheme = settings.customTheme || {};
const themeId = typeof settings.themeId === 'string' ? settings.themeId : DEFAULT_DARK_THEME_ID;
const useSystemTheme =
typeof settings.useSystemTheme === 'boolean' ? settings.useSystemTheme : true;
return new ThemeSettings({
customThemeName,
customTheme,
themeId,
useSystemTheme,
});
}
toSettings(): Record<string, unknown> {
return {
customThemeName: this.getCustomThemeName(),
customTheme: this.getCustomThemeStyle(),
themeId: this.getThemeId(),
useSystemTheme: this.getUseSystemTheme(),
};
}
getThemeName(themeId: string): string | null {
return this.themeIdToName.get(themeId) || null;
}
getCustomThemeName(): string | null {
return this.themeIdToName.get('custom') || null;
}
setCustomThemeName(themeName: string): void {
this.themeIdToName.set('custom', themeName);
}
/**
* Get the theme ID that was selected by the user in settings. Note that if useSystemThee is
* enabled then this might not be the theme that's actually being used.
*/
getThemeId(): string {
return this.themeId;
}
/**
* Get the actual theme ID that is going to be used. This may not be the same as `getThemeId`
* because this takes the system theme into account.
* @returns The theme ID that is actually being used.
*/
getActualThemeId(): string {
if (this.useSystemTheme) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
return mediaQuery.matches ? DEFAULT_LIGHT_THEME_ID : DEFAULT_DARK_THEME_ID;
}
return this.themeId;
}
setThemeId(themeId: string): void {
this.themeId = themeId;
}
getCustomThemeStyle(): ThemeStyle {
return this.customThemeStyle;
}
setCustomThemeStyle(themeStyle: Partial<ThemeStyle>): void {
this.customThemeStyle = {
...this.customThemeStyle,
...themeStyle,
};
}
getUseSystemTheme(): boolean {
return this.useSystemTheme;
}
setUseSystemTheme(useSystemTheme: boolean): void {
this.useSystemTheme = useSystemTheme;
}
_clearTheme() {
this.themeIdToName.forEach((_, themeId) => {
if (themeId === '') return;
document.body.classList.remove(`${themeId}-theme`);
});
}
applyTheme() {
this._clearTheme();
const themeId = this.getActualThemeId();
document.body.classList.add(`${themeId}-theme`);
}
}

View file

@ -309,7 +309,7 @@
--ic-surface-low: rgba(255, 251, 222, 64%);
}
.dark-theme,
.classic-theme,
.butter-theme {
@include dark-mode();
}

View file

@ -7,8 +7,9 @@ import settings from './client/state/settings';
import App from './app/pages/App';
settings.applyTheme();
settings.getThemeSettings().applyTheme();
const container = document.getElementById('root');
if (!container) throw new Error('Root element not found');
const root = createRoot(container);
root.render(<App />);

View file

@ -2,13 +2,14 @@
"compilerOptions": {
"sourceMap": true,
"jsx": "react",
"target": "ES6",
"target": "ESNext",
"allowJs": true,
"esModuleInterop": true,
"moduleResolution": "Node",
"outDir": "dist",
"skipLibCheck": true,
"module": "ESNext"
"module": "ESNext",
"strict": true
},
"exclude": ["node_modules", "dist"],
"include": ["src"]