init commit,
This commit is contained in:
63
99_references/voyager-main/src/services/app.ts
Normal file
63
99_references/voyager-main/src/services/app.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { isNative } from "../helpers/device";
|
||||
import { isEqual } from "lodash";
|
||||
|
||||
const DEFAULT_LEMMY_SERVERS = getCustomDefaultServers() ?? ["lemmy.world"];
|
||||
|
||||
let _customServers = DEFAULT_LEMMY_SERVERS;
|
||||
|
||||
export function getCustomServers() {
|
||||
return _customServers;
|
||||
}
|
||||
|
||||
export function getDefaultServer() {
|
||||
return _customServers[0]!;
|
||||
}
|
||||
|
||||
export function defaultServersUntouched() {
|
||||
return isEqual(DEFAULT_LEMMY_SERVERS, getCustomServers());
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
if (isNative()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch("/_config");
|
||||
|
||||
const { customServers } = await response.json();
|
||||
|
||||
if (customServers?.length) {
|
||||
_customServers = customServers;
|
||||
}
|
||||
} catch (_) {
|
||||
return; // ignore errors in loading config
|
||||
}
|
||||
}
|
||||
|
||||
// Only needs to be done once for app load
|
||||
const config = getConfig();
|
||||
|
||||
interface ConfigProviderProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ConfigProvider({ children }: ConfigProviderProps) {
|
||||
const [configLoaded, setConfigLoaded] = useState(isNative()); // native does not load config
|
||||
|
||||
useEffect(() => {
|
||||
// Config is not necessary for app to run
|
||||
config.finally(() => {
|
||||
setConfigLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (configLoaded) return children;
|
||||
}
|
||||
|
||||
function getCustomDefaultServers() {
|
||||
const serversList = import.meta.env.VITE_CUSTOM_LEMMY_SERVERS;
|
||||
|
||||
if (!serversList) return;
|
||||
|
||||
return serversList.split(",");
|
||||
}
|
776
99_references/voyager-main/src/services/db.ts
Normal file
776
99_references/voyager-main/src/services/db.ts
Normal file
@@ -0,0 +1,776 @@
|
||||
import { differenceInHours, subHours } from "date-fns";
|
||||
import Dexie, { Table } from "dexie";
|
||||
import { CommentSortType, FederatedInstances, SortType } from "lemmy-js-client";
|
||||
import { zipObject } from "lodash";
|
||||
import { ALL_POST_SORTS } from "../features/feed/PostSort";
|
||||
import { COMMENT_SORTS } from "../features/comment/CommentSort";
|
||||
import { StringArrayToIdentityObject } from "../helpers/typescript";
|
||||
|
||||
export interface IPostMetadata {
|
||||
post_id: number;
|
||||
user_handle: string;
|
||||
hidden: 0 | 1; // Not boolean because dexie doesn't support booleans for indexes
|
||||
hidden_updated_at?: number;
|
||||
}
|
||||
|
||||
export interface InstanceData {
|
||||
domain: string;
|
||||
updated: Date;
|
||||
data: FederatedInstances;
|
||||
}
|
||||
|
||||
export const OAppThemeType = {
|
||||
Default: "default",
|
||||
FieryMario: "mario",
|
||||
Pistachio: "pistachio",
|
||||
SpookyPumpkin: "pumpkin",
|
||||
UV: "uv",
|
||||
Mint: "mint",
|
||||
Dracula: "dracula",
|
||||
Tangerine: "tangerine",
|
||||
Sunset: "sunset",
|
||||
Outrun: "outrun",
|
||||
} as const;
|
||||
|
||||
export type AppThemeType = (typeof OAppThemeType)[keyof typeof OAppThemeType];
|
||||
|
||||
export const OCommentsThemeType = {
|
||||
Rainbow: "rainbow",
|
||||
UnoReverse: "uno-reverse",
|
||||
Pastel: "pastel",
|
||||
Mauve: "mauve",
|
||||
Electric: "electric",
|
||||
Citrus: "citrus",
|
||||
Blush: "blush",
|
||||
} as const;
|
||||
|
||||
export type CommentsThemeType =
|
||||
(typeof OCommentsThemeType)[keyof typeof OCommentsThemeType];
|
||||
|
||||
export const OVotesThemeType = {
|
||||
Lemmy: "lemmy",
|
||||
Reddit: "reddit",
|
||||
} as const;
|
||||
|
||||
export type VotesThemeType =
|
||||
(typeof OVotesThemeType)[keyof typeof OVotesThemeType];
|
||||
|
||||
export const OPostAppearanceType = {
|
||||
Compact: "compact",
|
||||
Large: "large",
|
||||
} as const;
|
||||
|
||||
export type PostAppearanceType =
|
||||
(typeof OPostAppearanceType)[keyof typeof OPostAppearanceType];
|
||||
|
||||
export const OCompactThumbnailPositionType = {
|
||||
Left: "left",
|
||||
Right: "right",
|
||||
} as const;
|
||||
|
||||
export type CompactThumbnailPositionType =
|
||||
(typeof OCompactThumbnailPositionType)[keyof typeof OCompactThumbnailPositionType];
|
||||
|
||||
export const OCompactThumbnailSizeType = {
|
||||
Hidden: "hidden",
|
||||
|
||||
/**
|
||||
* Default
|
||||
*/
|
||||
Small: "small",
|
||||
|
||||
Medium: "medium",
|
||||
Large: "large",
|
||||
} as const;
|
||||
|
||||
export type CompactThumbnailSizeType =
|
||||
(typeof OCompactThumbnailSizeType)[keyof typeof OCompactThumbnailSizeType];
|
||||
|
||||
export const OCommentThreadCollapse = {
|
||||
Never: "never",
|
||||
RootOnly: "root_only",
|
||||
All: "all",
|
||||
} as const;
|
||||
|
||||
export type CommentThreadCollapse =
|
||||
(typeof OCommentThreadCollapse)[keyof typeof OCommentThreadCollapse];
|
||||
|
||||
export const OPostBlurNsfw = {
|
||||
InFeed: "in_feed",
|
||||
Never: "never",
|
||||
} as const;
|
||||
|
||||
export type CommentDefaultSort = CommentSortType;
|
||||
export const OCommentDefaultSort = zipObject(
|
||||
COMMENT_SORTS,
|
||||
COMMENT_SORTS,
|
||||
) as StringArrayToIdentityObject<typeof COMMENT_SORTS>;
|
||||
|
||||
export const OSortType = zipObject(
|
||||
ALL_POST_SORTS,
|
||||
ALL_POST_SORTS,
|
||||
) as StringArrayToIdentityObject<typeof ALL_POST_SORTS>;
|
||||
|
||||
export type PostBlurNsfwType =
|
||||
(typeof OPostBlurNsfw)[keyof typeof OPostBlurNsfw];
|
||||
|
||||
export const OInstanceUrlDisplayMode = {
|
||||
WhenRemote: "when-remote",
|
||||
Never: "never",
|
||||
} as const;
|
||||
|
||||
export type InstanceUrlDisplayMode =
|
||||
(typeof OInstanceUrlDisplayMode)[keyof typeof OInstanceUrlDisplayMode];
|
||||
|
||||
export const OVoteDisplayMode = {
|
||||
/**
|
||||
* Show upvotes and downvotes separately
|
||||
*/
|
||||
Separate: "separate",
|
||||
|
||||
/**
|
||||
* Show total score (upvotes + downvotes)
|
||||
*/
|
||||
Total: "total",
|
||||
|
||||
/**
|
||||
* Hide scores
|
||||
*/
|
||||
Hide: "hide",
|
||||
} as const;
|
||||
|
||||
export type VoteDisplayMode =
|
||||
(typeof OVoteDisplayMode)[keyof typeof OVoteDisplayMode];
|
||||
|
||||
export const OProfileLabelType = {
|
||||
/**
|
||||
* e.g. aeharding@lemmy.world
|
||||
*/
|
||||
Handle: "handle",
|
||||
|
||||
/**
|
||||
* e.g. aeharding
|
||||
*/
|
||||
Username: "username",
|
||||
|
||||
/**
|
||||
* e.g. lemmy.world
|
||||
*/
|
||||
Instance: "instance",
|
||||
|
||||
/**
|
||||
* e.g. Profile
|
||||
*/
|
||||
Hide: "hide",
|
||||
} as const;
|
||||
|
||||
export type LinkHandlerType =
|
||||
(typeof OLinkHandlerType)[keyof typeof OLinkHandlerType];
|
||||
|
||||
export const OLinkHandlerType = {
|
||||
DefaultBrowser: "default-browser",
|
||||
InApp: "in-app",
|
||||
} as const;
|
||||
|
||||
export type ShowSubscribedIcon =
|
||||
(typeof OShowSubscribedIcon)[keyof typeof OShowSubscribedIcon];
|
||||
|
||||
export const OShowSubscribedIcon = {
|
||||
Never: "never",
|
||||
OnlyAllLocal: "all-local",
|
||||
Everywhere: "everywhere",
|
||||
} as const;
|
||||
|
||||
export type DefaultFeedType =
|
||||
| {
|
||||
type:
|
||||
| typeof ODefaultFeedType.All
|
||||
| typeof ODefaultFeedType.Home
|
||||
| typeof ODefaultFeedType.Local
|
||||
| typeof ODefaultFeedType.CommunityList
|
||||
| typeof ODefaultFeedType.Moderating;
|
||||
}
|
||||
| {
|
||||
type: typeof ODefaultFeedType.Community;
|
||||
|
||||
/**
|
||||
* Community handle. If remote, "community@instance.com".
|
||||
* If local, "community"
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const ODefaultFeedType = {
|
||||
Home: "home",
|
||||
All: "all",
|
||||
Local: "local",
|
||||
Moderating: "moderating",
|
||||
CommunityList: "community-list",
|
||||
Community: "community",
|
||||
} as const;
|
||||
|
||||
export type JumpButtonPositionType =
|
||||
(typeof OJumpButtonPositionType)[keyof typeof OJumpButtonPositionType];
|
||||
|
||||
export const OJumpButtonPositionType = {
|
||||
LeftTop: "left-top",
|
||||
LeftMiddle: "left-middle",
|
||||
LeftBottom: "left-bottom",
|
||||
Center: "center",
|
||||
RightTop: "right-top",
|
||||
RightMiddle: "right-middle",
|
||||
RightBottom: "right-bottom",
|
||||
} as const;
|
||||
|
||||
export type TapToCollapseType =
|
||||
(typeof OTapToCollapseType)[keyof typeof OTapToCollapseType];
|
||||
|
||||
export const OTapToCollapseType = {
|
||||
OnlyComments: "only-comments",
|
||||
OnlyHeaders: "only-headers",
|
||||
Both: "both",
|
||||
Neither: "neither",
|
||||
} as const;
|
||||
|
||||
export type AutoplayMediaType =
|
||||
(typeof OAutoplayMediaType)[keyof typeof OAutoplayMediaType];
|
||||
|
||||
export const OAutoplayMediaType = {
|
||||
WifiOnly: "wifi-only",
|
||||
Always: "always",
|
||||
Never: "never",
|
||||
} as const;
|
||||
|
||||
export type ProfileLabelType =
|
||||
(typeof OProfileLabelType)[keyof typeof OProfileLabelType];
|
||||
|
||||
export const OLongSwipeTriggerPointType = {
|
||||
Normal: "normal",
|
||||
Later: "later",
|
||||
} as const;
|
||||
|
||||
export type LongSwipeTriggerPointType =
|
||||
(typeof OLongSwipeTriggerPointType)[keyof typeof OLongSwipeTriggerPointType];
|
||||
|
||||
const OSwipeActionBase = {
|
||||
None: "none",
|
||||
Upvote: "upvote",
|
||||
Downvote: "downvote",
|
||||
Reply: "reply",
|
||||
Save: "save",
|
||||
Share: "share",
|
||||
} as const;
|
||||
|
||||
export const OSwipeActionPost = {
|
||||
...OSwipeActionBase,
|
||||
Hide: "hide",
|
||||
} as const;
|
||||
|
||||
export const OSwipeActionComment = {
|
||||
...OSwipeActionBase,
|
||||
CollapseToTop: "collapse-to-top",
|
||||
Collapse: "collapse",
|
||||
} as const;
|
||||
|
||||
export const OSwipeActionInbox = {
|
||||
...OSwipeActionBase,
|
||||
MarkUnread: "mark-unread",
|
||||
} as const;
|
||||
|
||||
export const OSwipeActionAll = {
|
||||
...OSwipeActionPost,
|
||||
...OSwipeActionComment,
|
||||
...OSwipeActionInbox,
|
||||
} as const;
|
||||
|
||||
export type SwipeAction =
|
||||
(typeof OSwipeActionAll)[keyof typeof OSwipeActionAll];
|
||||
|
||||
export type SwipeDirection = "farStart" | "start" | "end" | "farEnd";
|
||||
export type SwipeActions = Record<SwipeDirection, SwipeAction>;
|
||||
|
||||
type Provider = "redgifs";
|
||||
|
||||
type ProviderData<Name extends string, Data> = {
|
||||
name: Name;
|
||||
data: Data;
|
||||
};
|
||||
|
||||
export type RedgifsProvider = ProviderData<"redgifs", { token: string }>;
|
||||
|
||||
type ProvidersData = RedgifsProvider;
|
||||
|
||||
export type SettingValueTypes = {
|
||||
comments_theme: CommentsThemeType;
|
||||
votes_theme: VotesThemeType;
|
||||
collapse_comment_threads: CommentThreadCollapse;
|
||||
user_instance_url_display: InstanceUrlDisplayMode;
|
||||
vote_display_mode: VoteDisplayMode;
|
||||
profile_label: ProfileLabelType;
|
||||
post_appearance_type: PostAppearanceType;
|
||||
remember_post_appearance_type: boolean;
|
||||
compact_thumbnail_position_type: CompactThumbnailPositionType;
|
||||
large_show_voting_buttons: boolean;
|
||||
compact_show_voting_buttons: boolean;
|
||||
compact_thumbnail_size: CompactThumbnailSizeType;
|
||||
compact_show_self_post_thumbnails: boolean;
|
||||
blur_nsfw: PostBlurNsfwType;
|
||||
favorite_communities: string[];
|
||||
migration_links: string[];
|
||||
default_comment_sort: CommentDefaultSort;
|
||||
default_comment_sort_by_feed: CommentDefaultSort;
|
||||
disable_marking_posts_read: boolean;
|
||||
mark_read_on_scroll: boolean;
|
||||
show_hide_read_button: boolean;
|
||||
show_hidden_in_communities: boolean;
|
||||
auto_hide_read: boolean;
|
||||
disable_auto_hide_in_communities: boolean;
|
||||
gesture_swipe_post: SwipeActions;
|
||||
gesture_swipe_comment: SwipeActions;
|
||||
gesture_swipe_inbox: SwipeActions;
|
||||
disable_left_swipes: boolean;
|
||||
disable_right_swipes: boolean;
|
||||
enable_haptic_feedback: boolean;
|
||||
link_handler: LinkHandlerType;
|
||||
prefer_native_apps: boolean;
|
||||
show_jump_button: boolean;
|
||||
jump_button_position: JumpButtonPositionType;
|
||||
tap_to_collapse: TapToCollapseType;
|
||||
filtered_keywords: string[];
|
||||
filtered_websites: string[];
|
||||
highlight_new_account: boolean;
|
||||
default_feed: DefaultFeedType;
|
||||
touch_friendly_links: boolean;
|
||||
show_comment_images: boolean;
|
||||
long_swipe_trigger_point: LongSwipeTriggerPointType;
|
||||
has_presented_block_nsfw_tip: boolean;
|
||||
no_subscribed_in_feed: boolean;
|
||||
thumbnailinator_enabled: boolean;
|
||||
embed_external_media: boolean;
|
||||
always_show_author: boolean;
|
||||
always_use_reader_mode: boolean;
|
||||
infinite_scrolling: boolean;
|
||||
upvote_on_save: boolean;
|
||||
default_post_sort: SortType;
|
||||
default_post_sort_by_feed: SortType;
|
||||
remember_community_post_sort: boolean;
|
||||
remember_community_comment_sort: boolean;
|
||||
embed_crossposts: boolean;
|
||||
show_community_icons: boolean;
|
||||
community_at_top: boolean;
|
||||
autoplay_media: AutoplayMediaType;
|
||||
show_collapsed_comment: boolean;
|
||||
quick_switch_dark_mode: boolean;
|
||||
subscribed_icon: ShowSubscribedIcon;
|
||||
};
|
||||
|
||||
export interface ISettingItem<T extends keyof SettingValueTypes> {
|
||||
key: T;
|
||||
value: SettingValueTypes[T];
|
||||
user_handle: string;
|
||||
community: string;
|
||||
}
|
||||
|
||||
export const CompoundKeys = {
|
||||
postMetadata: {
|
||||
post_id_and_user_handle: "[post_id+user_handle]",
|
||||
user_handle_and_hidden: "[user_handle+hidden]",
|
||||
},
|
||||
settings: {
|
||||
key_and_user_handle_and_community: "[key+user_handle+community]",
|
||||
},
|
||||
};
|
||||
|
||||
export class WefwefDB extends Dexie {
|
||||
postMetadatas!: Table<IPostMetadata, number>;
|
||||
settings!: Table<ISettingItem<keyof SettingValueTypes>, string>;
|
||||
cachedFederatedInstanceData!: Table<InstanceData, number>;
|
||||
providers!: Table<ProvidersData, Provider>;
|
||||
|
||||
constructor() {
|
||||
super("WefwefDB");
|
||||
|
||||
/* IMPORTANT: Do not alter the version if you're changing an existing schema.
|
||||
If you want to change the schema, create a higher version and provide migration logic.
|
||||
Always assume there is a device out there with the first version of the app.
|
||||
Also please read the Dexie documentation about versioning.
|
||||
*/
|
||||
this.version(2).stores({
|
||||
postMetadatas: `
|
||||
++,
|
||||
${CompoundKeys.postMetadata.post_id_and_user_handle},
|
||||
${CompoundKeys.postMetadata.user_handle_and_hidden},
|
||||
post_id,
|
||||
user_handle,
|
||||
hidden,
|
||||
hidden_updated_at
|
||||
`,
|
||||
settings: `
|
||||
++,
|
||||
key,
|
||||
${CompoundKeys.settings.key_and_user_handle_and_community},
|
||||
value,
|
||||
user_handle,
|
||||
community
|
||||
`,
|
||||
});
|
||||
|
||||
this.version(3).upgrade(async () => {
|
||||
await this.setSetting("blur_nsfw", OPostBlurNsfw.InFeed);
|
||||
});
|
||||
|
||||
this.version(4).stores({
|
||||
postMetadatas: `
|
||||
++,
|
||||
${CompoundKeys.postMetadata.post_id_and_user_handle},
|
||||
${CompoundKeys.postMetadata.user_handle_and_hidden},
|
||||
post_id,
|
||||
user_handle,
|
||||
hidden,
|
||||
hidden_updated_at
|
||||
`,
|
||||
settings: `
|
||||
++,
|
||||
key,
|
||||
${CompoundKeys.settings.key_and_user_handle_and_community},
|
||||
value,
|
||||
user_handle,
|
||||
community
|
||||
`,
|
||||
cachedFederatedInstanceData: `
|
||||
++id,
|
||||
&domain,
|
||||
updated
|
||||
`,
|
||||
});
|
||||
|
||||
this.version(5).upgrade(async () => {
|
||||
// Upgrade comment gesture "collapse" => "collapse-to-top"
|
||||
await (async () => {
|
||||
const gestures = await this.getSetting("gesture_swipe_comment");
|
||||
|
||||
if (!gestures) return;
|
||||
|
||||
Object.entries(gestures).map(([direction, gesture]) => {
|
||||
if (!gestures) return;
|
||||
if (gesture === "collapse")
|
||||
gestures[direction as keyof typeof gestures] = "collapse-to-top";
|
||||
});
|
||||
|
||||
await this.setSetting("gesture_swipe_comment", gestures);
|
||||
})();
|
||||
|
||||
// Upgrade inbox gesture "mark_unread" => "mark-unread"
|
||||
await (async () => {
|
||||
const gestures = await this.getSetting("gesture_swipe_inbox");
|
||||
|
||||
if (!gestures) return;
|
||||
|
||||
Object.entries(gestures).map(([direction, gesture]) => {
|
||||
if (!gestures) return;
|
||||
if ((gesture as string) === "mark_unread")
|
||||
gestures[direction as keyof typeof gestures] = "mark-unread";
|
||||
});
|
||||
|
||||
await this.setSetting("gesture_swipe_inbox", gestures);
|
||||
})();
|
||||
});
|
||||
|
||||
this.version(6).upgrade(async () => {
|
||||
// Upgrade collapse comment threads "always" => "root_only"
|
||||
await (async () => {
|
||||
let default_collapse = await this.getSetting(
|
||||
"collapse_comment_threads",
|
||||
);
|
||||
|
||||
if (!default_collapse) return;
|
||||
if ((default_collapse as string) === "always")
|
||||
default_collapse = "root_only";
|
||||
|
||||
await this.setSetting("collapse_comment_threads", default_collapse);
|
||||
})();
|
||||
});
|
||||
|
||||
this.version(7).stores({
|
||||
postMetadatas: `
|
||||
++,
|
||||
${CompoundKeys.postMetadata.post_id_and_user_handle},
|
||||
${CompoundKeys.postMetadata.user_handle_and_hidden},
|
||||
post_id,
|
||||
user_handle,
|
||||
hidden,
|
||||
hidden_updated_at
|
||||
`,
|
||||
settings: `
|
||||
++,
|
||||
key,
|
||||
${CompoundKeys.settings.key_and_user_handle_and_community},
|
||||
value,
|
||||
user_handle,
|
||||
community
|
||||
`,
|
||||
cachedFederatedInstanceData: `
|
||||
++id,
|
||||
&domain,
|
||||
updated
|
||||
`,
|
||||
providers: `
|
||||
++,
|
||||
&name,
|
||||
data
|
||||
`,
|
||||
});
|
||||
|
||||
this.version(8).upgrade(async () => {
|
||||
await this.settings
|
||||
.where("key")
|
||||
.equals("remember_community_sort")
|
||||
.modify({ key: "remember_community_post_sort" });
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Providers
|
||||
*/
|
||||
async getProvider(providerName: ProvidersData["name"]) {
|
||||
return await this.providers.where("name").equals(providerName).first();
|
||||
}
|
||||
|
||||
async setProvider(payload: ProvidersData) {
|
||||
return await this.transaction("rw", this.providers, async () => {
|
||||
await this.providers.where("name").equals(payload.name).delete();
|
||||
|
||||
await this.providers.put(payload);
|
||||
});
|
||||
}
|
||||
|
||||
async resetProviders() {
|
||||
return await this.providers.clear();
|
||||
}
|
||||
|
||||
/*
|
||||
* Post Metadata
|
||||
*/
|
||||
async getPostMetadatas(post_id: number | number[], user_handle: string) {
|
||||
const post_ids = Array.isArray(post_id) ? post_id : [post_id];
|
||||
|
||||
return await this.postMetadatas
|
||||
.where(CompoundKeys.postMetadata.post_id_and_user_handle)
|
||||
.anyOf(post_ids.map((id) => [id, user_handle]))
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async upsertPostMetadata(postMetadata: IPostMetadata) {
|
||||
const { post_id, user_handle } = postMetadata;
|
||||
|
||||
await this.transaction("rw", this.postMetadatas, async () => {
|
||||
const query = this.postMetadatas
|
||||
.where(CompoundKeys.postMetadata.post_id_and_user_handle)
|
||||
.equals([post_id, user_handle]);
|
||||
|
||||
const item = await query.first();
|
||||
|
||||
if (item) {
|
||||
await query.modify(postMetadata);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.postMetadatas.add(postMetadata);
|
||||
});
|
||||
}
|
||||
|
||||
// This is a very specific method to get the hidden posts of a user in a paginated manner.
|
||||
// It's efficient when used in a feed style pagination where pages are fetched
|
||||
// one after the other. It's not efficient if you want to jump to a specific page
|
||||
// because it has to fetch all the previous pages and run a filter on them.
|
||||
async getHiddenPostMetadatasPaginated(
|
||||
user_handle: string,
|
||||
page: number,
|
||||
limit: number,
|
||||
lastPageItems?: IPostMetadata[],
|
||||
) {
|
||||
const filterFn = (metadata: IPostMetadata) =>
|
||||
metadata.user_handle === user_handle && metadata.hidden === 1;
|
||||
|
||||
if (page === 1) {
|
||||
// First page, no need to check lastPageItems. We know we're at the beginning
|
||||
return await this.postMetadatas
|
||||
.orderBy("hidden_updated_at")
|
||||
.reverse()
|
||||
.filter(filterFn)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
if (!lastPageItems) {
|
||||
// Ideally tis should never happen. It's very not efficient.
|
||||
// It runs filterFn on all of the table's items
|
||||
return await this.postMetadatas
|
||||
.orderBy("hidden_updated_at")
|
||||
.reverse()
|
||||
.filter(filterFn)
|
||||
.offset((page - 1) * limit)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
if (lastPageItems?.length < limit) {
|
||||
// We've reached the end
|
||||
return [];
|
||||
}
|
||||
|
||||
// We're in the middle of the list
|
||||
// We can use the last item of the previous page to get the next page
|
||||
|
||||
const lastPageLastEntry = lastPageItems?.[lastPageItems.length - 1];
|
||||
|
||||
return await this.postMetadatas
|
||||
.where("hidden_updated_at")
|
||||
.below(lastPageLastEntry?.hidden_updated_at)
|
||||
.reverse()
|
||||
.filter(filterFn)
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async clearHiddenPosts(user_handle: string) {
|
||||
return await this.postMetadatas
|
||||
.where("user_handle")
|
||||
.equals(user_handle)
|
||||
.delete();
|
||||
}
|
||||
|
||||
/*
|
||||
* Federated instance data
|
||||
*/
|
||||
async getCachedFederatedInstances(domain: string) {
|
||||
const INVALIDATE_AFTER_HOURS = 12;
|
||||
|
||||
const result = await this.cachedFederatedInstanceData.get({ domain });
|
||||
|
||||
// Cleanup stale
|
||||
(async () => {
|
||||
this.cachedFederatedInstanceData
|
||||
.where("updated")
|
||||
.below(subHours(new Date(), INVALIDATE_AFTER_HOURS))
|
||||
.delete();
|
||||
})();
|
||||
|
||||
if (!result) return;
|
||||
|
||||
if (differenceInHours(new Date(), result.updated) > INVALIDATE_AFTER_HOURS)
|
||||
return;
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async setCachedFederatedInstances(
|
||||
domain: string,
|
||||
federatedInstances: FederatedInstances,
|
||||
) {
|
||||
const payload: InstanceData = {
|
||||
updated: new Date(),
|
||||
domain,
|
||||
data: federatedInstances,
|
||||
};
|
||||
|
||||
await this.transaction("rw", this.cachedFederatedInstanceData, async () => {
|
||||
const query = this.cachedFederatedInstanceData
|
||||
.where("domain")
|
||||
.equals(domain);
|
||||
|
||||
const item = await query.first();
|
||||
|
||||
if (item) {
|
||||
await query.modify({ ...payload });
|
||||
return;
|
||||
}
|
||||
|
||||
await this.cachedFederatedInstanceData.add(payload);
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* Settings
|
||||
*/
|
||||
|
||||
private findSetting(key: string, user_handle: string, community: string) {
|
||||
return this.settings
|
||||
.where(CompoundKeys.settings.key_and_user_handle_and_community)
|
||||
.equals([key, user_handle, community])
|
||||
.first();
|
||||
}
|
||||
|
||||
getSetting<T extends keyof SettingValueTypes>(
|
||||
key: T,
|
||||
specificity?: {
|
||||
user_handle?: string;
|
||||
community?: string;
|
||||
},
|
||||
) {
|
||||
const { user_handle = "", community = "" } = specificity || {};
|
||||
|
||||
return this.transaction("r", this.settings, async () => {
|
||||
let setting = await this.findSetting(key, user_handle, community);
|
||||
|
||||
if (!setting && user_handle === "" && community === "") {
|
||||
// Already requested the global setting and it's not found, we can stop here
|
||||
return;
|
||||
}
|
||||
|
||||
if (!setting && user_handle !== "" && community !== "") {
|
||||
// Try to find the setting with user_handle only, community only
|
||||
setting =
|
||||
(await this.findSetting(key, user_handle, "")) ||
|
||||
(await this.findSetting(key, "", community));
|
||||
}
|
||||
|
||||
if (!setting) {
|
||||
// Try to find the global setting
|
||||
setting = await this.findSetting(key, "", "");
|
||||
}
|
||||
|
||||
if (!setting) {
|
||||
return;
|
||||
}
|
||||
|
||||
return setting.value as SettingValueTypes[T];
|
||||
});
|
||||
}
|
||||
|
||||
async setSetting<T extends keyof SettingValueTypes>(
|
||||
key: T,
|
||||
value: SettingValueTypes[T],
|
||||
specificity?: {
|
||||
/**
|
||||
* Note: user_handle can be a user handle (`aeharding@lemmy.world`)
|
||||
* or an instance handle (`lemmy.world`) when in guest mode
|
||||
*/
|
||||
user_handle?: string;
|
||||
community?: string;
|
||||
},
|
||||
) {
|
||||
const { user_handle = "", community = "" } = specificity || {};
|
||||
|
||||
this.transaction("rw", this.settings, async () => {
|
||||
const query = this.settings
|
||||
.where(CompoundKeys.settings.key_and_user_handle_and_community)
|
||||
.equals([key, user_handle, community]);
|
||||
|
||||
const item = await query.first();
|
||||
|
||||
if (item) {
|
||||
return await query.modify({ value });
|
||||
}
|
||||
|
||||
return await this.settings.add({
|
||||
key,
|
||||
value,
|
||||
user_handle,
|
||||
community,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new WefwefDB();
|
104
99_references/voyager-main/src/services/lemmy.ts
Normal file
104
99_references/voyager-main/src/services/lemmy.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { LemmyHttp } from "lemmy-js-client";
|
||||
import { reduceFileSize } from "../helpers/imageCompress";
|
||||
import { isNative, supportsWebp } from "../helpers/device";
|
||||
import nativeFetch from "./nativeFetch";
|
||||
|
||||
export function buildBaseLemmyUrl(url: string): string {
|
||||
if (import.meta.env.VITE_FORCE_LEMMY_INSECURE) {
|
||||
return `http://${url}`;
|
||||
}
|
||||
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
export function getClient(url: string, jwt?: string): LemmyHttp {
|
||||
return new LemmyHttp(buildBaseLemmyUrl(url), {
|
||||
fetchFunction: isNative() ? nativeFetch : fetch.bind(globalThis),
|
||||
headers: jwt
|
||||
? {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
["Cache-Control"]: "no-cache", // otherwise may get back cached site response (despite JWT)
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const LIMIT = 50;
|
||||
|
||||
/**
|
||||
* upload image, compressing before upload if needed
|
||||
*
|
||||
* @returns relative pictrs URL
|
||||
*/
|
||||
export async function _uploadImage(client: LemmyHttp, image: File) {
|
||||
let compressedImageIfNeeded;
|
||||
|
||||
try {
|
||||
compressedImageIfNeeded = await reduceFileSize(
|
||||
image,
|
||||
990_000, // 990 kB - Lemmy's default limit is 1MB
|
||||
1500,
|
||||
1500,
|
||||
0.85,
|
||||
);
|
||||
} catch (error) {
|
||||
compressedImageIfNeeded = image;
|
||||
console.error("Image compress failed", error);
|
||||
}
|
||||
|
||||
const response = await client.uploadImage({
|
||||
image: compressedImageIfNeeded as File,
|
||||
});
|
||||
|
||||
// lemm.ee uses response.message for error messages (e.g. account too new)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (!response.url) throw new Error(response.msg ?? (response as any).message);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
interface ImageOptions {
|
||||
/**
|
||||
* maximum image dimension
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
devicePixelRatio?: number;
|
||||
|
||||
format?: "jpg" | "png" | "webp";
|
||||
}
|
||||
|
||||
const defaultFormat = supportsWebp() ? "webp" : "jpg";
|
||||
|
||||
export function getImageSrc(url: string, options?: ImageOptions) {
|
||||
if (!options || !options.size) return url;
|
||||
|
||||
let mutableUrl;
|
||||
|
||||
try {
|
||||
mutableUrl = new URL(url);
|
||||
} catch (_) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const params = mutableUrl.searchParams;
|
||||
|
||||
if (options.size) {
|
||||
params.set(
|
||||
"thumbnail",
|
||||
`${Math.round(
|
||||
options.size * (options?.devicePixelRatio ?? window.devicePixelRatio),
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
params.set("format", options.format ?? defaultFormat);
|
||||
|
||||
return mutableUrl.toString();
|
||||
}
|
||||
|
||||
export const customBackOff = async (attempt = 0, maxRetries = 5) => {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, Math.min(attempt, maxRetries) * 4_000);
|
||||
});
|
||||
};
|
33
99_references/voyager-main/src/services/lemmyverse.ts
Normal file
33
99_references/voyager-main/src/services/lemmyverse.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Incomplete
|
||||
export interface LVInstance {
|
||||
baseurl: string;
|
||||
url: string;
|
||||
name: string;
|
||||
desc: string;
|
||||
downvotes: boolean;
|
||||
nsfw: boolean;
|
||||
create_admin: boolean;
|
||||
private: boolean;
|
||||
fed: boolean;
|
||||
version: string;
|
||||
open: boolean;
|
||||
langs: string[];
|
||||
date: string;
|
||||
published: number;
|
||||
time: number;
|
||||
score: number;
|
||||
tags: string[];
|
||||
icon?: string;
|
||||
banner?: string;
|
||||
trust: {
|
||||
score: number;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFullList(): Promise<LVInstance[]> {
|
||||
const data = await fetch(
|
||||
"https://data.lemmyverse.net/data/instance.full.json",
|
||||
);
|
||||
|
||||
return await data.json();
|
||||
}
|
136
99_references/voyager-main/src/services/nativeFetch.ts
Normal file
136
99_references/voyager-main/src/services/nativeFetch.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { CapacitorHttp } from "@capacitor/core";
|
||||
import { CapFormDataEntry } from "@capacitor/core/types/definitions-internal";
|
||||
|
||||
// Stolen from capacitor fetch shim
|
||||
// https://github.com/ionic-team/capacitor/blob/5.2.3/core/native-bridge.ts
|
||||
|
||||
export const webviewServerUrl =
|
||||
"WEBVIEW_SERVER_URL" in window &&
|
||||
typeof window.WEBVIEW_SERVER_URL === "string"
|
||||
? window.WEBVIEW_SERVER_URL
|
||||
: "";
|
||||
|
||||
export default async function nativeFetch(
|
||||
resource: RequestInfo | URL,
|
||||
options?: RequestInit,
|
||||
) {
|
||||
if (resource.toString().startsWith(`${webviewServerUrl}/`)) {
|
||||
return window.fetch(resource, options);
|
||||
}
|
||||
|
||||
try {
|
||||
// intercept request & pass to the bridge
|
||||
const {
|
||||
data: requestData,
|
||||
type,
|
||||
headers,
|
||||
} = await convertBody(options?.body || undefined);
|
||||
let optionHeaders = options?.headers;
|
||||
if (options?.headers instanceof Headers) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
optionHeaders = Object.fromEntries((options.headers as any).entries());
|
||||
}
|
||||
const nativeResponse = await CapacitorHttp.request({
|
||||
url: resource as never,
|
||||
method: options?.method ? options.method : undefined,
|
||||
data: requestData,
|
||||
dataType: type,
|
||||
headers: {
|
||||
...headers,
|
||||
...optionHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
const contentType =
|
||||
nativeResponse.headers["Content-Type"] ||
|
||||
nativeResponse.headers["content-type"];
|
||||
let data = contentType?.startsWith("application/json")
|
||||
? JSON.stringify(nativeResponse.data)
|
||||
: nativeResponse.data;
|
||||
|
||||
// use null data for 204 No Content HTTP response
|
||||
if (nativeResponse.status === 204) {
|
||||
data = null;
|
||||
}
|
||||
|
||||
// intercept & parse response before returning
|
||||
const response = new Response(data, {
|
||||
headers: nativeResponse.headers,
|
||||
status: nativeResponse.status,
|
||||
});
|
||||
|
||||
/*
|
||||
* copy url to response, `cordova-plugin-ionic` uses this url from the response
|
||||
* we need `Object.defineProperty` because url is an inherited getter on the Response
|
||||
* see: https://stackoverflow.com/a/57382543
|
||||
* */
|
||||
Object.defineProperty(response, "url", {
|
||||
value: nativeResponse.url,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
const readFileAsBase64 = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const data = reader.result as string;
|
||||
resolve(btoa(data));
|
||||
};
|
||||
reader.onerror = reject;
|
||||
|
||||
reader.readAsBinaryString(file);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const convertFormData = async (formData: FormData): Promise<any> => {
|
||||
const newFormData: CapFormDataEntry[] = [];
|
||||
for (const pair of formData.entries()) {
|
||||
const [key, value] = pair;
|
||||
if (value instanceof File) {
|
||||
const base64File = await readFileAsBase64(value);
|
||||
newFormData.push({
|
||||
key,
|
||||
value: base64File,
|
||||
type: "base64File",
|
||||
contentType: value.type,
|
||||
fileName: value.name,
|
||||
});
|
||||
} else {
|
||||
newFormData.push({ key, value, type: "string" });
|
||||
}
|
||||
}
|
||||
|
||||
return newFormData;
|
||||
};
|
||||
|
||||
const convertBody = async (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
body: Document | XMLHttpRequestBodyInit | ReadableStream<any> | undefined,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): Promise<any> => {
|
||||
if (body instanceof FormData) {
|
||||
const formData = await convertFormData(body);
|
||||
const boundary = `${Date.now()}`;
|
||||
return {
|
||||
data: formData,
|
||||
type: "formData",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=--${boundary}`,
|
||||
},
|
||||
};
|
||||
} else if (body instanceof File) {
|
||||
const fileData = await readFileAsBase64(body);
|
||||
return {
|
||||
data: fileData,
|
||||
type: "file",
|
||||
headers: { "Content-Type": body.type },
|
||||
};
|
||||
}
|
||||
|
||||
return { data: body, type: "json" };
|
||||
};
|
41
99_references/voyager-main/src/services/redgifs.ts
Normal file
41
99_references/voyager-main/src/services/redgifs.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { CapacitorHttp } from "@capacitor/core";
|
||||
|
||||
// https://github.com/Redgifs/api/wiki/Temporary-tokens
|
||||
|
||||
interface TemporaryTokenResponse {
|
||||
token?: string;
|
||||
addr: string;
|
||||
agent: string;
|
||||
rtfm: string;
|
||||
}
|
||||
|
||||
const BASE_URL = "https://api.redgifs.com";
|
||||
const HEADERS = {
|
||||
["User-Agent"]: navigator.userAgent,
|
||||
} as const;
|
||||
|
||||
export async function getTemporaryToken(): Promise<string> {
|
||||
const result = await CapacitorHttp.get({
|
||||
url: `${BASE_URL}/v2/auth/temporary`,
|
||||
headers: HEADERS,
|
||||
});
|
||||
|
||||
const response: TemporaryTokenResponse = result.data;
|
||||
|
||||
if (typeof response?.token !== "string")
|
||||
throw new Error("Failed to get temporary redgifs token");
|
||||
|
||||
return response.token;
|
||||
}
|
||||
|
||||
export async function getGif(id: string, token: string): Promise<string> {
|
||||
const result = await CapacitorHttp.get({
|
||||
url: `${BASE_URL}/v2/gifs/${id.toLowerCase()}`,
|
||||
headers: {
|
||||
...HEADERS,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
return result.data.gif.urls.hd;
|
||||
}
|
Reference in New Issue
Block a user