diff --git a/src/api.ts b/src/api.ts index bc448f7..8fcb112 100644 --- a/src/api.ts +++ b/src/api.ts @@ -12,11 +12,31 @@ import { UploadAttachResponse, } from "./types"; import { client } from "./oauth"; +import { RequestInit, Response } from "node-fetch"; -export const fetchToken = async (params: URLSearchParams, errorMessage: string): Promise => { - const { instance } = getPreferenceValues(); +const { instance } = getPreferenceValues(); - const response = await fetch(`https://${instance}/oauth/token`, { +const CONFIG = { + tokenUrl: "/oauth/token", + appUrl: "/api/v1/apps", + statusesUrl: "/api/v1/statuses", + verifyCredentialsUrl: "/api/v1/accounts/verify_credentials", + mediaUrl: "/api/v1/media/", +}; + +const apiUrl = (instance: string, path: string): string => `https://${instance}${path}`; + +const fetchWithAuth = async (url: string, options: RequestInit = {}): Promise => { + const tokenSet = await client.getTokens(); + const headers = { + ...options.headers, + Authorization: `Bearer ${tokenSet?.accessToken}`, + }; + return fetch(url, { ...options, headers }); +}; + +const fetchToken = async (params: URLSearchParams, errorMessage: string): Promise => { + const response = await fetch(apiUrl(instance, CONFIG.tokenUrl), { method: "POST", body: params, }); @@ -25,14 +45,10 @@ export const fetchToken = async (params: URLSearchParams, errorMessage: string): return (await response.json()) as OAuth.TokenResponse; }; -export const createApp = async (): Promise => { - const { instance } = getPreferenceValues(); - - const response = await fetch(`https://${instance}/api/v1/apps`, { +const createApp = async (): Promise => { + const response = await fetch(apiUrl(instance, CONFIG.appUrl), { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ client_name: "raycast-akkoma-extension", redirect_uris: "https://raycast.com/redirect?packageName=Extension", @@ -46,77 +62,43 @@ export const createApp = async (): Promise => { return (await response.json()) as Credentials; }; -export const postNewStatus = async ({ - status, - visibility, - spoiler_text, - sensitive, - scheduled_at, - content_type, - media_ids, -}: Partial): Promise => { - const { instance } = getPreferenceValues(); - const tokenSet = await client.getTokens(); - - const response = await fetch(`https://${instance}/api/v1/statuses`, { +const postNewStatus = async (statusOptions: Partial): Promise => { + const response = await fetchWithAuth(apiUrl(instance, CONFIG.statusesUrl), { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${tokenSet?.accessToken}`, - }, - body: JSON.stringify({ - status, - visibility, - spoiler_text, - sensitive, - content_type, - scheduled_at, - media_ids, - }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(statusOptions), }); - if (!response.ok) throw new Error("Failed to pulish :("); + if (!response.ok) throw new Error("Failed to publish :("); return (await response.json()) as StatusResponse; }; -export const fetchAccountInfo = async (): Promise => { - const { instance } = getPreferenceValues(); - const tokenSet = await client.getTokens(); - - const response = await fetch(`https://${instance}/api/v1/accounts/verify_credentials`, { +const fetchAccountInfo = async (): Promise => { + const response = await fetchWithAuth(apiUrl(instance, CONFIG.verifyCredentialsUrl), { method: "GET", - headers: { - Authorization: `Bearer ${tokenSet?.accessToken}`, - }, }); if (!response.ok) throw new Error("Failed to fetch account's info :("); - return (await response.json()) as Account; }; -export const uploadAttachment = async ({ file, description }: StatusAttachment): Promise => { - const { instance } = getPreferenceValues(); - const tokenSet = await client.getTokens(); - +const uploadAttachment = async ({ file, description }: StatusAttachment): Promise => { const attachment = fs.readFileSync(file); const attachmentData = new File([attachment], file); await attachmentData.arrayBuffer(); - + const formData = new FormData(); formData.append("file", attachmentData); formData.append("description", description ?? ""); - const response = await fetch(`https://${instance}/api/v1/media/`, { - method: "POST", - headers: { - Authorization: `Bearer ${tokenSet?.accessToken}`, - }, + const response = await fetchWithAuth(apiUrl(instance, CONFIG.mediaUrl), { + method: "POST", body: formData, }); - if (!response.ok) throw new Error("Could not upload attechments"); - + if (!response.ok) throw new Error("Could not upload attachments"); return (await response.json()) as UploadAttachResponse; }; + +export default { fetchToken, createApp, postNewStatus, fetchAccountInfo, uploadAttachment }; diff --git a/src/detail-status.tsx b/src/detail-status.tsx index 048ff27..c72cae0 100644 --- a/src/detail-status.tsx +++ b/src/detail-status.tsx @@ -4,16 +4,12 @@ import SimpleCommand from "./simple-status"; import { Status } from "./types"; import { useState } from "react"; -interface CommandProps extends LaunchProps<{ draftValues: Status }> { - children?: React.ReactNode; -} - -export default function DetailCommand(props: CommandProps) { +export default function DetailCommand(props: LaunchProps<{ draftValues: Partial }>) { const [files, setFiles] = useState([]); return ( - + {files.length !== 0 && } diff --git a/src/oauth.ts b/src/oauth.ts index a948fb8..f316cab 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,6 +1,6 @@ import { LocalStorage, OAuth, getPreferenceValues } from "@raycast/api"; import { Preference } from "./types"; -import { fetchToken, createApp, fetchAccountInfo } from "./api"; +import apiServer from "./api"; export const client = new OAuth.PKCEClient({ redirectMethod: OAuth.RedirectMethod.Web, @@ -10,37 +10,33 @@ export const client = new OAuth.PKCEClient({ description: "Connect to your Akkoma / Pleroma acount", }); -const requestAccessToken = async ( +const requestToken = async ( clientId: string, clientSecret: string, - authRequest: OAuth.AuthorizationRequest, - authCode: string + grantType: string, + authRequest?: OAuth.AuthorizationRequest, + authCode?: string, + refreshToken?: string ): Promise => { const params = new URLSearchParams(); params.append("client_id", clientId); params.append("client_secret", clientSecret); - params.append("code", authCode); - params.append("code_verifier", authRequest.codeVerifier); - params.append("grant_type", "authorization_code"); - params.append("redirect_uri", authRequest.redirectURI); + params.append("grant_type", grantType); - return await fetchToken(params, "fetch tokens error:"); -}; + if (grantType === "authorization_code") { + params.append("code", authCode!); + params.append("code_verifier", authRequest!.codeVerifier); + params.append("redirect_uri", authRequest!.redirectURI); + } else { + params.append("refresh_token", refreshToken!); + } -const refreshToken = async ( - clientId: string, - clientSecret: string, - refreshToken: string -): Promise => { - const params = new URLSearchParams(); - params.append("client_id", clientId); - params.append("client_secret", clientSecret); - params.append("refresh_token", refreshToken); - params.append("grant_type", "refresh_token"); + const tokenResponse = await apiServer.fetchToken(params, `Error while requesting ${grantType} tokens:`); - const tokenResponse = await fetchToken(params, "refresh tokens error:"); + if (grantType === "refresh_token") { + tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken; + } - tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken; return tokenResponse; }; @@ -51,13 +47,15 @@ export const authorize = async (): Promise => { if (tokenSet?.accessToken) { if (tokenSet.refreshToken && tokenSet.isExpired()) { LocalStorage.clear(); - const { client_id, client_secret } = await createApp(); - await client.setTokens(await refreshToken(client_id, client_secret, tokenSet.refreshToken)); + const { client_id, client_secret } = await apiServer.createApp(); + await client.setTokens( + await requestToken(client_id, client_secret, "refresh_token", undefined, undefined, tokenSet.refreshToken) + ); } return; } - const { client_id, client_secret } = await createApp(); + const { client_id, client_secret } = await apiServer.createApp(); const authRequest = await client.authorizationRequest({ endpoint: `https://${instance}/oauth/authorize`, clientId: client_id, @@ -65,9 +63,13 @@ export const authorize = async (): Promise => { }); const { authorizationCode } = await client.authorize(authRequest); - await client.setTokens(await requestAccessToken(client_id, client_secret, authRequest, authorizationCode)); + await client.setTokens( + await requestToken(client_id, client_secret, "authorization_code", authRequest, authorizationCode) + ); - const { fqn, avatar_static } = await fetchAccountInfo(); + const { fqn, avatar_static } = await apiServer.fetchAccountInfo(); await LocalStorage.setItem("account-fqn", fqn); await LocalStorage.setItem("account-avator", avatar_static); }; + +export default { authorize }; diff --git a/src/simple-status.tsx b/src/simple-status.tsx index a2229ed..f9d835b 100644 --- a/src/simple-status.tsx +++ b/src/simple-status.tsx @@ -13,8 +13,10 @@ import { LaunchProps, } from "@raycast/api"; -import { postNewStatus, uploadAttachment } from "./api"; +import apiServer from "./api"; + import { AkkomaError, StatusResponse, Preference, Status } from "./types"; + import { authorize } from "./oauth"; import VisibilityDropdown from "./components/VisibilityDropdown"; @@ -22,13 +24,11 @@ import StatusContent from "./components/StatusContent"; const cache = new Cache(); -type SimpleStatus = Pick; - -interface CommandProps extends LaunchProps<{ draftValues: SimpleStatus }> { +interface CommandProps extends LaunchProps<{ draftValues: Partial }> { children?: React.ReactNode; } -interface StausForm extends Status { +interface StatusForm extends Status { files: string[]; description?: string; } @@ -57,7 +57,7 @@ export default function SimpleCommand(props: CommandProps) { init(); }, []); - const handleSubmit = async ({ spoiler_text, status, scheduled_at, visibility, files, description }: StausForm) => { + const handleSubmit = async ({ spoiler_text, status, scheduled_at, visibility, files, description }: StatusForm) => { try { if (!status) throw new Error("You might forget the content, right ? |・ω・)"); @@ -65,7 +65,7 @@ export default function SimpleCommand(props: CommandProps) { const mediaIds = await Promise.all( files?.map(async (file) => { - const { id} = await uploadAttachment({ file, description }); + const { id } = await apiServer.uploadAttachment({ file, description }); return id; }) ); @@ -79,7 +79,7 @@ export default function SimpleCommand(props: CommandProps) { media_ids: mediaIds, }; - const response = await postNewStatus(newStatus); + const response = await apiServer.postNewStatus(newStatus); setStatusInfo(response); cache.set("latest_published_status", JSON.stringify(response)); diff --git a/src/types.ts b/src/types.ts index 99980e7..6daa312 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,23 +1,25 @@ -import type { Icon } from "@raycast/api"; +import type { Icon, LaunchProps } from "@raycast/api"; -export interface AkkomaError { - code: string; - message: string; -} +export type VisibilityScope = "public" | "unlisted" | "direct" | "private" | "local"; export interface Preference { instance: string; defaultVisibility: VisibilityScope; } -export type VisibilityScope = "public" | "unlisted" | "direct" | "private" | "local"; - export interface VisibilityOption { title: string; value: VisibilityScope; icon: Icon; } +// Error +export interface AkkomaError { + code: string; + message: string; +} + +// Application and Credentials interface Application { name: string; website: string; @@ -33,12 +35,7 @@ export interface Credentials { vapid_key: string; } -export interface ApiResponse { - id: number; - created_at: string; - text: string; -} - +// Statuses interface Poll { expired_in: number; hide_totals?: boolean | string; @@ -63,6 +60,13 @@ export interface Status { to?: string[]; } +// API Responses +export interface ApiResponse { + id: number; + created_at: string; + text: string; +} + export interface StatusResponse { id: string; create_at: Date; @@ -78,21 +82,22 @@ export interface Account { avatar_static: string; } +// Attachments export interface StatusAttachment { file: string; description?: string; focus?: { x: number; y: number }; } -export interface UploadAttachResponse{ +export interface UploadAttachResponse { description: string | null; id: string; pleroma: { mime_type: string; - } + }; preview_url: string; remote_url: string | null; text_url: string; - type: "image" | "video" | "audio" | "unknown", + type: "image" | "video" | "audio" | "unknown"; url: string; -} \ No newline at end of file +}