Config overhaul with tons of new options.

- Lots of new config options and feature switches.
- Changed the way query parameters are translated to config values and vice versa.
- New layout and smarter error correction for config UI.

WIP, still missing functionality for many of the new options.
This commit is contained in:
Marcel Hellkamp 2023-07-20 09:53:07 +02:00
parent 7d003382ce
commit de8d4f834e
6 changed files with 634 additions and 132 deletions

View File

@ -2,8 +2,25 @@
"servers": ["mastodon.social"], "servers": ["mastodon.social"],
"tags": ["foss", "cats", "dogs"], "tags": ["foss", "cats", "dogs"],
"accounts": [], "accounts": [],
"loadPublic": false,
"loadFederated": false,
"loadTrends": false,
"languages": [],
"badWords": [],
"hideSensitive": true,
"hideBots": true,
"hideReplies": true,
"hideBoosts": false,
"limit": 20, "limit": 20,
"interval": 10, "interval": 10,
"theme": "light",
"info": "top" "title": "Fediwall",
"theme": "auto",
"showInfobar": true,
"showText": true,
"showMedia": true,
"playVideos": true,
} }

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'; import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
import Card, { type Post } from './components/Card.vue'; import Card, { type Post } from './components/Card.vue';
import { useDocumentVisibility, useWindowSize, watchDebounced } from '@vueuse/core' import { useDocumentVisibility, usePreferredDark, useWindowSize, watchDebounced } from '@vueuse/core'
import ConfigModal from './components/ConfigModal.vue'; import ConfigModal from './components/ConfigModal.vue';
import { loadConfig, type Config } from './config'; import { loadConfig, type Config } from './config';
import InfoBar from './components/InfoBar.vue'; import InfoBar from './components/InfoBar.vue';
@ -36,8 +36,15 @@ const windowSize = useWindowSize()
watchDebounced(windowSize.width, () => { fixLayout() }, { debounce: 500, maxWait: 1000 }) watchDebounced(windowSize.width, () => { fixLayout() }, { debounce: 500, maxWait: 1000 })
// Watch for a theme changes // Watch for a theme changes
watch(() => config.value?.theme, () => { const isDartPrefered = usePreferredDark()
document.body!.parentElement!.dataset.bsTheme = config.value?.theme || "light" const actualTheme = computed(() => {
var theme = config.value?.theme
if(!theme || theme === "auto")
theme = isDartPrefered.value ? "dark" : "light"
return theme
})
watch(actualTheme, () => {
document.body!.parentElement!.dataset.bsTheme = actualTheme.value
}) })
// Watch for a update interval changes // Watch for a update interval changes
@ -210,7 +217,9 @@ const fetchSource = async (source: SourceConfig) => {
if (!cfg) return [] if (!cfg) return []
const posts = [] const posts = []
for (const tag of source.tags) { for (let tag of source.tags) {
if(tag.startsWith("!")) continue;
if(tag.startsWith("#")) tag = tag.substring(1)
const items = await fetchJson(`https://${source.domain}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${cfg.limit}`) const items = await fetchJson(`https://${source.domain}/api/v1/timelines/tag/${encodeURIComponent(tag)}?limit=${cfg.limit}`)
posts.push(...items) posts.push(...items)
} }
@ -242,6 +251,21 @@ async function fetchAllPosts() {
posts.unshift(post) posts.unshift(post)
} }
type Job = ()=>void;
const jobsPerServer: Record<string, Array<Job>> = {}
const addJob = (domain:string, job: Job) => {
(jobsPerServer[domain] ??= []).push(job)
}
for(const domain of cfg.servers) {
for(const tag of cfg.tags) {
if(tag.startsWith("!")) continue
if(!tag.startsWith){}
}
}
// Start all sources in parallel // Start all sources in parallel
const tasks = groupedSources.value.map(source => fetchSource(source)); const tasks = groupedSources.value.map(source => fetchSource(source));
const results = await Promise.allSettled(tasks); const results = await Promise.allSettled(tasks);
@ -360,7 +384,7 @@ const hideAuthor = (url: string) => {
const toggleTheme = () => { const toggleTheme = () => {
if (!config.value) return if (!config.value) return
config.value.theme = config.value.theme === "dark" ? "light" : "dark" config.value.theme = actualTheme.value === "dark" ? "light" : "dark"
} }
const aboutLink = computed(() => { const aboutLink = computed(() => {
@ -380,7 +404,7 @@ const privacyLink = computed(() => {
<template> <template>
<div id="page"> <div id="page">
<span v-show="updateInProgress" class="position-fixed bottom-0 start-0 m-1 opacity-25 text-muted"></span> <span v-show="updateInProgress" class="position-fixed bottom-0 start-0 m-1 opacity-25 text-muted"></span>
<header v-if="config?.info === 'top'"> <header v-if="config?.showInfobar">
<InfoBar :config="config" class="secret-hover"> <InfoBar :config="config" class="secret-hover">
<small class="text-secondary secret float-end"> <small class="text-secondary secret float-end">
[<a href="#" class="text-secondary" data-bs-toggle="modal" data-bs-target="#configModal">edit</a>] [<a href="#" class="text-secondary" data-bs-toggle="modal" data-bs-target="#configModal">edit</a>]
@ -415,7 +439,7 @@ const privacyLink = computed(() => {
<ConfigModal v-if="config" v-model="config" id="configModal" /> <ConfigModal v-if="config" v-model="config" id="configModal" />
<footer> <footer>
<button class="btn btn-link text-muted" @click="toggleTheme(); false">[{{ config?.theme == "dark" ? "Light" : "Dark" <button class="btn btn-link text-muted" @click="toggleTheme(); false">[{{ actualTheme == "dark" ? "Light" : "Dark"
}} mode]</button> }} mode]</button>
<button class="btn btn-link text-muted" data-bs-toggle="modal" data-bs-target="#configModal">[Customize]</button> <button class="btn btn-link text-muted" data-bs-toggle="modal" data-bs-target="#configModal">[Customize]</button>
<div> <div>

View File

@ -1,10 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { sanatizeConfig } from '@/config'; import { sanatizeConfig, isServer, isLanguage, toQuery, type Config } from '@/config';
import { isServer } from '@/config';
import { toQuery, type Config } from '@/config';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { arrayUnique } from '@/utils';
import { useClipboard } from '@vueuse/core'
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(['update:modelValue'])
const modalDom = ref(null) const modalDom = ref(null)
@ -23,10 +21,15 @@ const formServers = computed({
set: (value) => config.value.servers = (value || "").split(" ").filter(isServer), set: (value) => config.value.servers = (value || "").split(" ").filter(isServer),
}); });
const tagPattern = /\b([\p{Letter}\p{Number}\p{Mark}\p{Connector_Punctuation}_]+)\b/igu const tagPattern = /#?([\p{Letter}\p{Number}\p{Mark}\p{Connector_Punctuation}_]+)/igu
const formTags = computed({ const formTags = computed({
get: () => config.value.tags.map(t => "#" + t).join(" "), get: () => config.value.tags.map(t => '#' + t).join(" "),
set: (value) => config.value.tags = [...(value || "").matchAll(tagPattern)].map(m => m[0]), set: (value) => config.value.tags = arrayUnique([...(value || "").matchAll(tagPattern)].map(m => m[1])),
});
const formBadWords = computed({
get: () => config.value.badWords.join(" "),
set: (value) => config.value.badWords = arrayUnique([...(value || "").matchAll(tagPattern)].map(m => m[1])),
}); });
const accountPattern = /\b([a-z0-9_]+)(@([a-z0-9.-]+\.[a-z]{2,}))?\b/ig; const accountPattern = /\b([a-z0-9_]+)(@([a-z0-9.-]+\.[a-z]{2,}))?\b/ig;
@ -35,6 +38,12 @@ const formAccounts = computed({
set: (value) => config.value.accounts = [...(value || "").matchAll(accountPattern)].map(m => m[0]), set: (value) => config.value.accounts = [...(value || "").matchAll(accountPattern)].map(m => m[0]),
}); });
const langPattern = /\b([a-z]{2})\b/ig;
const formLang = computed({
get: () => config.value.languages.join(" "),
set: (value) => config.value.languages = [...(value || "").matchAll(langPattern)].map(m => m[0]).filter(isLanguage),
});
const formLimit = computed({ const formLimit = computed({
get: () => config.value.limit.toString(), get: () => config.value.limit.toString(),
set: (value) => config.value.limit = Math.min(100, Math.max(10, parseInt(value || "0") || 0)), set: (value) => config.value.limit = Math.min(100, Math.max(10, parseInt(value || "0") || 0)),
@ -45,11 +54,52 @@ const formInterval = computed({
set: (value) => config.value.interval = Math.min(600, Math.max(5, parseInt(value || "0") || 0)), set: (value) => config.value.interval = Math.min(600, Math.max(5, parseInt(value || "0") || 0)),
}); });
const formInfo = computed({ const formMediaText = computed({
get: () => config.value.info, get: () => config.value.showText,
set: (value) => config.value.info = value === "top" ? "top" : "hide", set: (value) => {
config.value.showText = value
if (!value)
config.value.showMedia = true
}
}); });
const formMediaMedia = computed({
get: () => config.value.showMedia,
set: (value) => {
config.value.showMedia = value
if (!value) {
config.value.showText = true
config.value.playVideos = false
}
}
});
const formAutoplay = computed({
get: () => config.value.playVideos,
set: (value) => {
config.value.playVideos = value
if (value)
config.value.showMedia = true
}
});
const sourceCount = computed(() => {
var c = 0
c += config.value.servers.length * config.value.tags.length
c += config.value.loadFederated ? config.value.servers.length : 0
c += config.value.loadPublic ? config.value.servers.length : 0
c += config.value.loadTrends ? config.value.servers.length : 0
c += config.value.accounts
.map(a => a.indexOf('@') > 0 ? 1 : config.value.servers.length)
.reduce((p, e) => p + e, 0)
return c;
})
const clip = useClipboard()
const fullConfig = computed(() => {
return JSON.stringify(config.value)
})
const fullUrl = computed(() => { const fullUrl = computed(() => {
const url = new URL(location.href.toString()); const url = new URL(location.href.toString());
@ -68,82 +118,257 @@ const onSubmit = () => {
<div class="modal" tabindex="-1" ref="modalDom"> <div class="modal" tabindex="-1" ref="modalDom">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Configure Wall</h5> <h5 class="modal-title">Configure Wall</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<ul class="nav nav-tabs nav-fill mt-3" id="cfg-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" id="btab-content" data-bs-target="#ctab-content"
type="button" role="tab" aria-controls="ctab-content" aria-selected="true">Sources</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" id="btab-filter" data-bs-target="#ctab-filter" type="button"
role="tab" aria-controls="ctab-filter" aria-selected="false">Filter</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" id="btab-appearance" data-bs-target="#ctab-appearance"
type="button" role="tab" aria-controls="ctab-appearance" aria-selected="false">Apperance</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" id="btab-advanced" data-bs-target="#ctab-advanced" type="button"
role="tab" aria-controls="ctab-advanced" aria-selected="false">Advanced</button>
</li>
</ul>
<div class="modal-body"> <div class="modal-body">
<form @submit.prevent="onSubmit"> <form @submit.prevent="onSubmit" id="settings-form">
<div class="mb-3 row"> <div class="tab-content">
<label for="edit-server" class="col-sm-2 col-form-label">Server</label> <div class="tab-pane active" id="ctab-content" aria-labelledby="btab-content" role="tabpanel">
<div class="col-sm-10">
<div class="mb-3">
<label for="edit-server" class="form-label">Servers:</label>
<div class="ms-5">
<input type="text" class="form-control" id="edit-server" v-model.lazy="formServers"> <input type="text" class="form-control" id="edit-server" v-model.lazy="formServers">
<div class="form-text">Mastodon (or compatible) server domains.</div> <div class="form-text"><span title="Mastodon or compatible"
style="border-bottom: 1px dotted; cursor: help;">Mastodon</span> server domains to query for
content.</div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3">
<label for="edit-tags" class="col-sm-2 col-form-label">Hashtags</label> <label for="edit-tags" class="form-label">Hashtags:</label>
<div class="col-sm-10"> <div class="ms-5">
<input type="text" class="form-control" id="edit-tags" v-model.lazy="formTags"> <input type="text" class="form-control" id="edit-tags" v-model.lazy="formTags">
<div class="form-text">Hashtags to follow on each server.</div> <div class="form-text">Show public posts matching these hashtags.
</div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3">
<label for="edit-accounts" class="col-sm-2 col-form-label">Accounts</label> <label for="edit-accounts" class="form-label">Accounts:</label>
<div class="col-sm-10"> <div class="ms-5">
<input type="text" class="form-control" id="edit-accounts" v-model.lazy="formAccounts"> <input type="text" class="form-control" id="edit-accounts" v-model.lazy="formAccounts">
<div class="form-text">Accounts to follow. Only public posts and boosts will be shown (no replies).</div> <div class="form-text">Show all public posts or boosts from these profiles.
</div>
</div> </div>
</div> </div>
<div class="mb-1 row"> <div class="mb-3">
<label for="edit-theme" class="col-sm-2 col-form-label">Theme</label> <h6>Public server timelines:</h6>
<div class="col-sm-10">
<select class="form-select" id="edit-theme" v-model="config.theme">
<option value="dark">dark</option>
<option value="light">light</option>
</select>
</div>
</div>
<div class="mb-3 row"> <div class="ms-5">
<div class="col-sm-2 col-form-label hidden"></div>
<div class="col-sm-10">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-info" true-value="top" false-value="hide" v-model="formInfo"> <input class="form-check-input" type="checkbox" id="edit-trends" v-model="config.loadTrends">
<label class="form-check-label" for="edit-info"> <label class="form-check-label" for="edit-trends">
Show info line at the top Show all trending posts
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-local" v-model="config.loadPublic">
<label class="form-check-label" for="edit-local">
Show all local posts
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-federated" v-model="config.loadFederated">
<label class="form-check-label" for="edit-federated">
Show all federated posts
</label>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="ctab-filter" aria-labelledby="btab-filter" role="tabpanel">
<div class="mb-3">
<h6>Filter unwanted posts:</h6>
<div class="ms-5">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-nsfw" v-model="config.hideSensitive">
<label class="form-check-label" for="edit-nsfw">
Hide sensitive posts (e.g. content warnings)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-bots" v-model="config.hideBots">
<label class="form-check-label" for="edit-bots">
Hide automated posts or bot accounts
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-replies" v-model="config.hideReplies">
<label class="form-check-label" for="edit-replies">
Hide replies
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-boosts" v-model="config.hideBoosts">
<label class="form-check-label" for="edit-boosts">
Hide boosts
</label> </label>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3">
<label for="limit" class="col-sm-2 col-form-label">Limit</label> <label for="edit-server" class="form-label">Filter bad words:</label>
<div class="col-sm-10"> <div class="ms-5">
<input type="text" class="form-control" name="limit" v-model.lazy="formLimit"> <input type="text" class="form-control" id="edit-server" v-model.lazy="formBadWords">
<div class="form-text">Fetch this many posts per hashtag or account.</div> <div class="form-text">Hide posts containing certain words or hashtags. Only exact matches are
filtered.</div>
</div> </div>
</div> </div>
<div class="mb-3 row"> <div class="mb-3">
<label for="interval" class="col-sm-2 col-form-label">Interval</label> <label for="edit-server" class="form-label">Filter by language:</label>
<div class="col-sm-10"> <div class="ms-5">
<input type="text" class="form-control" id="edit-server" placeholder="all languages"
v-model.lazy="formLang">
<div class="form-text">List of <a href="https://en.wikipedia.org/wiki/ISO_639-1"
tyrget="_blanlk">two-letter language codes</a> to allow. Leave blank to allow all languages.</div>
</div>
</div>
</div>
<div class="tab-pane" id="ctab-appearance" aria-labelledby="btab-appearance" role="tabpanel">
<div class="mb-3">
<label for="edit-title" class="form-label">Wall title:</label>
<div class="ms-5">
<input type="text" class="form-control" id="edit-title" v-model.lazy="config.title">
</div>
</div>
<div class="mb-3">
<label for="edit-theme" class="form-label">Theme:</label>
<div class="ms-5">
<select class="form-select mb-1" id="edit-theme" v-model="config.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="auto">Auto</option>
</select>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="edit-info" v-model="config.showInfobar">
<label class="form-check-label" for="edit-info">
Show info bar at the top
</label>
</div>
</div>
</div>
<div class="mb-3">
<h6>Post content:</h6>
<div class="ms-5">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-text" v-model="formMediaText">
<label class="form-check-label" for="edit-text">
Show text content
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit-media" v-model="formMediaMedia">
<label class="form-check-label" for="edit-media">
Show embedded media
</label>
</div>
<div class="form-check ms-3">
<input class="form-check-input" type="checkbox" id="edit-autoplay" v-model="formAutoplay">
<label class="form-check-label" for="edit-autoplay">
Autoplay videos (muted)
</label>
</div>
</div>
</div>
</div>
<div class="tab-pane" id="ctab-advanced" aria-labelledby="btab-advanced" role="tabpanel">
<div class="mb-3">
<label for="interval" class="form-label">Interval</label>
<div class="ms-5">
<input type="text" class="form-control" name="interval" v-model.lazy="formInterval"> <input type="text" class="form-control" name="interval" v-model.lazy="formInterval">
<div class="form-text">Update interval in seconds. Note that checking too many hashtags or accounts too <div class="form-text">Update interval in seconds.</div>
fast may clash with server rate limits.</div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary float-end">Apply</button> <div class="mb-3">
</form> <label for="limit" class="form-label">Limit results per source</label>
<div class="ms-5">
<input type="text" class="form-control" name="limit" v-model.lazy="formLimit">
<div class="form-text">Fetch this many new posts per request.</div>
</div> </div>
</div> </div>
<div class="mb-3">
<h6>Save config</h6>
<div class="ms-5">
<div class="input-group">
<input type="text" class="form-control" readonly :value="fullUrl">
<button v-if="clip.isSupported" class="btn btn-outline-secondary" type="button"
@click.prevent="clip.copy(fullUrl)">Copy</button>
</div>
<div class="form-text">Bookmark or share this <a :href="fullUrl" target="_blank">Fediwall link</a>.</div>
<div class="mt-3 input-group">
<input type="text" class="form-control" readonly :value="fullConfig">
<button v-if="clip.isSupported" class="btn btn-outline-secondary" type="button"
@click.prevent="clip.copy(fullUrl)">Copy</button>
</div>
<div class="form-text">Only useful if you plan to self-host Fediwall on your own domain.</div>
</div>
</div>
</div>
</div>
</form>
<div v-if="sourceCount / config.interval > 1" class="alert alert-warning mt-5" role="alert">
Checking {{ sourceCount }} sources every {{ formInterval }} seconds requires a high number
of API requests per second. Please reduce the number of servers, hashtags or accounts, or increase the update
interval.
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary float-end" form="settings-form">Apply</button>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<style></style> <style scoped>
.form-label,
h6 {
font-weight: bolder;
}</style>

View File

@ -1,40 +1,209 @@
import { arrayEquals, deepClone, isString } from "./utils"; import { arrayUnique, deepClone, isString } from "./utils";
import { fallbackConfig, siteConfigUrl } from "@/defaults"; import { fallbackConfig, siteConfigUrl } from "@/defaults";
export type Config = { export type Config = {
servers: Array<string>, servers: Array<string>,
tags: Array<string>, tags: Array<string>,
accounts: Array<string>, accounts: Array<string>,
loadPublic: boolean,
loadFederated: boolean,
loadTrends: boolean,
languages: Array<string>,
badWords: Array<string>,
hideSensitive: boolean,
hideBoosts: boolean,
hideReplies: boolean,
hideBots: boolean,
limit: number, limit: number,
interval: number, interval: number,
title: string,
theme: string, theme: string,
info: string, showInfobar: boolean,
showText: boolean,
showMedia: boolean,
playVideos: boolean,
} }
var siteConfig: Config = null; var siteConfig: Config | null = null;
const themes = ["dark", "light"] as const; const themes = ["dark", "light", "auto"];
const infoLineModes = ["top", "hide"] as const; const boolYes = ["", "y", "yes", "true"];
const boolNo = ["n", "no", "false"];
const choice = <T>(choices: readonly T[], value?: T, fallback?: T): T => { const fromBool = (value: string): boolean | undefined => {
return choices.includes(value) ? value : fallback; return boolYes.includes(value.toLowerCase())
}
const toBool = (value: boolean | undefined) => {
return value ? boolYes[0] : boolNo[0]
}
/**
* Parameter definition used to translate between url parameters and the
* internal Config struct.
*/
type ParamDef = {
// Parameter names and their aliases. Must be globally unique.
names: string[]
// Function to apply this parameter to an incomplete Config.
from: (config: Partial<Config>, value: string) => any,
// Function to get the parameter value from a populated Config.
to: (config: Config) => string,
}
/**
* All supported query parameters.
*/
const parameterDefinitions: Array<ParamDef> = [
// Content
{
names: ["servers", "server", "s"],
from: (config: Partial<Config>, value: string) => config.servers = value.split(","),
to: (config: Config) => (config.servers || []).join(","),
},
{
names: ["tags", "t"],
from: (config: Partial<Config>, value: string) => config.tags = value.split(","),
to: (config: Config) => (config.tags || []).join(","),
},
{
names: ["accounts", "a"],
from: (config: Partial<Config>, value: string) => config.accounts = value?.split(","),
to: (config: Config) => (config.accounts || []).join(","),
},
{
names: ["timelines", "tl"],
from: (config: Partial<Config>, value: string) => {
const flags = value.split(",")
config.loadPublic = flags.includes("local")
config.loadFederated = flags.includes("remote")
config.loadTrends = flags.includes("trends")
},
to: (config: Config) => {
const flags: string[] = []
if (config.loadPublic) flags.push("local")
if (config.loadFederated) flags.push("remote")
if (config.loadTrends) flags.push("trends")
return flags.join(",")
},
},
// Filter options
{
names: ["no"],
from: (config: Partial<Config>, value: string) => {
const flags = value.split(",")
config.hideSensitive = flags.includes("nsfw")
config.hideReplies = flags.includes("replies")
config.hideBoosts = flags.includes("boosts")
config.hideBots = flags.includes("bots")
},
to: (config: Config) => {
const flags: string[] = []
if (config.hideSensitive) flags.push("nsfw")
if (config.hideReplies) flags.push("replies")
if (config.hideBoosts) flags.push("boosts")
if (config.hideBots) flags.push("bots")
return flags.join(",")
},
},
{
names: ["lang", "l"],
from: (config: Partial<Config>, value: string) => config.languages = value.split(","),
to: (config: Config) => (config.languages || []).join(","),
},
{
names: ["block"],
from: (config: Partial<Config>, value: string) => config.badWords = value.split(","),
to: (config: Config) => (config.badWords || []).join(","),
},
{
names: ["limit"],
from: (config: Partial<Config>, value: string) => config.limit = parseInt(value),
to: (config: Config) => config.limit.toString(),
},
{
names: ["interval"],
from: (config: Partial<Config>, value: string) => config.interval = parseInt(value),
to: (config: Config) => config.interval.toString(),
},
{
names: ["title"],
from: (config: Partial<Config>, value: string) => config.title = value.trim(),
to: (config: Config) => config.title,
},
{
names: ["theme"],
from: (config: Partial<Config>, value: string) => config.theme = value.trim(),
to: (config: Config) => config.theme,
},
{
names: ["info"],
from: (config: Partial<Config>, value: string) => config.showInfobar = fromBool(value),
to: (config: Config) => toBool(config.showInfobar),
},
{
// yes = text+media
// no = text
// notext = media
names: ["media"],
from: (config: Partial<Config>, value: string) => {
config.showText = ["yes", "no"].includes(value.trim().toLocaleLowerCase())
config.showMedia = ["yes", "notext"].includes(value.trim().toLocaleLowerCase())
},
to: (config: Config) => {
if (!config.showText)
return "notext"
if (!config.showMedia)
return "no"
return "yes"
},
},
{
names: ["autoplay", "play"],
from: (config: Partial<Config>, value: string) => config.playVideos = fromBool(value),
to: (config: Config) => toBool(config.playVideos),
}]
if (import.meta.env.DEV) {
parameterDefinitions.flatMap(p => p.names).filter((v, i, a) => {
if (a.indexOf(v) !== i)
throw new Error(`Parameter names not unique! ${v}`);
});
} }
export function fromQuery(query: string): Config { export function fromQuery(query: string): Config {
const params = new URLSearchParams(query); const params = new URLSearchParams(query);
const config: Partial<Config> = {} const config: Partial<Config> = {}
// Keep URLs backwards compatible // Keep URLs backwards compatible
if (params.has("server")) if (params.has("server")) {
params.set("servers", params.get("server")) params.set("servers", params.get("server")!)
// Parse URL parameters very roughly params.delete("server")
config.servers = params.get("servers")?.split(",") }
config.tags = params.get("tags")?.split(",") if (params.get("info") === "top")
config.accounts = params.get("accounts")?.split(",") params.set("info", boolYes[0])
config.limit = parseInt(params.get("limit") || "0") if (params.get("info") === "hide")
config.interval = parseInt(params.get("interval") || "0") params.set("info", boolNo[0])
config.theme = params.get("theme")
config.info = params.get("info") for (const { names, from } of parameterDefinitions) {
const param = names.find(n => params.has(n))
if (param === undefined) continue
from(config, params.get(param) as string)
}
// Clean, fix and return a valid config // Clean, fix and return a valid config
return sanatizeConfig(config); return sanatizeConfig(config);
} }
@ -42,27 +211,28 @@ export function fromQuery(query: string): Config {
export function toQuery(config: Config): string { export function toQuery(config: Config): string {
const params = new URLSearchParams(); const params = new URLSearchParams();
const defaults = siteConfig || fallbackConfig; const defaults = siteConfig || fallbackConfig;
if (!arrayEquals(config.servers, defaults.servers))
params.set("servers", config.servers.join(",")) for (const { names, to } of parameterDefinitions) {
if (!arrayEquals(config.tags, defaults.tags)) const value = to(config)
params.set("tags", config.tags.join(",")) if (value !== to(defaults))
if (!arrayEquals(config.accounts, defaults.accounts)) params.set(names[0], value)
params.set("accounts", config.accounts.join(","))
if (config.limit !== defaults.limit)
params.set("limit", config.limit.toString())
if (config.interval !== defaults.interval)
params.set("interval", config.interval.toString())
if (config.theme !== defaults.theme)
params.set("theme", config.theme)
if (config.info !== defaults.info)
params.set("info", config.info)
return params.toString().replace(/%2C/g, ',').replace(/%40/g, '@')
} }
export function isTag(tag: string) { return params.toString()
.replace(/%2C/g, ',')
.replace(/%40/g, '@')
.replace(/=(&|$)/g, '') // a=&b= -> a&b
}
export function isTag(tag: any) {
return isString(tag) && tag.match(/^[\p{Letter}\p{Number}\p{Mark}\p{Connector_Punctuation}_]+$/iu) return isString(tag) && tag.match(/^[\p{Letter}\p{Number}\p{Mark}\p{Connector_Punctuation}_]+$/iu)
} }
export function stripTag(tag: string) {
const m = tag.match(/[\p{Letter}\p{Number}\p{Mark}\p{Connector_Punctuation}_]+/iu)
return m ? m[0] : null;
}
export function isAccount(acc: string) { export function isAccount(acc: string) {
return isString(acc) && acc.match(/^([a-z0-9_]+)(@([a-z0-9.-]+\.[a-z]{2,}))?$/i) return isString(acc) && acc.match(/^([a-z0-9_]+)(@([a-z0-9.-]+\.[a-z]{2,}))?$/i)
} }
@ -71,42 +241,87 @@ export function isServer(server: string) {
return isString(server) && server.match(/^([a-z0-9.-]+\.[a-z]{2,})$/i) return isString(server) && server.match(/^([a-z0-9.-]+\.[a-z]{2,})$/i)
} }
export function sanatizeConfig(config: any): Config { export function isLanguage(lang: string) {
return isString(lang) && lang.match(/^[a-z]{2}$/i)
// Migrate old configuration within same minor release
if (isString(config.server) && !config.servers) {
console.warn("DEPRECATED: Config parameter 'server' is now an array and called 'servers'.")
config.servers = [config.server]
} }
const defaults = siteConfig ? siteConfig : fallbackConfig; export function sanatizeConfig(config: any): Config {
const boolOr = (value: any, fallback: boolean) => {
if (typeof value == "boolean") return value;
return fallback
}
const choice = <T, U>(choices: readonly T[], value: T, fallback: U): T | U => {
return choices.includes(value) ? value : fallback;
}
const fallback = siteConfig || fallbackConfig;
const result: Partial<Config> = {} const result: Partial<Config> = {}
result.servers = Array.isArray(config.servers) ? [...config.servers].filter(isServer).sort() : [...defaults.servers]; result.servers = arrayUnique((Array.isArray(config.servers) ? [...config.servers] : [...fallback.servers]).filter(isServer));
result.tags = Array.isArray(config.tags) ? [...config.tags].filter(isTag).sort() : [...defaults.tags] result.tags = arrayUnique((Array.isArray(config.tags) ? [...config.tags] : [...fallback.tags]).map(stripTag).filter(isTag).sort() as string[]);
result.accounts = Array.isArray(config.accounts) ? [...config.accounts].filter(isAccount).sort() : [...defaults.accounts] result.accounts = arrayUnique((Array.isArray(config.accounts) ? [...config.accounts] : [...fallback.accounts]).filter(isAccount).sort());
result.limit = Math.max(1, Math.min(100, config?.limit || defaults.limit))
result.interval = Math.max(1, Math.min(600, config?.interval || defaults.interval)) result.loadFederated = boolOr(config.loadFederated, fallback.loadFederated)
result.theme = choice(themes, config.theme, defaults.theme) result.loadPublic = boolOr(config.loadPublic, fallback.loadPublic)
result.info = choice(infoLineModes, config.info, defaults.info) result.loadTrends = boolOr(config.loadTrends, fallback.loadTrends)
result.languages = arrayUnique((Array.isArray(config.languages) ? [...config.languages] : [...fallback.languages]).filter(isLanguage));
result.badWords = arrayUnique((Array.isArray(config.badWords) ? [...config.badWords] : [...fallback.badWords]).filter(isTag));
result.hideSensitive = boolOr(config.hideNsfw, fallback.hideSensitive)
result.hideBoosts = boolOr(config.hideBoosts, fallback.hideBoosts)
result.hideBots = boolOr(config.hideBots, fallback.hideBots)
result.hideReplies = boolOr(config.hideReplies, fallback.hideReplies)
result.limit = Math.max(1, Math.min(100, config?.limit || fallback.limit))
result.interval = Math.max(1, Math.min(600, config?.interval || fallback.interval))
result.title = config?.title || fallback.title
result.theme = choice(themes, config.theme, fallback.theme)
result.showInfobar = boolOr(config.showInfo, fallback.showInfobar)
result.showText = boolOr(config.showText, fallback.showText)
result.showMedia = boolOr(config.showMedia, fallback.showMedia)
result.playVideos = boolOr(config.playVideos, fallback.playVideos)
if (result.playVideos)
result.showMedia = true
if (!result.showMedia)
result.playVideos = false
if (!result.showMedia && !result.showText)
result.showText = true
return result as Config; return result as Config;
} }
export async function loadConfig() { async function loadSideConfig() {
if (siteConfig === null && siteConfigUrl) { var config;
try { try {
siteConfig = sanatizeConfig(await (await fetch(siteConfigUrl)).json() || {}) config = await (await fetch(siteConfigUrl)).json() || {};
} catch (e) { } catch (e) {
console.warn("Site config failed to load, falling back to hard-coded defaults!") console.warn("Site config failed to load, falling back to hard-coded defaults!")
} return;
} }
if (siteConfig === null) // Migrate old configuration within same minor release
if (isString(config.server)) {
console.warn("DEPRECATED: Config parameter 'server' is now an array and called 'servers'.");
(config.servers ??= []).push(config.server);
}
if (isString(config.info))
config.showinfo = config.info == "top"
return sanatizeConfig(config)
}
export async function loadConfig() {
if (!siteConfig && siteConfigUrl)
siteConfig = await loadSideConfig() || null
if (!siteConfig)
siteConfig = sanatizeConfig(deepClone(fallbackConfig)) siteConfig = sanatizeConfig(deepClone(fallbackConfig))
if (window.location.search) if (window.location.search)
return fromQuery(window.location.search) return fromQuery(window.location.search)
return deepClone({ ... (siteConfig || fallbackConfig) }) return deepClone({ ...siteConfig })
} }

View File

@ -7,10 +7,27 @@ export const fallbackConfig: Config = {
servers: ["mastodon.social"], servers: ["mastodon.social"],
tags: ["foss", "cats", "dogs"], tags: ["foss", "cats", "dogs"],
accounts: [], accounts: [],
loadPublic: false,
loadFederated: false,
loadTrends: false,
languages: [], // empty = do not filter based on language
badWords: [],
hideSensitive: true,
hideBots: true,
hideReplies: true,
hideBoosts: false,
limit: 20, limit: 20,
interval: 10, interval: 10,
theme: "light",
info: "top", title: "Fediwall",
theme: "auto",
showInfobar: true,
showText: true,
showMedia: true,
playVideos: true,
} }
// URL for a site-config file that overrides the default configuration above, if present. // URL for a site-config file that overrides the default configuration above, if present.

View File

@ -9,6 +9,10 @@ export function isString(test: any) {
return typeof test === 'string' || test instanceof String return typeof test === 'string' || test instanceof String
} }
export function arrayUnique<T>(array: T[]) {
return array.filter((v,i,a) => a.indexOf(v) === i)
}
export function deepClone(obj: any) { export function deepClone(obj: any) {
if(window.structuredClone) if(window.structuredClone)
return window.structuredClone(obj) return window.structuredClone(obj)