init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View 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(",");
}

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

View 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);
});
};

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

View 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" };
};

View 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;
}