Files
HKSingleParty/99_references/voyager-main/src/services/db.ts
2025-05-28 09:55:51 +08:00

777 lines
20 KiB
TypeScript

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();