unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at angular21 374 lines 13 kB view raw
1import { effect, Injectable, signal, WritableSignal, inject } from '@angular/core' 2import { LoginService } from './login.service' 3import { HttpClient } from '@angular/common/http' 4import { debounceTime, filter, firstValueFrom, fromEvent, merge } from 'rxjs' 5import { EnvironmentService } from './environment.service' 6import { SettingListItem, SettingsService } from './settings.service' 7 8// !! NOTE FOR ADDING THEMES !! // 9// 10// If you want to add a theme, you must: 11// - Add it to `themeVariants` 12// - Fill out its `themeData` (name data and if the theme forces light/dark) entry 13// - Compatibility allows you to force dark/light if you need. 14// - Auto Reset makes the theme be reset to default on reload 15// - Add the theme as a CSS file in `/assets/themes/name.css` 16// - Add a link file to it in theme-manager.component.html 17// - Add your theme to a group in `themeGroupList` 18 19// !! NOTE FOR ADDING MODES !! // 20// 21// If you want to add a style mode, you must: 22// - Add it to `additionalStyleModeVariants` 23// - Fill out its `additionalStyleModesData` 24// - Add a link file to it in theme-manager.component.html 25 26const themeVariants = [ 27 'default', 28 'tan', 29 'green', 30 'gold', 31 'red', 32 'pink', 33 'purple', 34 'blue', 35 'rizzler', 36 'contrastWater', 37 'wafrn98', 38 'aqua', 39 'unwafrn', 40 'wafrnverse', 41 'dracula', 42 'fan', 43 'waffler', 44 'catppuccin_frappe', 45 'catppuccin_latte', 46 'catppuccin_macchiato', 47 'catppuccin_mocha', 48 'cohfrn' 49] as const 50type ThemeTuple = typeof themeVariants 51export type Theme = ThemeTuple[number] 52 53type ThemeData = { 54 [key in Theme]: { 55 name: string 56 compatibility: 'light' | 'dark' | 'both' 57 autoReset?: boolean 58 } 59} 60 61export const themeData: ThemeData = { 62 default: { name: 'Default', compatibility: 'both' }, 63 tan: { name: 'Tan', compatibility: 'both' }, 64 green: { name: 'Green', compatibility: 'both' }, 65 gold: { name: 'Gold', compatibility: 'both' }, 66 red: { name: 'Red', compatibility: 'both' }, 67 pink: { name: 'Pink', compatibility: 'both' }, 68 purple: { name: 'Purple', compatibility: 'both' }, 69 blue: { name: 'Blue', compatibility: 'both' }, 70 rizzler: { name: 'Rizzler', compatibility: 'both', autoReset: true }, 71 contrastWater: { name: 'Contrast Water', compatibility: 'both', autoReset: true }, 72 wafrn98: { name: 'Wafrn98', compatibility: 'dark' }, 73 aqua: { name: 'Aqua', compatibility: 'light' }, 74 unwafrn: { name: 'Unwafrn', compatibility: 'dark' }, 75 wafrnverse: { name: 'Wafrnverse', compatibility: 'both' }, 76 dracula: { name: 'Dracula', compatibility: 'both' }, 77 fan: { name: 'Fan', compatibility: 'both' }, 78 waffler: { name: 'Waffler', compatibility: 'both' }, 79 cohfrn: { name: 'Cohfrn', compatibility: 'both' }, 80 catppuccin_frappe: { name: 'Catppuccin Frappe', compatibility: 'both' }, 81 catppuccin_latte: { name: 'Catppuccin Latte', compatibility: 'both' }, 82 catppuccin_macchiato: { name: 'Catppuccin Macchiato', compatibility: 'both' }, 83 catppuccin_mocha: { name: 'Catppuccin Mocha', compatibility: 'both' } 84} 85 86const themeGroupVariants = ['defaultThemes', 'computeryThemes', 'experimentalThemes', 'programmersThemes'] as const 87type ThemeGroupTuple = typeof themeGroupVariants 88export type ThemeGroup = ThemeGroupTuple[number] 89export type ThemeGroupList = { 90 [key in ThemeGroup]: { 91 name: string 92 entries: Theme[] 93 } 94} 95 96export const themeGroupList: ThemeGroupList = { 97 defaultThemes: { 98 name: 'Default theme variants', 99 entries: ['default', 'tan', 'green', 'gold', 'red', 'pink', 'purple', 'blue'] 100 }, 101 computeryThemes: { 102 name: 'Computery themes', 103 entries: ['unwafrn', 'wafrnverse', 'wafrn98', 'aqua', 'fan', 'cohfrn', 'waffler'] 104 }, 105 experimentalThemes: { 106 name: 'Experimental themes', 107 entries: ['rizzler', 'contrastWater'] 108 }, 109 programmersThemes: { 110 name: "Programmer's Favourites", 111 entries: ['dracula', 'catppuccin_latte', 'catppuccin_frappe', 'catppuccin_macchiato', 'catppuccin_mocha'] 112 } 113} 114 115const lightDarkModeVariants = ['light', 'dark', 'auto'] as const 116type lightDarkModeTuple = typeof lightDarkModeVariants 117export type LightDarkMode = lightDarkModeTuple[number] 118 119export type LightDarkModeData = { [key in LightDarkMode]: string } 120export const lightDarkModeData: LightDarkModeData = { 121 light: 'Light', 122 dark: 'Dark', 123 auto: 'Auto' 124} 125 126// Verifying that a theme/scheme is real 127function isLightDarkMode(value: string | undefined): value is LightDarkMode { 128 return value !== undefined && lightDarkModeVariants.includes(value as LightDarkMode) 129} 130 131function isTheme(value: string | undefined): value is Theme { 132 return value !== undefined && themeVariants.includes(value as Theme) 133} 134 135// Covers SettingListItem[] because type jank 136function isAdditionalStyleMode(value: string[] | SettingListItem[]): value is AdditionalStyleMode[] { 137 return !value.some((mode) => !additionalStyleModeVariants.includes(mode as AdditionalStyleMode)) 138} 139 140// More styles! 141const additionalStyleModeVariants = [ 142 'centerLayout', 143 'topToolbar', 144 'horizontalMenu', 145 'lowContrastSidebar', 146 'oldTags', 147 'colorfulTags', 148 'solidCards' 149] as const 150type AdditionalStyleModeTuple = typeof additionalStyleModeVariants 151export type AdditionalStyleMode = AdditionalStyleModeTuple[number] 152 153type AdditionalStyleModeData = { 154 [key in AdditionalStyleMode]: { 155 name: string 156 } 157} 158 159export const additionalStyleModesData: AdditionalStyleModeData = { 160 centerLayout: { name: 'Center Layout' }, 161 topToolbar: { name: 'Top Toolbar' }, 162 horizontalMenu: { name: 'Horizontal Menu' }, 163 lowContrastSidebar: { name: 'Low Contrast Sidebar' }, 164 oldTags: { name: 'Old Tags' }, 165 colorfulTags: { name: 'Colorful Tags' }, 166 solidCards: { name: 'Solid style cards' } 167} 168 169@Injectable({ 170 providedIn: 'root' 171}) 172export class ThemeService { 173 private loginService = inject(LoginService); 174 private http = inject(HttpClient); 175 private settingService = inject(SettingsService); 176 177 public theme = signal<Theme>('default') 178 public lightDarkMode = signal<LightDarkMode>('auto') 179 public additionalStyleModes: { [key in AdditionalStyleMode]: WritableSignal<boolean> } = { 180 centerLayout: signal(false), 181 topToolbar: signal(false), 182 horizontalMenu: signal(false), 183 lowContrastSidebar: signal(false), 184 oldTags: signal(false), 185 colorfulTags: signal(false), 186 solidCards: signal(false) 187 } 188 public customCSS = signal<string>('') // Empty string is own theme, otherwise is the theme of the viewed blog 189 public customCSSEnabled = signal(true) // Allows pages to disable it and re-enable on snappy hide 190 191 constructor() { 192 const loginService = this.loginService; 193 194 // Setup when logging out and completing setting sync 195 // Also watches change from other tabs 196 merge( 197 loginService.loggedIn, 198 fromEvent(window, 'storage').pipe( 199 filter((event) => ['theme', 'colorScheme'].includes((<StorageEvent>event).key ?? '')), 200 debounceTime(200) 201 ) 202 ).subscribe(() => this.setup()) 203 204 // Also run once initially 205 this.setup() 206 207 // Load and sync user custom CSS 208 this.syncCustomCSS() 209 effect(() => { 210 this.syncCustomCSS() 211 }) 212 } 213 214 setup() { 215 const theme = this.settingService.values().theme 216 if (typeof theme === 'string' && isTheme(theme)) { 217 this.setTheme(theme) 218 } 219 220 const darkLightMode = this.settingService.values().lightDarkMode 221 if (typeof darkLightMode === 'string' && isLightDarkMode(darkLightMode)) { 222 this.setLightDarkMode(darkLightMode) 223 } 224 225 const settingAdditionalStyleModes = this.settingService.values().additionalStyleModes 226 if (Array.isArray(settingAdditionalStyleModes) && isAdditionalStyleMode(settingAdditionalStyleModes)) { 227 additionalStyleModeVariants.forEach((mode) => { 228 const enabled = settingAdditionalStyleModes.includes(mode) 229 this.additionalStyleModes[mode].set(enabled) 230 }) 231 } 232 233 // Fan theme fallback for old browsers 234 const chromeVersionForCompatibilityReasons = this.getChromeVersion() 235 if (chromeVersionForCompatibilityReasons && chromeVersionForCompatibilityReasons < 122) { 236 // we force the fan theme on old browsers 237 this.setTheme('fan', true) 238 } 239 } 240 241 public setTheme = async (theme: Theme, doNotSavePreference = false) => { 242 this.theme.set(theme) 243 this.settingService.values().theme = theme 244 this.settingService.values.update((v) => v) 245 246 // Forced lightDarkMode 247 if (themeData[theme]?.compatibility === 'light') await this.setLightDarkMode('light') 248 if (themeData[theme]?.compatibility === 'dark') await this.setLightDarkMode('dark') 249 250 this.settingService.forceUpdateValue('theme', !doNotSavePreference) 251 } 252 253 public setLightDarkMode = async (lightDarkMode: LightDarkMode, doNotSavePreference = false) => { 254 this.lightDarkMode.set(lightDarkMode) 255 this.settingService.values().lightDarkMode = lightDarkMode 256 this.settingService.values.update((v) => v) 257 258 document.documentElement.setAttribute('data-theme', lightDarkMode) 259 this.settingService.forceUpdateValue('lightDarkMode', !doNotSavePreference) 260 } 261 262 // When setting additionalStyleMode either call the set method here or modify and call sync afterwards if modifying many settings at once. 263 public setAdditionalStyleMode = async (mode: AdditionalStyleMode, value: boolean, doNotSavePreference = false) => { 264 this.additionalStyleModes[mode].set(value) 265 this.syncAdditionalStyleModeSettings() 266 267 this.settingService.forceUpdateValue('additionalStyleModes', !doNotSavePreference) 268 } 269 270 public syncAdditionalStyleMode() { 271 this.syncAdditionalStyleModeSettings() 272 273 this.settingService.forceUpdateValue('additionalStyleModes') 274 } 275 276 // Helper to sync up the settings service values and theme service values 277 private syncAdditionalStyleModeSettings() { 278 const modes = Object.entries(this.additionalStyleModes) 279 .filter(([_, enabled]) => enabled()) 280 .map(([val, _]) => val) 281 282 this.settingService.values().additionalStyleModes = modes 283 this.settingService.values.update((v) => v) 284 } 285 286 public async toggleAdditionalStyleMode(mode: AdditionalStyleMode, doNotSavePreference = false) { 287 this.setAdditionalStyleMode(mode, !this.additionalStyleModes[mode](), doNotSavePreference) 288 } 289 290 getChromeVersion() { 291 var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./) 292 293 return raw ? parseInt(raw[2], 10) : false 294 } 295 296 // 297 // CUSTOM CSS STUFF 298 // 299 300 async syncCustomCSS() { 301 const isOwnCSS = this.customCSS() === '' 302 if (isOwnCSS && this.loginService.loggedIn.value) { 303 this.customCSSLinkElement().href = this.getThemeUrl(this.loginService.getLoggedUserUUID()) 304 return 305 } 306 307 // Someone else's CSS, check if we want to use it and if it exists 308 if (this.settingService.values().useOtherUserCustomThemes !== true) return 309 310 const themeExists = await this.themeExists(this.customCSS()) 311 if (!themeExists) return 312 313 // We want to use it and it exists 314 this.customCSSLinkElement().href = this.getThemeUrl(this.customCSS()) 315 } 316 317 // Get or create custom CSS link element 318 private customCSSLinkElement(): HTMLLinkElement { 319 // If it exists, return it 320 const existingElement = document.getElementById(this.linkElementID()) 321 if (existingElement) return <HTMLLinkElement>existingElement 322 323 // Otherwise, create it and return it 324 const linkEl = document.createElement('link') 325 linkEl.setAttribute('rel', 'stylesheet') 326 linkEl.id = this.linkElementID() 327 document.head.appendChild(linkEl) 328 return linkEl 329 } 330 331 // arbitrary ID to give the theme link element 332 // If it collides with some random extension element just add more text 333 private linkElementID(): string { 334 return 'app-custom-css-link' 335 } 336 337 // Shorthand for the theme location in the media URL 338 private getThemeUrl(theme: string): string { 339 return `${EnvironmentService.environment.baseUrl}/uploads/themes/${theme}.css` 340 } 341 342 async themeExists(theme: string): Promise<boolean> { 343 const res = await firstValueFrom( 344 this.http.get(`${EnvironmentService.environment.baseUrl}/uploads/themes/${theme}.css`, { 345 responseType: 'text' 346 }) 347 ) 348 return res !== undefined && res.length > 0 349 } 350 351 // CSS editor stuff 352 353 updateTheme(newTheme: string) { 354 return firstValueFrom(this.http.post(`${EnvironmentService.environment.baseUrl}/updateCSS`, { css: newTheme })) 355 } 356 357 async getMyThemeAsSting(): Promise<string> { 358 let res = '' 359 try { 360 const themeResponse = await firstValueFrom( 361 this.http.get( 362 `${EnvironmentService.environment.baseUrl}/uploads/themes/${this.loginService.getLoggedUserUUID()}.css`, 363 { 364 responseType: 'text' 365 } 366 ) 367 ) 368 if (themeResponse && themeResponse.length > 0) { 369 res = themeResponse 370 } 371 } catch (error) {} 372 return res 373 } 374}