unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
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}