feeds-fun
Version:
Frontend for the Feeds Fun — web-based news reader
463 lines (362 loc) • 11.3 kB
text/typescript
import * as _ from "lodash";
import axios, {AxiosError} from "axios";
import * as t from "@/logic/types";
import type * as e from "@/logic/enums";
import * as settings from "@/logic/settings";
import * as cookieConsent from "@/plugins/CookieConsent";
import {useGlobalState} from "@/stores/globalState";
///////////////
// API handlers
///////////////
const publicEntryPoint = "/spa/api/public";
const privateEntryPoint = "/spa/api/private";
const apiPublic = axios.create({baseURL: publicEntryPoint, withCredentials: true});
const apiPrivate = axios.create({baseURL: privateEntryPoint, withCredentials: true});
// It is an open question what should we do in case of session expiration:
// - redirect to login page
// - redirect to home page & show notification
// Currently the easiest way to handle it is to always redirect to login page.
export function redirectToLogin(returnTo?: string) {
if (!returnTo) {
returnTo = window.location.pathname + window.location.search;
}
window.location.assign(`/spa/auth/login?return_to=${encodeURIComponent(returnTo)}`);
}
export function redirectToJoin(returnTo?: string) {
if (!returnTo) {
returnTo = window.location.pathname + window.location.search;
}
window.location.assign(`/spa/auth/join?return_to=${encodeURIComponent(returnTo)}`);
}
export function logoutRedirect() {
window.location.assign("/spa/auth/logout");
}
let _refreshingAuth: Promise<void> | null = null;
enum Ffun401Behaviour {
RedirectToLogin = "redirectToLogin",
DoNotRetry = "doNotRetry",
ReturnNull = "returnNull"
}
// We try to refresh auth on 401 responses for private API.
// For the public API we do nothing, because it most likely means infrastructure issue.
apiPrivate.interceptors.response.use(
(r) => r,
async (error) => {
const {config, response} = error;
if (!response) {
throw error;
}
if (response.status !== 401) {
throw error;
}
if (config?.ffunRequestRetried) {
throw error;
}
if (config?.ffun401Behaviour === Ffun401Behaviour.RedirectToLogin) {
redirectToLogin();
return; // never reached
}
if (config?.ffun401Behaviour === Ffun401Behaviour.DoNotRetry) {
throw error;
}
if (config?.ffun401Behaviour === Ffun401Behaviour.ReturnNull) {
return {data: null};
}
(config as any).ffunRequestRetried = true;
if (!_refreshingAuth) {
_refreshingAuth = apiPrivate
// @ts-ignore
.post("/refresh-auth", undefined, {ffun401Behaviour: Ffun401Behaviour.RedirectToLogin})
.then(() => {})
.finally(() => {
_refreshingAuth = null;
});
}
await _refreshingAuth; // all 401s await the same refresh
return await apiPrivate(config); // retry the original request generically
}
);
async function postPublic({url, data}: {url: string; data: any}) {
const response = await apiPublic.post(url, data);
return response.data;
}
async function postPrivate({url, data, config}: {url: string; data: any; config?: any}) {
const response = await apiPrivate.post(url, data, config);
return response.data;
}
/////////////
// Public API
/////////////
export async function getLastCollectionEntries({
period,
collectionSlug,
minTagCount
}: {
period: number;
collectionSlug: t.CollectionSlug | null;
minTagCount: number;
}) {
const response = await postPublic({
url: "/get-last-collection-entries",
data: {period: period, collectionSlug: collectionSlug, minTagCount: minTagCount}
});
const entries = [];
for (let rawEntry of response.entries) {
const entry = t.entryFromJSON(rawEntry, response.tagsMapping);
entries.push(entry);
}
return entries;
}
export async function getEntriesByIds({ids}: {ids: t.EntryId[]}) {
const globalState = useGlobalState();
let response = null;
if (globalState.loginConfirmed) {
response = await postPrivate({
url: "/get-entries-by-ids",
data: {ids: ids},
config: {ffun401Behaviour: Ffun401Behaviour.ReturnNull}
});
}
if (!response) {
response = await postPublic({
url: "/get-entries-by-ids",
data: {ids: ids}
});
}
const entries = [];
for (let rawEntry of response.entries) {
const entry = t.entryFromJSON(rawEntry, response.tagsMapping);
entries.push(entry);
}
return entries;
}
export async function getCollections() {
const response = await postPublic({url: "/get-collections", data: {}});
const collections = [];
for (let rawCollection of response.collections) {
const collection = t.collectionFromJSON(rawCollection);
collections.push(collection);
}
return collections;
}
export async function getCollectionFeeds({collectionId}: {collectionId: t.CollectionId}) {
const response = await postPublic({
url: "/get-collection-feeds",
data: {collectionId: collectionId}
});
const feeds = [];
for (let rawFeed of response.feeds) {
const feed = t.collectionFeedInfoFromJSON(rawFeed);
feeds.push(feed);
}
return feeds;
}
export async function getTagsInfo({uids}: {uids: string[]}) {
const response = await postPublic({url: "/get-tags-info", data: {uids: uids}});
const tags: {[key: string]: t.TagInfo} = {};
for (let uid in response.tags) {
const rawTag = response.tags[uid];
const tag = t.tagInfoFromJSON(rawTag);
tags[uid] = tag;
}
return tags;
}
export async function getInfo() {
const response = await postPublic({url: "/get-info", data: {}});
return t.stateInfoFromJSON(response);
}
export async function getUser() {
const response = await postPrivate({
url: "/get-user",
data: {},
config: {ffun401Behaviour: Ffun401Behaviour.ReturnNull}
});
if (!response) {
return null;
}
return t.userInfoFromJSON(response);
}
export function trackEvent(data: {[key: string]: string | number | null}) {
if (!settings.trackEvents) {
return;
}
if (!cookieConsent.isAnalyticsAllowed()) {
return;
}
const globalState = useGlobalState();
let url: string;
if (globalState.loginConfirmed) {
url = privateEntryPoint + "/track-event";
} else {
url = publicEntryPoint + "/track-event";
}
let payload = JSON.stringify({event: data});
if ("sendBeacon" in navigator) {
return navigator.sendBeacon(url, payload);
}
// Fallback: fire-and-forget; avoid preflight by using text/plain + no-cors
fetch(url, {
method: "POST",
keepalive: true,
mode: "no-cors",
headers: {"Content-Type": "text/plain;charset=UTF-8"},
body: payload
}).catch(() => {});
}
//////////////
// Private API
//////////////
export async function getFeeds() {
const response = await postPrivate({url: "/get-feeds", data: {}});
const feeds = [];
for (let rawFeed of response.feeds) {
const feed = t.feedFromJSON(rawFeed);
feeds.push(feed);
}
return feeds;
}
export async function getLastEntries({period, minTagCount}: {period: number; minTagCount: number}) {
const response = await postPrivate({
url: "/get-last-entries",
data: {
period: period,
minTagCount: minTagCount
}
});
const entries = [];
for (let rawEntry of response.entries) {
const entry = t.entryFromJSON(rawEntry, response.tagsMapping);
entries.push(entry);
}
return entries;
}
export async function createOrUpdateRule({
requiredTags,
excludedTags,
score
}: {
requiredTags: string[];
excludedTags: string[];
score: number;
}) {
const response = await postPrivate({
url: "/create-or-update-rule",
data: {
requiredTags: requiredTags,
excludedTags: excludedTags,
score: score
}
});
return response;
}
export async function deleteRule({id}: {id: t.RuleId}) {
const response = await postPrivate({url: "/delete-rule", data: {id: id}});
return response;
}
export async function updateRule({
id,
requiredTags,
excludedTags,
score
}: {
id: t.RuleId;
requiredTags: string[];
excludedTags: string[];
score: number;
}) {
const response = await postPrivate({
url: "/update-rule",
data: {id: id, score: score, requiredTags: requiredTags, excludedTags: excludedTags}
});
return response;
}
export async function getRules() {
const response = await postPrivate({url: "/get-rules", data: {}});
const rules = [];
for (let rawRule of response.rules) {
const rule = t.ruleFromJSON(rawRule);
rules.push(rule);
}
return rules;
}
export async function getScoreDetails({entryId}: {entryId: t.EntryId}) {
const response = await postPrivate({
url: "/get-score-details",
data: {entryId: entryId}
});
const rules = [];
for (let rawRule of response.rules) {
const rule = t.ruleFromJSON(rawRule);
rules.push(rule);
}
return rules;
}
export async function setMarker({entryId, marker}: {entryId: t.EntryId; marker: e.Marker}) {
await postPrivate({
url: "/set-marker",
data: {entryId: entryId, marker: marker}
});
}
export async function removeMarker({entryId, marker}: {entryId: t.EntryId; marker: e.Marker}) {
await postPrivate({
url: "/remove-marker",
data: {entryId: entryId, marker: marker}
});
}
export async function discoverFeeds({url}: {url: string}) {
const response = await postPrivate({url: "/discover-feeds", data: {url: url}});
const feeds = [];
const messages = [];
for (let rawFeed of response.feeds) {
const feed = t.feedInfoFromJSON(rawFeed);
feeds.push(feed);
}
for (let rawMessage of response.messages) {
const message = t.apiMessageFromJSON(rawMessage);
messages.push(message);
}
return {feeds, messages};
}
export async function addFeed({url}: {url: string}) {
const response = await postPrivate({url: "/add-feed", data: {url: url}});
return t.feedFromJSON(response.feed);
}
export async function addOPML({content}: {content: string}) {
await postPrivate({url: "/add-opml", data: {content: content}});
}
export async function unsubscribe({feedId}: {feedId: t.FeedId}) {
await postPrivate({url: "/unsubscribe", data: {feedId: feedId}});
}
export async function subscribeToCollections({collectionsIds}: {collectionsIds: t.CollectionId[]}) {
await postPrivate({
url: "/subscribe-to-collections",
data: {collections: collectionsIds}
});
}
export async function getUserSettings() {
const response = await postPrivate({url: "/get-user-settings", data: {}});
const settings: {[key: string]: t.UserSetting} = {};
for (let rawSetting of response.settings) {
const setting = t.userSettingFromJSON(rawSetting);
settings[setting.kind] = setting;
}
return settings;
}
export async function setUserSetting({kind, value}: {kind: string; value: string | number | boolean}) {
await postPrivate({url: "/set-user-setting", data: {kind: kind, value: value}});
}
export async function getResourceHistory({kind}: {kind: string}) {
const response = await postPrivate({
url: "/get-resource-history",
data: {kind: kind}
});
const history = [];
for (let rawRecord of response.history) {
const record = t.resourceHistoryRecordFromJSON(rawRecord);
history.push(record);
}
return history;
}
export async function removeUser() {
await postPrivate({url: "/remove-user", data: {}});
}