mirror of
https://github.com/defnull/fediwall.git
synced 2024-11-25 00:53:15 +01:00
Better error handling and visual clue on errors
This commit is contained in:
parent
70a5034874
commit
4d77c6cf8a
68
src/App.vue
68
src/App.vue
@ -10,6 +10,7 @@ import { fetchPosts } from '@/sources'
|
||||
import Card from './components/Card.vue';
|
||||
import ConfigModal from './components/ConfigModal.vue';
|
||||
import InfoBar from './components/InfoBar.vue';
|
||||
import { arrayUnique } from './utils';
|
||||
|
||||
const config = ref<Config>();
|
||||
|
||||
@ -18,6 +19,9 @@ const pinned = ref<Array<string>>([])
|
||||
const hidden = ref<Array<string>>([])
|
||||
const updateInProgress = ref(false)
|
||||
|
||||
const statusText = ref<string | undefined>("Initializing ...")
|
||||
const statusIsError = ref(false)
|
||||
|
||||
var updateIntervalHandle: number;
|
||||
var lastUpdate = 0;
|
||||
|
||||
@ -127,10 +131,19 @@ async function updateWall() {
|
||||
updateInProgress.value = true
|
||||
|
||||
try {
|
||||
allPosts.value = await fetchPosts(cfg)
|
||||
allPosts.value = await fetchPosts(cfg, progress => {
|
||||
if (progress.errors.length) {
|
||||
setStatus(progress.errors.slice(-1)[0].message, true)
|
||||
} else if (progress.finished < progress.total) {
|
||||
setStatus(`Updating [${progress.finished}/${progress.total}] ...`)
|
||||
} else {
|
||||
setStatus(false)
|
||||
}
|
||||
})
|
||||
|
||||
console.debug("Update completed")
|
||||
} catch (e) {
|
||||
console.warn("Update failed", e)
|
||||
setStatus(`Update failed: ${e}`)
|
||||
} finally {
|
||||
lastUpdate = Date.now()
|
||||
updateInProgress.value = false;
|
||||
@ -138,18 +151,29 @@ async function updateWall() {
|
||||
|
||||
}
|
||||
|
||||
|
||||
function setStatus(text: string | false, isError?: boolean) {
|
||||
if (text === false) {
|
||||
statusText.value = undefined
|
||||
statusIsError.value = false
|
||||
} else {
|
||||
statusText.value = text
|
||||
statusIsError.value = isError === true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and order posts based on real-time criteria (e.g. pinned or hidden posts).
|
||||
* Most of filtering already happened earlier.
|
||||
*/
|
||||
const filteredPosts = computed(() => {
|
||||
// Copy to make sure those are detected as a reactive dependencies
|
||||
var posts: Array<Post> = [... allPosts.value]
|
||||
var posts: Array<Post> = [...allPosts.value]
|
||||
const pinnedLocal = [...pinned.value]
|
||||
const hiddenLocal = [...hidden.value]
|
||||
|
||||
// Filter hidden posts, authors or domains
|
||||
posts = posts.filter((p) => !hiddenLocal.some(hide =>
|
||||
posts = posts.filter((p) => !hiddenLocal.some(hide =>
|
||||
p.id == hide || p.author?.profile.endsWith(hide)
|
||||
))
|
||||
|
||||
@ -186,7 +210,8 @@ const hideAuthor = (profile: string) => {
|
||||
|
||||
const hideDomain = (profile: string) => {
|
||||
var domain = profile.split("@").pop()
|
||||
toggle(hidden.value, "@" + domain)
|
||||
if (domain)
|
||||
toggle(hidden.value, "@" + domain)
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
@ -204,8 +229,7 @@ const privacyLink = computed(() => {
|
||||
|
||||
<template>
|
||||
<div id="page">
|
||||
<icon v-show="updateInProgress" icon="spinner" spin
|
||||
class="position-fixed bottom-0 start-0 m-1 opacity-25 text-muted" />
|
||||
|
||||
<header v-if="config?.showInfobar" class="secret-hover" style="cursor: context-menu" data-bs-toggle="modal"
|
||||
data-bs-target="#configModal" title="Click to edit wall settings">
|
||||
<span class="text-muted float-end secret">
|
||||
@ -214,11 +238,16 @@ const privacyLink = computed(() => {
|
||||
<InfoBar :config="config" />
|
||||
</header>
|
||||
|
||||
<aside id="status-row" class="position-absolute opacity-25">
|
||||
<Transition>
|
||||
<icon v-if="statusIsError" icon="triangle-exclamation" class="mx-1" :title="statusText" />
|
||||
<icon v-else-if="updateInProgress" icon="spinner" spin class="mx-1" />
|
||||
</Transition>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div v-if="config === undefined">Initializing ...</div>
|
||||
<div v-else-if="filteredPosts.length === 0 && updateInProgress">Loading first posts ...</div>
|
||||
<div v-else-if="filteredPosts.length === 0">Nothing there yet ...</div>
|
||||
<div v-else v-masonry transition-duration="1s" item-selector=".wall-item" percent-position="true" id="wall">
|
||||
<div v-if="config && filteredPosts.length > 0" v-masonry transition-duration="1s" item-selector=".wall-item"
|
||||
percent-position="true" id="wall">
|
||||
<Card v-masonry-tile class="wall-item secret-hover" v-for="post in filteredPosts" :key="post.id" :post="post"
|
||||
:config="config">
|
||||
|
||||
@ -228,7 +257,7 @@ const privacyLink = computed(() => {
|
||||
aria-expanded="false">...</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="#" @click.prevent="pin(post.id)">{{ post.pinned ? "Unpin" : "Pin"
|
||||
}}</a></li>
|
||||
}}</a></li>
|
||||
<li><a class="dropdown-item" href="#" @click.prevent="hide(post.id)">Hide Post</a></li>
|
||||
<li v-if="post.author?.profile"><a class="dropdown-item" href="#"
|
||||
@click.prevent="hideAuthor(post.author?.profile)">Hide
|
||||
@ -247,8 +276,11 @@ const privacyLink = computed(() => {
|
||||
<ConfigModal v-if="config" v-model="config" id="configModal" />
|
||||
|
||||
<footer>
|
||||
<aside class="opacity-50 text-center">
|
||||
Status: {{ statusText || "OK" }}
|
||||
</aside>
|
||||
<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>
|
||||
<div>
|
||||
<a href="https://github.com/defnull/fediwall" target="_blank" class="mx-1 text-muted">Fediwall <span
|
||||
@ -363,4 +395,14 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.v-enter-from,
|
||||
.v-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -27,8 +27,8 @@ import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||
// Register fontawesome icons
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faGear, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
library.add(faGear, faSpinner)
|
||||
import { faGear, faSpinner, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'
|
||||
library.add(faGear, faSpinner, faTriangleExclamation)
|
||||
|
||||
import App from '@/App.vue'
|
||||
const app = createApp(App)
|
||||
|
@ -5,22 +5,32 @@ import type { faTags } from "@fortawesome/free-solid-svg-icons";
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Fetch unique posts from all sources (curently only Mastodon is implemented)
|
||||
* Fetch unique posts from all sources (currently only Mastodon is implemented)
|
||||
*/
|
||||
export async function fetchPosts(cfg: Config): Promise<Post[]> {
|
||||
export type Progress = {
|
||||
total: number
|
||||
started: number
|
||||
finished: number
|
||||
errors: Error[]
|
||||
}
|
||||
|
||||
export async function fetchPosts(cfg: Config, onProgress: (progress: Progress) => void): Promise<Post[]> {
|
||||
|
||||
type Task = () => Promise<MastodonStatus[]>;
|
||||
let progress: Progress = {total: 0, started: 0, finished: 0, errors: []}
|
||||
|
||||
// Group tasks by domain (see below)
|
||||
const domainTasks: Record<string, Array<Task>> = {}
|
||||
const addTask = (domain: string, task: Task) => {
|
||||
progress.total += 1;
|
||||
(domainTasks[domain] ??= []).push(task)
|
||||
}
|
||||
|
||||
// Load tags from all servers
|
||||
for (const domain of cfg.servers) {
|
||||
const query: Record<string, any> = { limit: cfg.limit }
|
||||
if(cfg.badWords.length) query.none = cfg.badWords.join(",")
|
||||
if(!cfg.showText) query.only_media = "True"
|
||||
if (cfg.badWords.length) query.none = cfg.badWords.join(",")
|
||||
if (!cfg.showText) query.only_media = "True"
|
||||
for (const tag of cfg.tags) {
|
||||
addTask(domain, async () => {
|
||||
return await fetchJson(domain, `api/v1/timelines/tag/${encodeURIComponent(tag)}`, query)
|
||||
@ -93,14 +103,19 @@ export async function fetchPosts(cfg: Config): Promise<Post[]> {
|
||||
.map(([domain, tasks]) => {
|
||||
return async () => {
|
||||
for (const task of tasks) {
|
||||
progress.started += 1;
|
||||
try {
|
||||
(await task())
|
||||
.map(status => fixLocalAcct(domain, status))
|
||||
.filter(status => filterStatus(cfg, status))
|
||||
.map(status => statusToWallPost(cfg, status))
|
||||
.forEach(addOrRepacePost)
|
||||
} catch (err) {
|
||||
console.warn(`Update task failed for domain ${domain}`, err)
|
||||
} catch (err: any) {
|
||||
let error = err instanceof Error ? err : new Error(err?.toString())
|
||||
progress.errors.push(error)
|
||||
} finally {
|
||||
progress.finished += 1
|
||||
onProgress(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -145,34 +160,45 @@ async function fetchJson(domain: string, path: string, query?: Record<string, an
|
||||
const pairs = Object.entries(query).map(([key, value]) => [key, value.toString()])
|
||||
url += "?" + new URLSearchParams(pairs).toString()
|
||||
}
|
||||
let rs = await fetch(url)
|
||||
|
||||
// Auto-retry rate limit errors
|
||||
let errCount = 0
|
||||
while (!rs.ok) {
|
||||
if (errCount++ > 3)
|
||||
break // Do not retry anymore
|
||||
let rs: Response;
|
||||
let json: any;
|
||||
|
||||
if (rs.headers.get("X-RateLimit-Remaining") === "0") {
|
||||
const resetTime = new Date(rs.headers.get("X-RateLimit-Reset") || (new Date().getTime() + 10000)).getTime();
|
||||
const referenceTime = new Date(rs.headers.get("Date") || new Date()).getTime();
|
||||
const sleep = Math.max(0, resetTime - referenceTime) + 1000 // 1 second leeway
|
||||
await new Promise(resolve => setTimeout(resolve, sleep));
|
||||
} else {
|
||||
break // Do not retry
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Retry
|
||||
try {
|
||||
rs = await fetch(url)
|
||||
|
||||
// Auto-retry rate limit errors
|
||||
let errCount = 0
|
||||
while (!rs.ok) {
|
||||
if (errCount++ > 3)
|
||||
break // Do not retry anymore
|
||||
|
||||
if (rs.headers.get("X-RateLimit-Remaining") === "0") {
|
||||
const resetTime = new Date(rs.headers.get("X-RateLimit-Reset") || (new Date().getTime() + 10000)).getTime();
|
||||
const referenceTime = new Date(rs.headers.get("Date") || new Date()).getTime();
|
||||
const sleep = Math.max(0, resetTime - referenceTime) + 1000 // 1 second leeway
|
||||
await new Promise(resolve => setTimeout(resolve, sleep));
|
||||
} else {
|
||||
break // Do not retry
|
||||
}
|
||||
|
||||
// Retry
|
||||
rs = await fetch(url)
|
||||
}
|
||||
json = await rs.json()
|
||||
} catch (e: any) {
|
||||
throw new Error(`Failed to fetch ${url} (${e.message || e})`);
|
||||
}
|
||||
|
||||
const json = await rs.json()
|
||||
if (json.error) {
|
||||
console.warn(`Fetch error: ${rs.status} ${JSON.stringify(json)}`)
|
||||
const err = new Error(json.error);
|
||||
if (!rs.ok || json.error) {
|
||||
const err = new Error(`Failed to fetch ${url} (${rs.status}: ${json.error || "Unknown reason"})`);
|
||||
(err as any).status = rs.status;
|
||||
(err as any).body = json;
|
||||
throw err;
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
@ -218,7 +244,7 @@ const filterStatus = (cfg: Config, status: MastodonStatus) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a mastdon status object to a Post.
|
||||
* Convert a mastodon status object to a Post.
|
||||
*/
|
||||
const statusToWallPost = (cfg: Config, status: MastodonStatus): Post => {
|
||||
const date = new Date(status.created_at)
|
||||
|
Loading…
Reference in New Issue
Block a user