forked from extern/fediwall
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:
parent
7d003382ce
commit
de8d4f834e
@ -2,8 +2,25 @@
|
||||
"servers": ["mastodon.social"],
|
||||
"tags": ["foss", "cats", "dogs"],
|
||||
"accounts": [],
|
||||
|
||||
"loadPublic": false,
|
||||
"loadFederated": false,
|
||||
"loadTrends": false,
|
||||
|
||||
"languages": [],
|
||||
"badWords": [],
|
||||
"hideSensitive": true,
|
||||
"hideBots": true,
|
||||
"hideReplies": true,
|
||||
"hideBoosts": false,
|
||||
|
||||
"limit": 20,
|
||||
"interval": 10,
|
||||
"theme": "light",
|
||||
"info": "top"
|
||||
|
||||
"title": "Fediwall",
|
||||
"theme": "auto",
|
||||
"showInfobar": true,
|
||||
"showText": true,
|
||||
"showMedia": true,
|
||||
"playVideos": true,
|
||||
}
|
38
src/App.vue
38
src/App.vue
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onBeforeUnmount, onMounted, onUpdated, ref, watch } from '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 { loadConfig, type Config } from './config';
|
||||
import InfoBar from './components/InfoBar.vue';
|
||||
@ -36,8 +36,15 @@ const windowSize = useWindowSize()
|
||||
watchDebounced(windowSize.width, () => { fixLayout() }, { debounce: 500, maxWait: 1000 })
|
||||
|
||||
// Watch for a theme changes
|
||||
watch(() => config.value?.theme, () => {
|
||||
document.body!.parentElement!.dataset.bsTheme = config.value?.theme || "light"
|
||||
const isDartPrefered = usePreferredDark()
|
||||
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
|
||||
@ -210,7 +217,9 @@ const fetchSource = async (source: SourceConfig) => {
|
||||
if (!cfg) return []
|
||||
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}`)
|
||||
posts.push(...items)
|
||||
}
|
||||
@ -242,6 +251,21 @@ async function fetchAllPosts() {
|
||||
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
|
||||
const tasks = groupedSources.value.map(source => fetchSource(source));
|
||||
const results = await Promise.allSettled(tasks);
|
||||
@ -360,7 +384,7 @@ const hideAuthor = (url: string) => {
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (!config.value) return
|
||||
config.value.theme = config.value.theme === "dark" ? "light" : "dark"
|
||||
config.value.theme = actualTheme.value === "dark" ? "light" : "dark"
|
||||
}
|
||||
|
||||
const aboutLink = computed(() => {
|
||||
@ -380,7 +404,7 @@ const privacyLink = computed(() => {
|
||||
<template>
|
||||
<div id="page">
|
||||
<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">
|
||||
<small class="text-secondary secret float-end">
|
||||
[<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" />
|
||||
|
||||
<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>
|
||||
<button class="btn btn-link text-muted" data-bs-toggle="modal" data-bs-target="#configModal">[Customize]</button>
|
||||
<div>
|
||||
|
@ -1,10 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { sanatizeConfig } from '@/config';
|
||||
import { isServer } from '@/config';
|
||||
import { toQuery, type Config } from '@/config';
|
||||
import { sanatizeConfig, isServer, isLanguage, toQuery, type Config } from '@/config';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
|
||||
import { arrayUnique } from '@/utils';
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const modalDom = ref(null)
|
||||
@ -23,10 +21,15 @@ const formServers = computed({
|
||||
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({
|
||||
get: () => config.value.tags.map(t => "#" + t).join(" "),
|
||||
set: (value) => config.value.tags = [...(value || "").matchAll(tagPattern)].map(m => m[0]),
|
||||
get: () => config.value.tags.map(t => '#' + t).join(" "),
|
||||
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;
|
||||
@ -35,6 +38,12 @@ const formAccounts = computed({
|
||||
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({
|
||||
get: () => config.value.limit.toString(),
|
||||
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)),
|
||||
});
|
||||
|
||||
const formInfo = computed({
|
||||
get: () => config.value.info,
|
||||
set: (value) => config.value.info = value === "top" ? "top" : "hide",
|
||||
const formMediaText = computed({
|
||||
get: () => config.value.showText,
|
||||
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 url = new URL(location.href.toString());
|
||||
@ -68,82 +118,257 @@ const onSubmit = () => {
|
||||
<div class="modal" tabindex="-1" ref="modalDom">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Configure Wall</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</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">
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" id="settings-form">
|
||||
|
||||
<div class="mb-3 row">
|
||||
<label for="edit-server" class="col-sm-2 col-form-label">Server</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="edit-server" v-model.lazy="formServers">
|
||||
<div class="form-text">Mastodon (or compatible) server domains.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="ctab-content" aria-labelledby="btab-content" role="tabpanel">
|
||||
|
||||
<div class="mb-3 row">
|
||||
<label for="edit-tags" class="col-sm-2 col-form-label">Hashtags</label>
|
||||
<div class="col-sm-10">
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
<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 class="mb-3 row">
|
||||
<label for="edit-accounts" class="col-sm-2 col-form-label">Accounts</label>
|
||||
<div class="col-sm-10">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="edit-tags" class="form-label">Hashtags:</label>
|
||||
<div class="ms-5">
|
||||
<input type="text" class="form-control" id="edit-tags" v-model.lazy="formTags">
|
||||
<div class="form-text">Show public posts matching these hashtags.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-1 row">
|
||||
<label for="edit-theme" class="col-sm-2 col-form-label">Theme</label>
|
||||
<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">
|
||||
<label for="edit-accounts" class="form-label">Accounts:</label>
|
||||
<div class="ms-5">
|
||||
<input type="text" class="form-control" id="edit-accounts" v-model.lazy="formAccounts">
|
||||
<div class="form-text">Show all public posts or boosts from these profiles.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<div class="col-sm-2 col-form-label hidden"></div>
|
||||
<div class="col-sm-10">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="edit-info" true-value="top" false-value="hide" v-model="formInfo">
|
||||
<label class="form-check-label" for="edit-info">
|
||||
Show info line at the top
|
||||
</label>
|
||||
<div class="mb-3">
|
||||
<h6>Public server timelines:</h6>
|
||||
|
||||
<div class="ms-5">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="edit-trends" v-model="config.loadTrends">
|
||||
<label class="form-check-label" for="edit-trends">
|
||||
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>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<label for="limit" class="col-sm-2 col-form-label">Limit</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="limit" v-model.lazy="formLimit">
|
||||
<div class="form-text">Fetch this many posts per hashtag or account.</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit-server" class="form-label">Filter bad words:</label>
|
||||
<div class="ms-5">
|
||||
<input type="text" class="form-control" id="edit-server" v-model.lazy="formBadWords">
|
||||
<div class="form-text">Hide posts containing certain words or hashtags. Only exact matches are
|
||||
filtered.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="edit-server" class="form-label">Filter by language:</label>
|
||||
<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">
|
||||
<div class="form-text">Update interval in seconds.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<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 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>
|
||||
|
||||
<div class="mb-3 row">
|
||||
<label for="interval" class="col-sm-2 col-form-label">Interval</label>
|
||||
<div class="col-sm-10">
|
||||
<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
|
||||
fast may clash with server rate limits.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary float-end">Apply</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
<style scoped>
|
||||
.form-label,
|
||||
h6 {
|
||||
font-weight: bolder;
|
||||
}</style>
|
||||
|
||||
|
323
src/config.ts
323
src/config.ts
@ -1,40 +1,209 @@
|
||||
|
||||
import { arrayEquals, deepClone, isString } from "./utils";
|
||||
import { arrayUnique, deepClone, isString } from "./utils";
|
||||
import { fallbackConfig, siteConfigUrl } from "@/defaults";
|
||||
|
||||
export type Config = {
|
||||
servers: Array<string>,
|
||||
tags: 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,
|
||||
interval: number,
|
||||
|
||||
title: 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 infoLineModes = ["top", "hide"] as const;
|
||||
const themes = ["dark", "light", "auto"];
|
||||
const boolYes = ["", "y", "yes", "true"];
|
||||
const boolNo = ["n", "no", "false"];
|
||||
|
||||
const choice = <T>(choices: readonly T[], value?: T, fallback?: T): T => {
|
||||
return choices.includes(value) ? value : fallback;
|
||||
const fromBool = (value: string): boolean | undefined => {
|
||||
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 {
|
||||
const params = new URLSearchParams(query);
|
||||
const config: Partial<Config> = {}
|
||||
|
||||
// Keep URLs backwards compatible
|
||||
if (params.has("server"))
|
||||
params.set("servers", params.get("server"))
|
||||
// Parse URL parameters very roughly
|
||||
config.servers = params.get("servers")?.split(",")
|
||||
config.tags = params.get("tags")?.split(",")
|
||||
config.accounts = params.get("accounts")?.split(",")
|
||||
config.limit = parseInt(params.get("limit") || "0")
|
||||
config.interval = parseInt(params.get("interval") || "0")
|
||||
config.theme = params.get("theme")
|
||||
config.info = params.get("info")
|
||||
if (params.has("server")) {
|
||||
params.set("servers", params.get("server")!)
|
||||
params.delete("server")
|
||||
}
|
||||
if (params.get("info") === "top")
|
||||
params.set("info", boolYes[0])
|
||||
if (params.get("info") === "hide")
|
||||
params.set("info", boolNo[0])
|
||||
|
||||
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
|
||||
return sanatizeConfig(config);
|
||||
}
|
||||
@ -42,27 +211,28 @@ export function fromQuery(query: string): Config {
|
||||
export function toQuery(config: Config): string {
|
||||
const params = new URLSearchParams();
|
||||
const defaults = siteConfig || fallbackConfig;
|
||||
if (!arrayEquals(config.servers, defaults.servers))
|
||||
params.set("servers", config.servers.join(","))
|
||||
if (!arrayEquals(config.tags, defaults.tags))
|
||||
params.set("tags", config.tags.join(","))
|
||||
if (!arrayEquals(config.accounts, defaults.accounts))
|
||||
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, '@')
|
||||
|
||||
for (const { names, to } of parameterDefinitions) {
|
||||
const value = to(config)
|
||||
if (value !== to(defaults))
|
||||
params.set(names[0], value)
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
.replace(/%2C/g, ',')
|
||||
.replace(/%40/g, '@')
|
||||
.replace(/=(&|$)/g, '') // a=&b= -> a&b
|
||||
}
|
||||
|
||||
export function isTag(tag: string) {
|
||||
export function isTag(tag: any) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
export function isLanguage(lang: string) {
|
||||
return isString(lang) && lang.match(/^[a-z]{2}$/i)
|
||||
}
|
||||
|
||||
export function sanatizeConfig(config: any): Config {
|
||||
|
||||
// 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 boolOr = (value: any, fallback: boolean) => {
|
||||
if (typeof value == "boolean") return value;
|
||||
return fallback
|
||||
}
|
||||
|
||||
const defaults = siteConfig ? siteConfig : fallbackConfig;
|
||||
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> = {}
|
||||
|
||||
result.servers = Array.isArray(config.servers) ? [...config.servers].filter(isServer).sort() : [...defaults.servers];
|
||||
result.tags = Array.isArray(config.tags) ? [...config.tags].filter(isTag).sort() : [...defaults.tags]
|
||||
result.accounts = Array.isArray(config.accounts) ? [...config.accounts].filter(isAccount).sort() : [...defaults.accounts]
|
||||
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.theme = choice(themes, config.theme, defaults.theme)
|
||||
result.info = choice(infoLineModes, config.info, defaults.info)
|
||||
result.servers = arrayUnique((Array.isArray(config.servers) ? [...config.servers] : [...fallback.servers]).filter(isServer));
|
||||
result.tags = arrayUnique((Array.isArray(config.tags) ? [...config.tags] : [...fallback.tags]).map(stripTag).filter(isTag).sort() as string[]);
|
||||
result.accounts = arrayUnique((Array.isArray(config.accounts) ? [...config.accounts] : [...fallback.accounts]).filter(isAccount).sort());
|
||||
|
||||
result.loadFederated = boolOr(config.loadFederated, fallback.loadFederated)
|
||||
result.loadPublic = boolOr(config.loadPublic, fallback.loadPublic)
|
||||
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;
|
||||
}
|
||||
|
||||
export async function loadConfig() {
|
||||
if (siteConfig === null && siteConfigUrl) {
|
||||
try {
|
||||
siteConfig = sanatizeConfig(await (await fetch(siteConfigUrl)).json() || {})
|
||||
} catch (e) {
|
||||
console.warn("Site config failed to load, falling back to hard-coded defaults!")
|
||||
}
|
||||
async function loadSideConfig() {
|
||||
var config;
|
||||
|
||||
try {
|
||||
config = await (await fetch(siteConfigUrl)).json() || {};
|
||||
} catch (e) {
|
||||
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))
|
||||
|
||||
if (window.location.search)
|
||||
return fromQuery(window.location.search)
|
||||
|
||||
return deepClone({ ... (siteConfig || fallbackConfig) })
|
||||
return deepClone({ ...siteConfig })
|
||||
}
|
@ -7,10 +7,27 @@ export const fallbackConfig: Config = {
|
||||
servers: ["mastodon.social"],
|
||||
tags: ["foss", "cats", "dogs"],
|
||||
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,
|
||||
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.
|
||||
|
@ -9,6 +9,10 @@ export function isString(test: any) {
|
||||
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) {
|
||||
if(window.structuredClone)
|
||||
return window.structuredClone(obj)
|
||||
|
Loading…
Reference in New Issue
Block a user