feeds-fun 1.27.1 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feeds-fun",
3
- "version": "1.27.1",
3
+ "version": "1.28.0",
4
4
  "author": "Aliaksei Yaletski (Tiendil) <a.eletsky@gmail.com> (https://tiendil.org/)",
5
5
  "description": "Frontend for the Feeds Fun — web-based news reader",
6
6
  "keywords": [
@@ -36,6 +36,7 @@
36
36
  "@tabler/icons-vue": "^3.31.0",
37
37
  "@vueuse/core": "^11.2.0",
38
38
  "axios": "^1.7.7",
39
+ "chart.js": "^4.5.1",
39
40
  "dompurify": "^3.1.7",
40
41
  "lodash": "^4.17.21",
41
42
  "neverthrow": "^8.2.0",
@@ -43,6 +44,7 @@
43
44
  "set-interval-async": "^3.0.3",
44
45
  "vanilla-cookieconsent": "^3.1.0",
45
46
  "vue": "^3.5.12",
47
+ "vue-chartjs": "^5.3.3",
46
48
  "vue-router": "^4.4.5"
47
49
  },
48
50
  "devDependencies": {
@@ -1,92 +1,231 @@
1
1
  <template>
2
2
  <div
3
- v-if="feed !== null"
4
- class="flex text-lg">
5
- <div class="ffun-body-list-icon-column">
6
- <a
7
- href="#"
8
- class="text-red-500 hover:text-red-600"
9
- title="Unsubscribe"
10
- @click.prevent="feedsStore.unsubscribe(feed.id)">
11
- <icon icon="x" />
12
- </a>
13
- </div>
14
-
15
- <body-list-reverse-time-column
16
- title="How long ago the feed was last checked for news"
17
- :time="feed.loadedAt" />
18
-
19
- <body-list-reverse-time-column
20
- title="How long ago the feed was added"
21
- :time="feed.linkedAt" />
22
-
23
- <div class="ffun-body-list-icon-column ml-3">
24
- <icon
25
- v-if="feed.isOk"
26
- title="everything is ok"
27
- icon="face-smile"
28
- class="text-green-700" />
29
- <icon
30
- v-else
31
- :title="feed.lastError || 'unknown error'"
32
- icon="face-sad"
33
- class="text-red-700" />
34
- </div>
35
-
36
- <body-list-favicon-column :url="feed.url" />
37
-
38
- <div class="flex-grow">
39
- <external-url
40
- class="ffun-normal-link"
41
- :url="feed.url"
42
- :text="purifiedTitle" />
43
-
44
- <template v-if="feed.collectionIds.length > 0">
45
- <span v-for="(collectionId, index) in feed.collectionIds">
46
- <template v-if="collectionId in collections.collections">
47
- <br />
48
- Collections:
49
- <span class="text-green-700 font-bold">{{ collections.collections[collectionId].name }}</span
50
- ><span v-if="index < feed.collectionIds.length - 1">, </span>
51
- </template>
3
+ ref="feedTop"
4
+ v-if="feed !== null">
5
+ <feed-list-columns class="text-lg">
6
+ <template #action>
7
+ <a
8
+ href="#"
9
+ class="text-red-500 hover:text-red-600"
10
+ title="Unsubscribe"
11
+ @click.prevent="feedsStore.unsubscribe(feed.id)">
12
+ <icon icon="x" />
13
+ </a>
14
+ </template>
15
+
16
+ <template #checked>
17
+ <span
18
+ :class="checkedClass"
19
+ :title="checkedTitle">
20
+ {{ checkedText }}
52
21
  </span>
53
22
  </template>
54
- </div>
23
+
24
+ <template #added>
25
+ <span title="Added: how long ago you added this feed">
26
+ <value-date-time
27
+ :value="feed.linkedAt"
28
+ :reversed="true" />
29
+ </span>
30
+ </template>
31
+
32
+ <template #rate>
33
+ <span
34
+ :class="entriesPerDayClass"
35
+ :title="entriesPerDayTitle">
36
+ {{ feed.entriesPerDay }}/day
37
+ </span>
38
+ </template>
39
+
40
+ <template #favicon>
41
+ <favicon-element
42
+ :url="feed.url"
43
+ class="w-5 h-5 mx-1 inline align-text-bottom" />
44
+ </template>
45
+
46
+ <template #main>
47
+ <div
48
+ class="flex min-w-0 cursor-pointer items-baseline gap-2"
49
+ @click="onTitleClick">
50
+ <span
51
+ class="flex-shrink-0 min-w-fit line-clamp-1 mb-0"
52
+ v-html="purifiedTitle" />
53
+
54
+ <span
55
+ v-if="purifiedDescriptionPreview"
56
+ class="min-w-0 flex-1 line-clamp-1 text-sm text-gray-600">
57
+ {{ purifiedDescriptionPreview }}
58
+ </span>
59
+ </div>
60
+
61
+ <div
62
+ v-if="feed.collectionIds.length > 0"
63
+ class="mt-1 flex min-w-0 flex-wrap gap-1">
64
+ <template
65
+ v-for="collectionId in feed.collectionIds"
66
+ :key="collectionId">
67
+ <span
68
+ v-if="collectionId in collections.collections"
69
+ :title="`This feed is part of the collection: ${collections.collections[collectionId].name}`"
70
+ class="cursor-default rounded border border-green-200 bg-green-50 px-1.5 py-0.5 text-sm font-semibold text-green-700">
71
+ {{ collections.collections[collectionId].name }}
72
+ </span>
73
+ </template>
74
+ </div>
75
+ </template>
76
+ </feed-list-columns>
55
77
  </div>
56
78
 
57
79
  <body-list-entry-body
58
- v-if="globalSettings.showFeedsDescriptions"
59
- class="ml-56"
60
- :url="null"
61
- :title="null"
62
- :loading="false"
63
- :references="[]"
64
- :text="purifiedDescription" />
80
+ v-if="showDescription"
81
+ class="justify-center"
82
+ :url="bodyTitleUrl"
83
+ :title="purifiedTitle"
84
+ :loading="feed.entriesLoadedDetails === null"
85
+ :references="feedReferences"
86
+ :text="purifiedDescription">
87
+ <template
88
+ v-if="feed.entriesLoadedDetails !== null"
89
+ #body-prefix>
90
+ <feed-entries-per-day-chart :entries-loaded-details="feed.entriesLoadedDetails" />
91
+ </template>
92
+ </body-list-entry-body>
65
93
  </template>
66
94
 
67
95
  <script lang="ts" setup>
68
- import {computed, ref} from "vue";
69
- import type * as t from "@/logic/types";
96
+ import {computed, useTemplateRef} from "vue";
70
97
  import * as e from "@/logic/enums";
71
- import * as api from "@/logic/api";
98
+ import * as t from "@/logic/types";
72
99
  import * as utils from "@/logic/utils";
73
- import {computedAsync} from "@vueuse/core";
74
- import DOMPurify from "dompurify";
75
- import {useGlobalSettingsStore} from "@/stores/globalSettings";
76
100
  import {useFeedsStore} from "@/stores/feeds";
77
101
  import {useCollectionsStore} from "@/stores/collections";
102
+ import FeedListColumns from "@/components/feed_list/Columns.vue";
103
+ import FeedEntriesPerDayChart from "@/components/feed_list/FeedEntriesPerDayChart.vue";
78
104
 
79
- const globalSettings = useGlobalSettingsStore();
80
105
  const feedsStore = useFeedsStore();
81
106
  const collections = useCollectionsStore();
107
+ const topElement = useTemplateRef("feedTop");
82
108
 
83
109
  const properties = defineProps<{feed: t.Feed}>();
84
110
 
111
+ const showDescription = computed(() => {
112
+ return properties.feed.id == feedsStore.displayedFeedId;
113
+ });
114
+
85
115
  const purifiedTitle = computed(() => {
86
116
  return utils.purifyTitle({raw: properties.feed.title, default_: properties.feed.url});
87
117
  });
88
118
 
119
+ const purifiedDescriptionPreview = computed(() => {
120
+ if (properties.feed.description === null) {
121
+ return "";
122
+ }
123
+
124
+ return utils.purifyTitle({raw: properties.feed.description, default_: ""});
125
+ });
126
+
89
127
  const purifiedDescription = computed(() => {
90
128
  return utils.purifyBody({raw: properties.feed.description, default_: "No description"});
91
129
  });
130
+
131
+ const bodyTitleUrl = computed(() => {
132
+ return properties.feed.siteUrl ?? properties.feed.url;
133
+ });
134
+
135
+ const feedReferences = computed(() => {
136
+ return [
137
+ new t.Reference({
138
+ kind: e.ReferenceKind.Page,
139
+ url: properties.feed.url,
140
+ title: "feed",
141
+ mimeType: null,
142
+ width: null,
143
+ height: null,
144
+ duration: null,
145
+ size: null,
146
+ extra: null
147
+ })
148
+ ];
149
+ });
150
+
151
+ const checkedText = computed(() => {
152
+ if (properties.feed.loadedAt === null) {
153
+ return "?";
154
+ }
155
+
156
+ return utils.timeSince(properties.feed.loadedAt);
157
+ });
158
+
159
+ const checkedClass = computed(() => {
160
+ if (properties.feed.loadedAt === null) {
161
+ return "text-yellow-700";
162
+ }
163
+
164
+ if (properties.feed.isOk) {
165
+ return "text-green-700";
166
+ }
167
+
168
+ return "text-red-700";
169
+ });
170
+
171
+ const checkedTitle = computed(() => {
172
+ if (properties.feed.loadedAt === null) {
173
+ const title = "Last load: unknown, the feed has no load timestamp.";
174
+
175
+ if (properties.feed.isOk) {
176
+ return `${title} Status: loaded, no recent feed errors.`;
177
+ }
178
+
179
+ return `${title} Status: feed is failing, ${properties.feed.lastError || "unknown error"}.`;
180
+ }
181
+
182
+ if (properties.feed.isOk) {
183
+ return "Last load: how long ago news was last loaded from the feed. Status: loaded, no recent feed errors.";
184
+ }
185
+
186
+ return `Last load: how long ago news was last loaded from the feed. Status: feed is failing, ${properties.feed.lastError || "unknown error"}.`;
187
+ });
188
+
189
+ const entriesPerDayTitle = computed(() => {
190
+ const title = "News/day: number of news loaded per day over the current statistics window.";
191
+
192
+ if (!properties.feed.young) {
193
+ return title;
194
+ }
195
+
196
+ return `${title} This feed is young, so statistics may be inaccurate.`;
197
+ });
198
+
199
+ const entriesPerDayClass = computed(() => {
200
+ return {
201
+ "text-yellow-700": properties.feed.young
202
+ };
203
+ });
204
+
205
+ function onTitleClick(event: MouseEvent) {
206
+ if (!event.ctrlKey) {
207
+ event.preventDefault();
208
+ event.stopPropagation();
209
+
210
+ if (showDescription.value) {
211
+ feedsStore.hideFeed({feedId: properties.feed.id});
212
+ } else {
213
+ feedsStore.displayFeed({feedId: properties.feed.id});
214
+
215
+ if (topElement.value) {
216
+ const rect = topElement.value.getBoundingClientRect();
217
+
218
+ const isVisible =
219
+ rect.top >= 0 &&
220
+ rect.left >= 0 &&
221
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
222
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth);
223
+
224
+ if (!isVisible) {
225
+ topElement.value.scrollIntoView({behavior: "instant"});
226
+ }
227
+ }
228
+ }
229
+ }
230
+ }
92
231
  </script>
@@ -1,4 +1,26 @@
1
1
  <template>
2
+ <feed-list-columns class="mb-1 border-b pb-1 text-xs text-gray-500">
3
+ <template #checked>
4
+ <span title="Last load: how long ago news was last loaded from the feed; color shows current feed status">
5
+ Last load
6
+ </span>
7
+ </template>
8
+
9
+ <template #added>
10
+ <span title="Subscribed: how long ago you added this feed">Subscribed</span>
11
+ </template>
12
+
13
+ <template #rate>
14
+ <span title="News/day: number of news loaded per day">News/day</span>
15
+ </template>
16
+
17
+ <template #favicon>
18
+ <span title="Site icon" />
19
+ </template>
20
+
21
+ <template #main>Feed</template>
22
+ </feed-list-columns>
23
+
2
24
  <ul>
3
25
  <li
4
26
  v-for="feed in feeds"
@@ -10,8 +32,8 @@
10
32
  </template>
11
33
 
12
34
  <script lang="ts" setup>
13
- import {computed, ref, onUnmounted, watch} from "vue";
14
35
  import type * as t from "@/logic/types";
36
+ import FeedListColumns from "@/components/feed_list/Columns.vue";
15
37
 
16
- const properties = defineProps<{feeds: Array<t.Feed>}>();
38
+ defineProps<{feeds: Array<t.Feed>}>();
17
39
  </script>
@@ -17,6 +17,8 @@
17
17
  v-if="references.length > 0"
18
18
  :references="references" />
19
19
 
20
+ <slot name="body-prefix" />
21
+
20
22
  <p
21
23
  v-if="loading"
22
24
  class="mt-4"
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <div class="flex">
3
+ <div class="flex-shrink-0 w-4 mr-2 cursor-default">
4
+ <slot name="action" />
5
+ </div>
6
+
7
+ <div class="flex-shrink-0 w-20 cursor-default whitespace-nowrap text-right pr-2">
8
+ <slot name="checked" />
9
+ </div>
10
+
11
+ <div class="flex-shrink-0 w-24 cursor-default whitespace-nowrap text-right pr-2">
12
+ <slot name="added" />
13
+ </div>
14
+
15
+ <div class="flex-shrink-0 w-24 cursor-default whitespace-nowrap text-right pr-2">
16
+ <slot name="rate" />
17
+ </div>
18
+
19
+ <div class="flex-shrink-0 w-8 cursor-default text-right pr-1">
20
+ <slot name="favicon" />
21
+ </div>
22
+
23
+ <div class="flex-grow min-w-0">
24
+ <slot name="main" />
25
+ </div>
26
+ </div>
27
+ </template>
@@ -0,0 +1,219 @@
1
+ <template>
2
+ <div class="w-full mb-1">
3
+ <div class="h-24">
4
+ <Bar
5
+ :data="chartData"
6
+ :options="chartOptions" />
7
+ </div>
8
+
9
+ <div class="mt-1 text-xs text-gray-500">{{ label }}</div>
10
+ </div>
11
+ </template>
12
+
13
+ <script lang="ts" setup>
14
+ import {computed} from "vue";
15
+ import {
16
+ BarElement,
17
+ CategoryScale,
18
+ Chart as ChartJS,
19
+ LinearScale,
20
+ Tooltip,
21
+ type ChartData,
22
+ type ChartOptions,
23
+ type TooltipItem
24
+ } from "chart.js";
25
+ import {Bar} from "vue-chartjs";
26
+
27
+ ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip);
28
+
29
+ const properties = defineProps<{
30
+ entriesLoadedDetails: number[];
31
+ }>();
32
+
33
+ type ChartSlot = {
34
+ value: number;
35
+ padded: boolean;
36
+ };
37
+
38
+ const chartDays = 30;
39
+
40
+ const chartSlots = computed<ChartSlot[]>(() => {
41
+ const details = properties.entriesLoadedDetails.slice(-chartDays);
42
+ const missingDays = chartDays - details.length;
43
+
44
+ const paddedSlots = Array.from({length: missingDays}, () => {
45
+ return {value: 0, padded: true};
46
+ });
47
+
48
+ const loadedSlots = details.map((value) => {
49
+ return {value, padded: false};
50
+ });
51
+
52
+ return [...paddedSlots, ...loadedSlots];
53
+ });
54
+
55
+ const zeroBarHeight = computed(() => {
56
+ const maxValue = Math.max(...chartSlots.value.map((slot) => slot.value));
57
+
58
+ return Math.max(maxValue * 0.04, 0.15);
59
+ });
60
+
61
+ const barValues = computed(() => {
62
+ return chartSlots.value.map((slot) => {
63
+ if (slot.value > 0) {
64
+ return slot.value;
65
+ }
66
+
67
+ return zeroBarHeight.value;
68
+ });
69
+ });
70
+
71
+ const barColors = computed(() => {
72
+ return chartSlots.value.map((slot) => {
73
+ if (slot.padded) {
74
+ return "#e5e7eb";
75
+ }
76
+
77
+ if (slot.value === 0) {
78
+ return "#bfdbfe";
79
+ }
80
+
81
+ return "#93c5fd";
82
+ });
83
+ });
84
+
85
+ const suggestedMax = computed(() => {
86
+ return Math.max(1, ...chartSlots.value.map((slot) => slot.value));
87
+ });
88
+
89
+ function dateForEntry(index: number, entriesCount: number): Date {
90
+ const date = new Date();
91
+
92
+ date.setHours(0, 0, 0, 0);
93
+ date.setDate(date.getDate() - (entriesCount - index - 1));
94
+
95
+ return date;
96
+ }
97
+
98
+ function formattedDate(date: Date): string {
99
+ return [date.getDate(), date.getMonth() + 1, date.getFullYear()]
100
+ .map((value) => value.toString().padStart(2, "0"))
101
+ .join("-");
102
+ }
103
+
104
+ function entriesLabel(count: number, date: Date): string {
105
+ const entriesNoun = count === 1 ? "entry" : "entries";
106
+
107
+ return `${count} news ${entriesNoun} loaded on ${formattedDate(date)}`;
108
+ }
109
+
110
+ function tooltipLabel(slot: ChartSlot, date: Date): string {
111
+ if (slot.padded) {
112
+ return `No data for ${formattedDate(date)}`;
113
+ }
114
+
115
+ return entriesLabel(slot.value, date);
116
+ }
117
+
118
+ const dates = computed(() => {
119
+ return chartSlots.value.map((_slot, index) => {
120
+ return dateForEntry(index, chartSlots.value.length);
121
+ });
122
+ });
123
+
124
+ const labels = computed(() => {
125
+ return dates.value.map(() => "");
126
+ });
127
+
128
+ const label = computed(() => {
129
+ return `Daily news entries loaded over the last ${chartDays} days`;
130
+ });
131
+
132
+ const chartData = computed<ChartData<"bar", number[], string>>(() => {
133
+ return {
134
+ labels: labels.value,
135
+ datasets: [
136
+ {
137
+ data: barValues.value,
138
+ backgroundColor: barColors.value,
139
+ hoverBackgroundColor: barColors.value,
140
+ borderRadius: 2,
141
+ borderSkipped: false,
142
+ barPercentage: 0.55,
143
+ categoryPercentage: 0.75
144
+ }
145
+ ]
146
+ };
147
+ });
148
+
149
+ const chartOptions = computed<ChartOptions<"bar">>(() => {
150
+ return {
151
+ responsive: true,
152
+ maintainAspectRatio: false,
153
+ animation: false,
154
+ layout: {
155
+ padding: {
156
+ bottom: 0,
157
+ right: 8
158
+ }
159
+ },
160
+ scales: {
161
+ x: {
162
+ offset: true,
163
+ border: {
164
+ display: true,
165
+ color: "#e5e7eb",
166
+ width: 1
167
+ },
168
+ grid: {
169
+ display: false
170
+ },
171
+ ticks: {
172
+ display: false,
173
+ color: "#4b5563",
174
+ font: {
175
+ size: 9
176
+ },
177
+ maxRotation: 0,
178
+ padding: 0,
179
+ autoSkip: false
180
+ }
181
+ },
182
+ y: {
183
+ beginAtZero: true,
184
+ suggestedMax: suggestedMax.value,
185
+ border: {
186
+ display: false
187
+ },
188
+ grid: {
189
+ display: false
190
+ },
191
+ ticks: {
192
+ display: false,
193
+ precision: 0
194
+ }
195
+ }
196
+ },
197
+ plugins: {
198
+ legend: {
199
+ display: false
200
+ },
201
+ tooltip: {
202
+ backgroundColor: "#111827",
203
+ borderColor: "#374151",
204
+ borderWidth: 1,
205
+ displayColors: false,
206
+ padding: 8,
207
+ callbacks: {
208
+ title: () => "",
209
+ label: (context: TooltipItem<"bar">) => {
210
+ const slot = chartSlots.value[context.dataIndex];
211
+
212
+ return tooltipLabel(slot, dates.value[context.dataIndex]);
213
+ }
214
+ }
215
+ }
216
+ }
217
+ };
218
+ });
219
+ </script>
package/src/logic/api.ts CHANGED
@@ -331,6 +331,19 @@ export async function getFeeds() {
331
331
  return feeds;
332
332
  }
333
333
 
334
+ export async function getFeedsByIds({ids}: {ids: t.FeedId[]}) {
335
+ const response = await postPrivate({url: "/get-feeds-by-ids", data: {ids: ids}});
336
+
337
+ const feeds = [];
338
+
339
+ for (let rawFeed of response.feeds) {
340
+ const feed = t.feedFromJSON(rawFeed);
341
+ feeds.push(feed);
342
+ }
343
+
344
+ return feeds;
345
+ }
346
+
334
347
  export async function getLastEntries({period, minTagCount}: {period: number; minTagCount: number}) {
335
348
  const response = await postPrivate({
336
349
  url: "/get-last-entries",
@@ -196,6 +196,7 @@ export const ReferenceKindProperties = new Map<ReferenceKind, ReferenceKindPrope
196
196
  export enum FeedsOrder {
197
197
  Title = "title",
198
198
  Url = "url",
199
+ EntriesPerDay = "entries-per-day",
199
200
  Loaded = "loaded",
200
201
  Linked = "linked"
201
202
  }
@@ -210,6 +211,7 @@ export type FeedsOrderProperty = {
210
211
  export const FeedsOrderProperties = new Map<FeedsOrder, FeedsOrderProperty>([
211
212
  [FeedsOrder.Title, {text: "title", orderField: "title", orderDirection: "asc", default: true}],
212
213
  [FeedsOrder.Url, {text: "url", orderField: "url", orderDirection: "asc"}],
214
+ [FeedsOrder.EntriesPerDay, {text: "news/day", orderField: "entriesPerDay", orderDirection: "desc"}],
213
215
  [FeedsOrder.Loaded, {text: "loaded", orderField: "loadedAt", orderDirection: "desc"}],
214
216
  [FeedsOrder.Linked, {text: "added", orderField: "linkedAt", orderDirection: "desc"}]
215
217
  ]);
@@ -1,7 +1,21 @@
1
1
  import {describe, expect, it} from "vitest";
2
2
 
3
3
  import * as e from "@/logic/enums";
4
- import {entryFromJSON, type RawEntry} from "@/logic/types";
4
+ import {entryFromJSON, feedFromJSON, type RawEntry, type RawFeed} from "@/logic/types";
5
+
6
+ function rawFeed(overrides: Partial<RawFeed> = {}): RawFeed {
7
+ return {
8
+ id: "dca40ac1-73da-49dc-b7d8-40dac312ae40",
9
+ title: "Feed",
10
+ description: "Feed description",
11
+ url: "https://example.com/feed",
12
+ state: "loaded",
13
+ collectionIds: [],
14
+ young: true,
15
+ entriesPerDay: 7,
16
+ ...overrides
17
+ };
18
+ }
5
19
 
6
20
  function rawEntry(markers: number[]): RawEntry {
7
21
  return {
@@ -17,6 +31,71 @@ function rawEntry(markers: number[]): RawEntry {
17
31
  };
18
32
  }
19
33
 
34
+ describe("feedFromJSON", () => {
35
+ it("translates loaded feed response", () => {
36
+ const feed = feedFromJSON(
37
+ rawFeed({
38
+ loadedAt: "2026-05-10T12:00:00Z",
39
+ linkedAt: "2026-05-11T12:00:00Z",
40
+ siteUrl: "https://example.com",
41
+ lastError: "network_connection_timeout",
42
+ collectionIds: ["dca40ac1-73da-49dc-b7d8-40dac312ae41"],
43
+ young: false,
44
+ entriesPerDay: 3,
45
+ entriesLoadedDetails: [1, 0, 3]
46
+ })
47
+ );
48
+
49
+ expect(feed.id).toBe("dca40ac1-73da-49dc-b7d8-40dac312ae40");
50
+ expect(feed.url).toBe("https://example.com/feed");
51
+ expect(feed.siteUrl).toBe("https://example.com");
52
+ expect(feed.state).toBe("loaded");
53
+ expect(feed.lastError).toBe("network_connection_timeout");
54
+ expect(feed.loadedAt).toEqual(new Date("2026-05-10T12:00:00Z"));
55
+ expect(feed.linkedAt).toEqual(new Date("2026-05-11T12:00:00Z"));
56
+ expect(feed.isOk).toBe(true);
57
+ expect(feed.collectionIds).toEqual(["dca40ac1-73da-49dc-b7d8-40dac312ae41"]);
58
+ expect(feed.young).toBe(false);
59
+ expect(feed.entriesPerDay).toBe(3);
60
+ expect(feed.entriesLoadedDetails).toEqual([1, 0, 3]);
61
+ });
62
+
63
+ it("uses null defaults for missing optional response values", () => {
64
+ const feed = feedFromJSON(rawFeed());
65
+
66
+ expect(feed.lastError).toBeNull();
67
+ expect(feed.loadedAt).toBeNull();
68
+ expect(feed.linkedAt).toBeNull();
69
+ expect(feed.siteUrl).toBeNull();
70
+ expect(feed.entriesLoadedDetails).toBeNull();
71
+ });
72
+
73
+ it("keeps null optional response values as null", () => {
74
+ const feed = feedFromJSON(
75
+ rawFeed({
76
+ lastError: null,
77
+ loadedAt: null,
78
+ linkedAt: null,
79
+ siteUrl: null,
80
+ entriesLoadedDetails: null
81
+ })
82
+ );
83
+
84
+ expect(feed.lastError).toBeNull();
85
+ expect(feed.loadedAt).toBeNull();
86
+ expect(feed.linkedAt).toBeNull();
87
+ expect(feed.siteUrl).toBeNull();
88
+ expect(feed.entriesLoadedDetails).toBeNull();
89
+ });
90
+
91
+ it("marks non-loaded feed as not ok", () => {
92
+ const feed = feedFromJSON(rawFeed({state: "damaged"}));
93
+
94
+ expect(feed.state).toBe("damaged");
95
+ expect(feed.isOk).toBe(false);
96
+ });
97
+ });
98
+
20
99
  describe("entryFromJSON", () => {
21
100
  it("translates integer markers to frontend enum values", () => {
22
101
  const entry = entryFromJSON(rawEntry([1, 2]), {});
@@ -41,46 +41,62 @@ export class Feed {
41
41
  readonly title: string | null;
42
42
  readonly description: string | null;
43
43
  readonly url: URL;
44
+ readonly siteUrl: URL | null;
44
45
  readonly state: string;
45
46
  readonly lastError: string | null;
46
47
  readonly loadedAt: Date | null;
47
- readonly linkedAt: Date;
48
+ readonly linkedAt: Date | null;
48
49
  readonly isOk: boolean;
49
50
  readonly collectionIds: CollectionId[];
51
+ readonly young: boolean;
52
+ readonly entriesPerDay: number;
53
+ readonly entriesLoadedDetails: number[] | null;
50
54
 
51
55
  constructor({
52
56
  id,
53
57
  title,
54
58
  description,
55
59
  url,
60
+ siteUrl,
56
61
  state,
57
62
  lastError,
58
63
  loadedAt,
59
64
  linkedAt,
60
65
  isOk,
61
- collectionIds
66
+ collectionIds,
67
+ young,
68
+ entriesPerDay,
69
+ entriesLoadedDetails
62
70
  }: {
63
71
  id: FeedId;
64
72
  title: string | null;
65
73
  description: string | null;
66
74
  url: URL;
75
+ siteUrl: URL | null;
67
76
  state: string;
68
77
  lastError: string | null;
69
78
  loadedAt: Date | null;
70
- linkedAt: Date;
79
+ linkedAt: Date | null;
71
80
  isOk: boolean;
72
81
  collectionIds: CollectionId[];
82
+ young: boolean;
83
+ entriesPerDay: number;
84
+ entriesLoadedDetails: number[] | null;
73
85
  }) {
74
86
  this.id = id;
75
87
  this.title = title;
76
88
  this.description = description;
77
89
  this.url = url;
90
+ this.siteUrl = siteUrl;
78
91
  this.state = state;
79
92
  this.lastError = lastError;
80
93
  this.loadedAt = loadedAt;
81
94
  this.linkedAt = linkedAt;
82
95
  this.isOk = isOk;
83
96
  this.collectionIds = collectionIds;
97
+ this.young = young;
98
+ this.entriesPerDay = entriesPerDay;
99
+ this.entriesLoadedDetails = entriesLoadedDetails;
84
100
  }
85
101
  }
86
102
 
@@ -89,11 +105,15 @@ export type RawFeed = {
89
105
  title: string | null;
90
106
  description: string | null;
91
107
  url: string;
108
+ siteUrl?: string | null;
92
109
  state: string;
93
110
  lastError?: string | null;
94
111
  loadedAt?: string | null;
95
- linkedAt: string;
112
+ linkedAt?: string | null;
96
113
  collectionIds: string[];
114
+ young: boolean;
115
+ entriesPerDay: number;
116
+ entriesLoadedDetails?: number[] | null;
97
117
  };
98
118
 
99
119
  export function feedFromJSON({
@@ -101,23 +121,31 @@ export function feedFromJSON({
101
121
  title,
102
122
  description,
103
123
  url,
124
+ siteUrl,
104
125
  state,
105
126
  lastError,
106
127
  loadedAt,
107
128
  linkedAt,
108
- collectionIds
129
+ collectionIds,
130
+ young,
131
+ entriesPerDay,
132
+ entriesLoadedDetails
109
133
  }: RawFeed): Feed {
110
134
  return {
111
135
  id: toFeedId(id),
112
136
  title: title !== null ? title : null,
113
137
  description: description !== null ? description : null,
114
138
  url: toURL(url),
139
+ siteUrl: siteUrl !== undefined && siteUrl !== null ? toURL(siteUrl) : null,
115
140
  state: state,
116
141
  lastError: lastError !== undefined ? lastError : null,
117
142
  loadedAt: loadedAt !== undefined && loadedAt !== null ? new Date(loadedAt) : null,
118
- linkedAt: new Date(linkedAt),
143
+ linkedAt: linkedAt !== undefined && linkedAt !== null ? new Date(linkedAt) : null,
119
144
  isOk: state === "loaded",
120
- collectionIds: collectionIds.map(toCollectionId)
145
+ collectionIds: collectionIds.map(toCollectionId),
146
+ young: young,
147
+ entriesPerDay: entriesPerDay,
148
+ entriesLoadedDetails: entriesLoadedDetails !== undefined ? entriesLoadedDetails : null
121
149
  };
122
150
  }
123
151
 
@@ -389,6 +417,7 @@ export function entryInfoFromJSON({title, body, url, publishedAt}: RawEntryInfo)
389
417
 
390
418
  export type FeedInfo = {
391
419
  readonly url: URL;
420
+ readonly siteUrl: URL | null;
392
421
  readonly title: string;
393
422
  readonly description: string;
394
423
  readonly entries: EntryInfo[];
@@ -397,15 +426,17 @@ export type FeedInfo = {
397
426
 
398
427
  export type RawFeedInfo = {
399
428
  url: string;
429
+ siteUrl?: string | null;
400
430
  title: string;
401
431
  description: string;
402
432
  entries: RawEntryInfo[];
403
433
  isLinked: boolean;
404
434
  };
405
435
 
406
- export function feedInfoFromJSON({url, title, description, entries, isLinked}: RawFeedInfo): FeedInfo {
436
+ export function feedInfoFromJSON({url, siteUrl, title, description, entries, isLinked}: RawFeedInfo): FeedInfo {
407
437
  return {
408
438
  url: toURL(url),
439
+ siteUrl: siteUrl !== undefined && siteUrl !== null ? toURL(siteUrl) : null,
409
440
  title,
410
441
  description,
411
442
  entries: entries.map(entryInfoFromJSON),
@@ -1,9 +1,7 @@
1
- import {computed, ref, watch, triggerRef} from "vue";
2
- import {useRouter} from "vue-router";
1
+ import {ref} from "vue";
3
2
  import {defineStore} from "pinia";
4
3
 
5
- import type * as t from "@/logic/types";
6
- import * as e from "@/logic/enums";
4
+ import * as t from "@/logic/types";
7
5
  import * as api from "@/logic/api";
8
6
  import {Timer} from "@/logic/timer";
9
7
  import {computedAsync} from "@vueuse/core";
@@ -12,20 +10,112 @@ import {useGlobalSettingsStore} from "@/stores/globalSettings";
12
10
  export const useFeedsStore = defineStore("feedsStore", () => {
13
11
  const globalSettings = useGlobalSettingsStore();
14
12
 
15
- const feeds = computedAsync(async () => {
13
+ const feeds = ref<{[key: t.FeedId]: t.Feed}>({});
14
+ const requestedFeeds = ref<{[key: t.FeedId]: boolean}>({});
15
+ const displayedFeedId = ref<t.FeedId | null>(null);
16
+
17
+ function feedWithFullDataFromStorage(feed: t.Feed, preserveFullData: boolean) {
18
+ // Full feed details power derived UI, like activity charts. On full list
19
+ // refresh we must drop them so opened feed bodies reload fresh details.
20
+ if (!preserveFullData) {
21
+ return feed;
22
+ }
23
+
24
+ if (feed.id in feeds.value) {
25
+ const existingFeed = feeds.value[feed.id];
26
+
27
+ if (feed.entriesLoadedDetails === null && existingFeed.entriesLoadedDetails !== null) {
28
+ return {
29
+ ...feed,
30
+ entriesLoadedDetails: existingFeed.entriesLoadedDetails
31
+ };
32
+ }
33
+ }
34
+
35
+ return feed;
36
+ }
37
+
38
+ function registerFeeds({newFeeds, replace}: {newFeeds: t.Feed[]; replace: boolean}) {
39
+ const delta: {[key: t.FeedId]: t.Feed} = {};
40
+
41
+ for (const feed of newFeeds) {
42
+ delta[feed.id] = feedWithFullDataFromStorage(feed, !replace);
43
+ }
44
+
45
+ if (replace) {
46
+ feeds.value = delta;
47
+
48
+ if (displayedFeedId.value === null) {
49
+ return;
50
+ }
51
+
52
+ if (!(displayedFeedId.value in feeds.value)) {
53
+ displayedFeedId.value = null;
54
+ return;
55
+ }
56
+
57
+ requestFullFeed({feedId: displayedFeedId.value});
58
+
59
+ return;
60
+ }
61
+
62
+ feeds.value = {...feeds.value, ...delta};
63
+ }
64
+
65
+ const loadedFeedsReport = computedAsync(async () => {
16
66
  // force refresh
17
67
  globalSettings.dataVersion;
18
68
 
19
69
  const feedsList = await api.getFeeds();
20
70
 
21
- const feedsDict: {[key: t.FeedId]: t.Feed} = {};
71
+ registerFeeds({
72
+ newFeeds: feedsList,
73
+ replace: true
74
+ });
75
+
76
+ return feedsList.map((feed) => feed.id);
77
+ }, null);
22
78
 
23
- for (const feed of feedsList) {
24
- feedsDict[feed.id] = feed;
79
+ function requestFullFeed({feedId}: {feedId: t.FeedId}) {
80
+ if (feedId in feeds.value && feeds.value[feedId].entriesLoadedDetails !== null) {
81
+ return;
25
82
  }
26
83
 
27
- return feedsDict;
28
- }, {});
84
+ requestedFeeds.value[feedId] = true;
85
+ }
86
+
87
+ function displayFeed({feedId}: {feedId: t.FeedId}) {
88
+ displayedFeedId.value = feedId;
89
+
90
+ requestFullFeed({feedId: feedId});
91
+ }
92
+
93
+ function hideFeed({feedId}: {feedId: t.FeedId}) {
94
+ if (displayedFeedId.value === feedId) {
95
+ displayedFeedId.value = null;
96
+ }
97
+ }
98
+
99
+ async function loadFullFeeds() {
100
+ const ids: t.FeedId[] = Object.keys(requestedFeeds.value).map((key) => t.toFeedId(key));
101
+
102
+ if (ids.length === 0) {
103
+ return;
104
+ }
105
+
106
+ const loadedFeeds = await api.getFeedsByIds({ids: ids});
107
+
108
+ registerFeeds({
109
+ newFeeds: loadedFeeds,
110
+ replace: false
111
+ });
112
+
113
+ requestedFeeds.value = {};
114
+ }
115
+
116
+ const requestedFeedsTimer = new Timer(loadFullFeeds, 1000);
117
+
118
+ requestedFeedsTimer.start();
29
119
 
30
120
  async function unsubscribe(feedId: t.FeedId) {
31
121
  await api.unsubscribe({feedId: feedId});
@@ -34,9 +124,9 @@ export const useFeedsStore = defineStore("feedsStore", () => {
34
124
  // it cause a lot of unnecessary requests to the server without any benefit
35
125
  // we just remove feed from frontend
36
126
 
37
- delete feeds.value[feedId];
38
-
39
- triggerRef(feeds);
127
+ const updatedFeeds = {...feeds.value};
128
+ delete updatedFeeds[feedId];
129
+ feeds.value = updatedFeeds;
40
130
  }
41
131
 
42
132
  async function subscribe(url: t.URL) {
@@ -46,13 +136,19 @@ export const useFeedsStore = defineStore("feedsStore", () => {
46
136
 
47
137
  // Attention, do not update globalSettings.updateDataVersion here (see above)
48
138
 
49
- feeds.value[newFeed.id] = newFeed;
50
-
51
- triggerRef(feeds);
139
+ registerFeeds({
140
+ newFeeds: [newFeed],
141
+ replace: false
142
+ });
52
143
  }
53
144
 
54
145
  return {
55
146
  feeds,
147
+ loadedFeedsReport,
148
+ displayedFeedId,
149
+ requestFullFeed,
150
+ displayFeed,
151
+ hideFeed,
56
152
  unsubscribe,
57
153
  subscribe
58
154
  };
@@ -177,7 +177,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => {
177
177
  // Feeds filter settings
178
178
  ////////////////////////
179
179
 
180
- const showFeedsDescriptions = boolBackendSettings("view_feeds_show_feed_descriptions", true);
181
180
  const feedsOrder = enumBackendSettings("view_feeds_feed_order", e.FeedsOrderProperties);
182
181
  const failedFeedsFirst = boolBackendSettings("view_feeds_failed_feeds_first", false);
183
182
 
@@ -217,7 +216,6 @@ export const useGlobalSettingsStore = defineStore("globalSettings", () => {
217
216
  entriesOrderProperties,
218
217
  dataVersion,
219
218
  updateDataVersion,
220
- showFeedsDescriptions,
221
219
  userSettingsPresent,
222
220
  feedsOrder,
223
221
  failedFeedsFirst,
@@ -0,0 +1,161 @@
1
+ import {createPinia, setActivePinia} from "pinia";
2
+ import {beforeEach, describe, expect, it, vi} from "vitest";
3
+
4
+ import * as api from "@/logic/api";
5
+ import * as t from "@/logic/types";
6
+ import {useFeedsStore} from "@/stores/feeds";
7
+
8
+ const timerInstances = vi.hoisted(() => [] as Array<{refresher: () => Promise<void>; delay: number}>);
9
+
10
+ vi.mock("@/logic/timer", () => ({
11
+ Timer: vi.fn().mockImplementation((refresher, delay) => {
12
+ timerInstances.push({refresher, delay});
13
+
14
+ return {
15
+ start: vi.fn(),
16
+ stop: vi.fn()
17
+ };
18
+ })
19
+ }));
20
+
21
+ vi.mock("@/stores/globalSettings", () => ({
22
+ useGlobalSettingsStore: vi.fn(() => ({
23
+ dataVersion: 0
24
+ }))
25
+ }));
26
+
27
+ vi.mock("@/logic/api", () => ({
28
+ addFeed: vi.fn(),
29
+ getFeeds: vi.fn(),
30
+ getFeedsByIds: vi.fn(),
31
+ unsubscribe: vi.fn()
32
+ }));
33
+
34
+ function feed(overrides: Partial<t.RawFeed> = {}) {
35
+ return t.feedFromJSON({
36
+ id: "dca40ac1-73da-49dc-b7d8-40dac312ae40",
37
+ title: "Feed",
38
+ description: "Feed description",
39
+ url: "https://example.com/feed",
40
+ siteUrl: "https://example.com",
41
+ state: "loaded",
42
+ collectionIds: [],
43
+ young: false,
44
+ entriesPerDay: 3,
45
+ ...overrides
46
+ });
47
+ }
48
+
49
+ async function settleAsyncWork() {
50
+ await Promise.resolve();
51
+ await Promise.resolve();
52
+ await Promise.resolve();
53
+ }
54
+
55
+ describe("useFeedsStore", () => {
56
+ beforeEach(() => {
57
+ vi.clearAllMocks();
58
+ timerInstances.length = 0;
59
+ setActivePinia(createPinia());
60
+
61
+ vi.mocked(api.addFeed).mockResolvedValue(feed());
62
+ vi.mocked(api.getFeeds).mockResolvedValue([]);
63
+ vi.mocked(api.getFeedsByIds).mockResolvedValue([]);
64
+ vi.mocked(api.unsubscribe).mockResolvedValue(undefined);
65
+ });
66
+
67
+ it("loads feed list and preserves already loaded details from storage", async () => {
68
+ const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
69
+
70
+ vi.mocked(api.addFeed).mockResolvedValue(feed({entriesLoadedDetails: [1, 0, 3]}));
71
+ vi.mocked(api.getFeeds).mockResolvedValue([feed({entriesLoadedDetails: null})]);
72
+
73
+ const store = useFeedsStore();
74
+
75
+ await store.subscribe(t.toURL("https://example.com/feed"));
76
+
77
+ expect(store.feeds[feedId].entriesLoadedDetails).toEqual([1, 0, 3]);
78
+
79
+ store.loadedFeedsReport;
80
+ await settleAsyncWork();
81
+
82
+ expect(store.loadedFeedsReport).toEqual([feedId]);
83
+ expect(store.feeds[feedId].entriesLoadedDetails).toEqual([1, 0, 3]);
84
+ });
85
+
86
+ it("loads full feed data for displayed feed only once", async () => {
87
+ const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
88
+ const store = useFeedsStore();
89
+
90
+ await store.subscribe(t.toURL("https://example.com/feed"));
91
+
92
+ vi.mocked(api.getFeedsByIds).mockResolvedValue([feed({entriesLoadedDetails: [2, 4]})]);
93
+
94
+ store.displayFeed({feedId});
95
+
96
+ expect(store.displayedFeedId).toBe(feedId);
97
+
98
+ await timerInstances[0].refresher();
99
+
100
+ expect(api.getFeedsByIds).toHaveBeenCalledWith({ids: [feedId]});
101
+ expect(store.feeds[feedId].entriesLoadedDetails).toEqual([2, 4]);
102
+
103
+ vi.mocked(api.getFeedsByIds).mockClear();
104
+
105
+ store.requestFullFeed({feedId});
106
+
107
+ await timerInstances[0].refresher();
108
+
109
+ expect(api.getFeedsByIds).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("keeps full feed request queued when API loading fails", async () => {
113
+ const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
114
+ const store = useFeedsStore();
115
+
116
+ await store.subscribe(t.toURL("https://example.com/feed"));
117
+
118
+ vi.mocked(api.getFeedsByIds).mockRejectedValueOnce(new Error("load failed"));
119
+
120
+ store.requestFullFeed({feedId});
121
+
122
+ await expect(timerInstances[0].refresher()).rejects.toThrow("load failed");
123
+
124
+ vi.mocked(api.getFeedsByIds).mockResolvedValueOnce([feed({entriesLoadedDetails: [7]})]);
125
+
126
+ await timerInstances[0].refresher();
127
+
128
+ expect(api.getFeedsByIds).toHaveBeenLastCalledWith({ids: [feedId]});
129
+ expect(store.feeds[feedId].entriesLoadedDetails).toEqual([7]);
130
+ });
131
+
132
+ it("hides only the currently displayed feed", () => {
133
+ const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
134
+ const anotherFeedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae41");
135
+ const store = useFeedsStore();
136
+
137
+ store.displayFeed({feedId});
138
+
139
+ store.hideFeed({feedId: anotherFeedId});
140
+
141
+ expect(store.displayedFeedId).toBe(feedId);
142
+
143
+ store.hideFeed({feedId});
144
+
145
+ expect(store.displayedFeedId).toBeNull();
146
+ });
147
+
148
+ it("removes unsubscribed feeds from storage", async () => {
149
+ const feedId = t.toFeedId("dca40ac1-73da-49dc-b7d8-40dac312ae40");
150
+ const store = useFeedsStore();
151
+
152
+ await store.subscribe(t.toURL("https://example.com/feed"));
153
+
154
+ expect(store.feeds[feedId]).toBeDefined();
155
+
156
+ await store.unsubscribe(feedId);
157
+
158
+ expect(api.unsubscribe).toHaveBeenCalledWith({feedId});
159
+ expect(store.feeds[feedId]).toBeUndefined();
160
+ });
161
+ });
@@ -1,22 +1,13 @@
1
1
  <template>
2
2
  <side-panel-layout>
3
3
  <template #side-menu-item-1>
4
- Show descriptions
5
- <config-flag
6
- style="min-width: 2.5rem"
7
- v-model:flag="globalSettings.showFeedsDescriptions"
8
- on-text="yes"
9
- off-text="no" />
10
- </template>
11
-
12
- <template #side-menu-item-2>
13
4
  Sorted by
14
5
  <config-selector
15
6
  :values="e.FeedsOrderProperties"
16
7
  v-model:property="globalSettings.feedsOrder" />
17
8
  </template>
18
9
 
19
- <template #side-menu-item-3>
10
+ <template #side-menu-item-2>
20
11
  Show failed
21
12
  <config-flag
22
13
  v-model:flag="globalSettings.failedFeedsFirst"
@@ -25,7 +16,7 @@
25
16
  off-text="last" />
26
17
  </template>
27
18
 
28
- <template #side-menu-item-4>
19
+ <template #side-menu-item-3>
29
20
  <a
30
21
  class="ffun-form-button p-1 my-1 block w-full text-center"
31
22
  :href="api.downloadOPMLUrl"