mirror of
https://github.com/Sevichecc/raycast-akkoma-extension.git
synced 2025-04-30 14:49:29 +08:00
refactor: add utils folder
This commit is contained in:
parent
d5860b63a0
commit
54a6188bc5
9 changed files with 164 additions and 71 deletions
16
package.json
16
package.json
|
@ -37,6 +37,22 @@
|
||||||
"placeholder": "default value : 20"
|
"placeholder": "default value : 20"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "my-status",
|
||||||
|
"title": "View My Status",
|
||||||
|
"description": "View your statuses",
|
||||||
|
"mode": "view",
|
||||||
|
"preferences": [
|
||||||
|
{
|
||||||
|
"name": "statusLimit",
|
||||||
|
"type": "textfield",
|
||||||
|
"required": false,
|
||||||
|
"title": "Maximum number of statuses",
|
||||||
|
"description": "Maximum number of statuses to be show",
|
||||||
|
"placeholder": "default value : 20"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"preferences": [
|
"preferences": [
|
||||||
|
|
|
@ -1,22 +1,16 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Action, ActionPanel, List, Toast, showToast, Cache } from "@raycast/api";
|
import { Action, ActionPanel, List, Toast, showToast, Cache } from "@raycast/api";
|
||||||
import { BookmarkedStatus, AkkomaError } from "./utils/types";
|
|
||||||
import { NodeHtmlMarkdown } from "node-html-markdown";
|
import { Status, AkkomaError } from "./utils/types";
|
||||||
import apiServer from "./utils/api";
|
import apiServer from "./utils/api";
|
||||||
import { authorize } from "./utils/oauth";
|
import { authorize } from "./utils/oauth";
|
||||||
|
import { statusParser } from "./utils/util";
|
||||||
|
|
||||||
const cache = new Cache();
|
const cache = new Cache();
|
||||||
const nhm = new NodeHtmlMarkdown();
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat("default", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function BookmarkCommand() {
|
export default function BookmarkCommand() {
|
||||||
const cached = cache.get("latest_bookmarks");
|
const cached = cache.get("latest_bookmarks");
|
||||||
const [bookmarks, setBookmarks] = useState<BookmarkedStatus[]>(cached ? JSON.parse(cached) : []);
|
const [bookmarks, setBookmarks] = useState<Status[]>(cached ? JSON.parse(cached) : []);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -25,7 +19,7 @@ export default function BookmarkCommand() {
|
||||||
await authorize();
|
await authorize();
|
||||||
showToast(Toast.Style.Animated, "Loading bookmarks...");
|
showToast(Toast.Style.Animated, "Loading bookmarks...");
|
||||||
const newBookmarks = await apiServer.fetchBookmarks();
|
const newBookmarks = await apiServer.fetchBookmarks();
|
||||||
setBookmarks(newBookmarks);
|
setBookmarks((prevBookmarks) => [...prevBookmarks, ...newBookmarks]);
|
||||||
showToast(Toast.Style.Success, "Bookmarked has been loaded");
|
showToast(Toast.Style.Success, "Bookmarked has been loaded");
|
||||||
cache.set("latest_bookmarks", JSON.stringify(newBookmarks));
|
cache.set("latest_bookmarks", JSON.stringify(newBookmarks));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -38,23 +32,13 @@ export default function BookmarkCommand() {
|
||||||
getBookmark();
|
getBookmark();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const parseStatus = ({ content, media_attachments, account, created_at }: BookmarkedStatus) => {
|
|
||||||
const images = media_attachments.filter((attachment) => attachment.type === "image");
|
|
||||||
const parsedImages = images.reduce((link, image) => link + ``, "");
|
|
||||||
|
|
||||||
const date = new Date(created_at);
|
|
||||||
const parsedTime = dateTimeFormatter.format(date);
|
|
||||||
|
|
||||||
return ` _@${account.acct} (${parsedTime})_ ` + nhm.translate("<br>" + content) + parsedImages;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List isShowingDetail isLoading={isLoading} searchBarPlaceholder="Search bookmarks">
|
<List isShowingDetail isLoading={isLoading} searchBarPlaceholder="Search bookmarks">
|
||||||
{bookmarks?.map((bookmark) => (
|
{bookmarks?.map((bookmark) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
title={bookmark.pleroma.content["text/plain"]}
|
title={bookmark.pleroma.content["text/plain"]}
|
||||||
key={bookmark.id}
|
key={bookmark.id}
|
||||||
detail={<List.Item.Detail markdown={parseStatus(bookmark)} />}
|
detail={<List.Item.Detail markdown={statusParser(bookmark)} />}
|
||||||
actions={
|
actions={
|
||||||
<ActionPanel>
|
<ActionPanel>
|
||||||
<Action.OpenInBrowser title="Open Original Status" url={bookmark.url} />
|
<Action.OpenInBrowser title="Open Original Status" url={bookmark.url} />
|
||||||
|
|
|
@ -1,30 +1,28 @@
|
||||||
import { getPreferenceValues, Color, Icon, Form } from "@raycast/api";
|
import { getPreferenceValues, Color, Icon, Form } from "@raycast/api";
|
||||||
import { Preference, VisibilityOption } from "../types";
|
import { Preference, VisibilityOption } from "../utils/types";
|
||||||
|
|
||||||
|
const visibilityOptions: VisibilityOption[] = [
|
||||||
|
{ value: "public", title: "Public", icon: Icon.Livestream },
|
||||||
|
{ value: "unlisted", title: "Unlisted", icon: Icon.LivestreamDisabled },
|
||||||
|
{ value: "private", title: "Followers-only", icon: Icon.TwoPeople },
|
||||||
|
{ value: "direct", title: "Direct", icon: Icon.Envelope },
|
||||||
|
{ value: "local", title: "Local-only", icon: Icon.Pin },
|
||||||
|
];
|
||||||
|
|
||||||
const VisibilityDropdown = () => {
|
const VisibilityDropdown = () => {
|
||||||
const { defaultVisibility }: Preference = getPreferenceValues();
|
const { defaultVisibility }: Preference = getPreferenceValues();
|
||||||
|
|
||||||
const visibilityOptions: VisibilityOption[] = [
|
|
||||||
{ value: "public", title: "Public", icon: Icon.Livestream },
|
|
||||||
{ value: "unlisted", title: "Unlisted", icon: Icon.LivestreamDisabled },
|
|
||||||
{ value: "private", title: "Followers-only", icon: Icon.TwoPeople },
|
|
||||||
{ value: "direct", title: "Direct", icon: Icon.Envelope },
|
|
||||||
{ value: "local", title: "Local-only", icon: Icon.Pin },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Form.Dropdown id="visibility" title="Visibility" storeValue={true} defaultValue={defaultVisibility}>
|
||||||
<Form.Dropdown id="visibility" title="Visibility" storeValue={true} defaultValue={defaultVisibility}>
|
{visibilityOptions.map(({ value, title, icon }) => (
|
||||||
{visibilityOptions.map(({ value, title, icon }) => (
|
<Form.Dropdown.Item
|
||||||
<Form.Dropdown.Item
|
key={value}
|
||||||
key={value}
|
value={value}
|
||||||
value={value}
|
title={title}
|
||||||
title={title}
|
icon={{ source: icon, tintColor: Color.SecondaryText }}
|
||||||
icon={{ source: icon, tintColor: Color.SecondaryText }}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</Form.Dropdown>
|
||||||
</Form.Dropdown>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
51
src/my-status.tsx
Normal file
51
src/my-status.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Action, ActionPanel, List, Toast, showToast, Cache } from "@raycast/api";
|
||||||
|
import { Status, AkkomaError } from "./utils/types";
|
||||||
|
|
||||||
|
import { authorize } from "./utils/oauth";
|
||||||
|
import apiServer from "./utils/api";
|
||||||
|
import { statusParser } from "./utils/util";
|
||||||
|
|
||||||
|
const cache = new Cache();
|
||||||
|
|
||||||
|
export default function ViewStatusCommand() {
|
||||||
|
const cached = cache.get("latest_statuses");
|
||||||
|
const [status, setStatus] = useState<Status[]>(cached ? JSON.parse(cached) : []);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getBookmark = async () => {
|
||||||
|
try {
|
||||||
|
await authorize();
|
||||||
|
showToast(Toast.Style.Animated, "Loading Status...");
|
||||||
|
const status = await apiServer.fetchUserStatus();
|
||||||
|
setStatus(status);
|
||||||
|
showToast(Toast.Style.Success, "Statuses has been loaded");
|
||||||
|
cache.set("latest_statuses", JSON.stringify(status));
|
||||||
|
} catch (error) {
|
||||||
|
const requestErr = error as AkkomaError;
|
||||||
|
showToast(Toast.Style.Failure, "Error", requestErr.message);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getBookmark();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List isShowingDetail isLoading={isLoading} searchBarPlaceholder="Search bookmarks">
|
||||||
|
{status?.map((statu) => (
|
||||||
|
<List.Item
|
||||||
|
title={statu.pleroma.content["text/plain"]}
|
||||||
|
key={statu.id}
|
||||||
|
detail={<List.Item.Detail markdown={statusParser(statu)} />}
|
||||||
|
actions={
|
||||||
|
<ActionPanel>
|
||||||
|
<Action.OpenInBrowser title="Open Original Status" url={statu.url} />
|
||||||
|
</ActionPanel>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,35 +13,25 @@ import {
|
||||||
LocalStorage,
|
LocalStorage,
|
||||||
} from "@raycast/api";
|
} from "@raycast/api";
|
||||||
import apiServer from "./utils/api";
|
import apiServer from "./utils/api";
|
||||||
import { AkkomaError, StatusResponse, Preference, Status } from "./utils/types";
|
import { AkkomaError, StatusResponse, Preference, StatusRequest } from "./utils/types";
|
||||||
import { authorize } from "./utils/oauth";
|
import { authorize } from "./utils/oauth";
|
||||||
|
import { dateTimeFormatter } from "./utils/util";
|
||||||
|
|
||||||
import VisibilityDropdown from "./components/VisibilityDropdown";
|
import VisibilityDropdown from "./components/VisibilityDropdown";
|
||||||
|
|
||||||
const cache = new Cache();
|
const cache = new Cache();
|
||||||
|
const { instance } = getPreferenceValues<Preference>();
|
||||||
|
|
||||||
interface CommandProps extends LaunchProps<{ draftValues: Partial<Status> }> {
|
interface CommandProps extends LaunchProps<{ draftValues: Partial<StatusRequest> }> {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatusForm extends Status {
|
interface StatusForm extends StatusRequest {
|
||||||
files: string[];
|
files: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const labelText = (time: Date) => {
|
|
||||||
return new Intl.DateTimeFormat("default", {
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
weekday: "long",
|
|
||||||
dayPeriod: "narrow",
|
|
||||||
}).format(time);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SimpleCommand(props: CommandProps) {
|
export default function SimpleCommand(props: CommandProps) {
|
||||||
const { instance } = getPreferenceValues<Preference>();
|
|
||||||
const { draftValues } = props;
|
const { draftValues } = props;
|
||||||
|
|
||||||
const [state, setState] = useState({
|
const [state, setState] = useState({
|
||||||
|
@ -86,7 +76,7 @@ export default function SimpleCommand(props: CommandProps) {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const newStatus: Partial<Status> = {
|
const newStatus: Partial<StatusRequest> = {
|
||||||
...value,
|
...value,
|
||||||
content_type: state.isMarkdown ? "text/markdown" : "text/plain",
|
content_type: state.isMarkdown ? "text/markdown" : "text/plain",
|
||||||
media_ids: mediaIds,
|
media_ids: mediaIds,
|
||||||
|
@ -96,7 +86,7 @@ export default function SimpleCommand(props: CommandProps) {
|
||||||
const response = await apiServer.postNewStatus(newStatus);
|
const response = await apiServer.postNewStatus(newStatus);
|
||||||
|
|
||||||
value.scheduled_at
|
value.scheduled_at
|
||||||
? showToast(Toast.Style.Success, "Scheduled", labelText(value.scheduled_at))
|
? showToast(Toast.Style.Success, "Scheduled", dateTimeFormatter(value.scheduled_at, "long"))
|
||||||
: showToast(Toast.Style.Success, "Status has been published (≧∇≦)/ ! ");
|
: showToast(Toast.Style.Success, "Status has been published (≧∇≦)/ ! ");
|
||||||
|
|
||||||
setStatusInfo(response);
|
setStatusInfo(response);
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { Form, LaunchProps } from "@raycast/api";
|
import { Form, LaunchProps } from "@raycast/api";
|
||||||
import { Status } from "./utils/types";
|
import { StatusRequest } from "./utils/types";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import VisibilityDropdown from "./components/VisibilityDropdown";
|
import VisibilityDropdown from "./components/VisibilityDropdown";
|
||||||
import SimpleCommand from "./simple-status";
|
import SimpleCommand from "./simple-status";
|
||||||
|
|
||||||
export default function DetailCommand(props: LaunchProps<{ draftValues: Partial<Status> }>) {
|
export default function DetailCommand(props: LaunchProps<{ draftValues: Partial<StatusRequest> }>) {
|
||||||
const [files, setFiles] = useState<string[]>([]);
|
const [files, setFiles] = useState<string[]>([]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleCommand {...props}>
|
<SimpleCommand {...props}>
|
||||||
<Form.FilePicker id="files" value={files} onChange={setFiles} title="Attechments"/>
|
<Form.FilePicker id="files" value={files} onChange={setFiles} title="Attechments" />
|
||||||
{files.length === 1 && <Form.TextArea id="description" title="Alt text" />}
|
{files.length === 1 && <Form.TextArea id="description" title="Alt text" />}
|
||||||
<Form.DatePicker id="scheduled_at" title="Scheduled Time" />
|
<Form.DatePicker id="scheduled_at" title="Scheduled Time" />
|
||||||
<VisibilityDropdown />
|
<VisibilityDropdown />
|
||||||
|
|
|
@ -5,12 +5,12 @@ import { OAuth, getPreferenceValues } from "@raycast/api";
|
||||||
import {
|
import {
|
||||||
Credentials,
|
Credentials,
|
||||||
Preference,
|
Preference,
|
||||||
Status,
|
StatusRequest,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
Account,
|
Account,
|
||||||
StatusAttachment,
|
StatusAttachment,
|
||||||
UploadAttachResponse,
|
UploadAttachResponse,
|
||||||
BookmarkedStatus,
|
Status,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { client } from "./oauth";
|
import { client } from "./oauth";
|
||||||
import { RequestInit, Response } from "node-fetch";
|
import { RequestInit, Response } from "node-fetch";
|
||||||
|
@ -21,6 +21,7 @@ const CONFIG = {
|
||||||
tokenUrl: "/oauth/token",
|
tokenUrl: "/oauth/token",
|
||||||
appUrl: "/api/v1/apps",
|
appUrl: "/api/v1/apps",
|
||||||
statusesUrl: "/api/v1/statuses",
|
statusesUrl: "/api/v1/statuses",
|
||||||
|
accountsUrl: "/api/v1/accounts",
|
||||||
verifyCredentialsUrl: "/api/v1/accounts/verify_credentials",
|
verifyCredentialsUrl: "/api/v1/accounts/verify_credentials",
|
||||||
mediaUrl: "/api/v1/media/",
|
mediaUrl: "/api/v1/media/",
|
||||||
bookmarkUrl: "/api/v1/bookmarks",
|
bookmarkUrl: "/api/v1/bookmarks",
|
||||||
|
@ -64,7 +65,7 @@ const createApp = async (): Promise<Credentials> => {
|
||||||
return (await response.json()) as Credentials;
|
return (await response.json()) as Credentials;
|
||||||
};
|
};
|
||||||
|
|
||||||
const postNewStatus = async (statusOptions: Partial<Status>): Promise<StatusResponse> => {
|
const postNewStatus = async (statusOptions: Partial<StatusRequest>): Promise<StatusResponse> => {
|
||||||
const response = await fetchWithAuth(apiUrl(instance, CONFIG.statusesUrl), {
|
const response = await fetchWithAuth(apiUrl(instance, CONFIG.statusesUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
@ -101,14 +102,32 @@ const uploadAttachment = async ({ file, description }: StatusAttachment): Promis
|
||||||
return (await response.json()) as UploadAttachResponse;
|
return (await response.json()) as UploadAttachResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchBookmarks = async (): Promise<BookmarkedStatus[]> => {
|
const fetchBookmarks = async (): Promise<Status[]> => {
|
||||||
const { bookmarkLimit } = getPreferenceValues<Preference>();
|
const { bookmarkLimit } = getPreferenceValues<Preference>();
|
||||||
const url = bookmarkLimit ? CONFIG.bookmarkUrl + `?&limit=${bookmarkLimit}` : CONFIG.bookmarkUrl;
|
const url = bookmarkLimit ? CONFIG.bookmarkUrl + `?&limit=${bookmarkLimit}` : CONFIG.bookmarkUrl;
|
||||||
|
|
||||||
const response = await fetchWithAuth(apiUrl(instance, url));
|
const response = await fetchWithAuth(apiUrl(instance, url));
|
||||||
if (!response.ok) throw new Error("Could not fetch bookmarks");
|
if (!response.ok) throw new Error("Could not fetch bookmarks");
|
||||||
|
|
||||||
return (await response.json()) as BookmarkedStatus[];
|
return (await response.json()) as Status[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default { fetchToken, createApp, postNewStatus, fetchAccountInfo, uploadAttachment, fetchBookmarks };
|
const fetchUserStatus = async (): Promise<Status[]> => {
|
||||||
|
const { id } = await fetchAccountInfo();
|
||||||
|
const url = CONFIG.accountsUrl + id + "/statuses?exclude_replies=false&with_muted=true";
|
||||||
|
|
||||||
|
const response = await fetchWithAuth(apiUrl(instance, url));
|
||||||
|
if (!response.ok) throw new Error("Could not fetch user's status");
|
||||||
|
|
||||||
|
return (await response.json()) as Status[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetchToken,
|
||||||
|
createApp,
|
||||||
|
postNewStatus,
|
||||||
|
fetchAccountInfo,
|
||||||
|
uploadAttachment,
|
||||||
|
fetchBookmarks,
|
||||||
|
fetchUserStatus,
|
||||||
|
};
|
||||||
|
|
|
@ -44,7 +44,7 @@ interface Poll {
|
||||||
options: string[];
|
options: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Status {
|
export interface StatusRequest {
|
||||||
spoiler_text?: string;
|
spoiler_text?: string;
|
||||||
status: string;
|
status: string;
|
||||||
content_type: string;
|
content_type: string;
|
||||||
|
@ -61,7 +61,7 @@ export interface Status {
|
||||||
to?: string[];
|
to?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookmarkedStatus {
|
export interface Status {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
media_attachments: UploadAttachResponse[];
|
media_attachments: UploadAttachResponse[];
|
||||||
account: {
|
account: {
|
||||||
|
@ -98,6 +98,7 @@ export interface Account {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
fqn: string;
|
fqn: string;
|
||||||
avatar_static: string;
|
avatar_static: string;
|
||||||
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attachments
|
// Attachments
|
||||||
|
|
34
src/utils/util.ts
Normal file
34
src/utils/util.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { Status } from "./types";
|
||||||
|
import { NodeHtmlMarkdown } from "node-html-markdown";
|
||||||
|
const nhm = new NodeHtmlMarkdown();
|
||||||
|
|
||||||
|
export const dateTimeFormatter = (time: Date, type: "short" | "long") => {
|
||||||
|
const options: Intl.DateTimeFormatOptions = {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
};
|
||||||
|
|
||||||
|
return type === "short"
|
||||||
|
? new Intl.DateTimeFormat("default", {
|
||||||
|
...options,
|
||||||
|
}).format(time)
|
||||||
|
: new Intl.DateTimeFormat("default", {
|
||||||
|
...options,
|
||||||
|
weekday: "long",
|
||||||
|
dayPeriod: "narrow",
|
||||||
|
}).format(time);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusParser = ({ content, media_attachments, account, created_at }: Status) => {
|
||||||
|
const images = media_attachments.filter((attachment) => attachment.type === "image");
|
||||||
|
const parsedImages = images.reduce((link, image) => link + ``, "");
|
||||||
|
|
||||||
|
const date = new Date(created_at);
|
||||||
|
const parsedTime = dateTimeFormatter(date, "short");
|
||||||
|
|
||||||
|
if (account) return ` _@${account.acct} (${parsedTime})_ ` + nhm.translate("<br>" + content) + parsedImages;
|
||||||
|
|
||||||
|
return parsedTime + nhm.translate("<br>" + content) + parsedImages;
|
||||||
|
};
|
Loading…
Reference in a new issue