This commit is contained in:
sevichecc 2023-04-07 21:02:19 +08:00
parent a810f1b9ee
commit 2e0377f7f4
Signed by untrusted user who does not match committer: SevicheCC
GPG key ID: C577000000000000
12 changed files with 1920 additions and 0 deletions

4
.eslintrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"root": true,
"extends": ["@raycast"]
}

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# misc
.DS_Store

4
.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"printWidth": 120,
"singleQuote": false
}

3
CHANGELOG.md Normal file
View file

@ -0,0 +1,3 @@
# Post to Akkoma Changelog
## [Initial Version] - 2023-04-06

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# Post to Akkoma
Write new post and send it to Akkoma

BIN
assets/akkoma-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

78
package.json Normal file
View file

@ -0,0 +1,78 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "akkoma",
"title": "Post to Akkoma",
"description": "Send your post from Raycast to Akkoma / Pleroma / Mastodon",
"icon": "akkoma-icon.png",
"author": "SevicheCC",
"categories": [
"Communication"
],
"license": "MIT",
"commands": [
{
"name": "index",
"title": "Post to Fediverse",
"description": "Send your post from Raycast to Akkoma / Pleroma / Mastodon",
"mode": "view"
}
],
"preferences": [
{
"name": "instance",
"type": "textfield",
"required": true,
"title": "Akkoma instance's URL",
"description": "Your Akkoma / Pleroma / Mastodon instance's URL",
"link": "https://github.com/Sevichecc/raycast-akkoma-extension",
"placeholder": "such as: example.dev"
},
{
"name": "defaultVisbility",
"type": "dropdown",
"required": false,
"data": [
{
"title": "🌎 Public",
"value": "public"
},
{
"title": "👥 Private",
"value": "private"
},
{
"title": "🙈 Unlist",
"value": "unlist"
},
{
"title": "✉️ Direct",
"value": "direct"
}
],
"title": "Akkoma instance's URL",
"description": "Your Akkoma / Pleroma / Mastodon instance's URL",
"link": "https://github.com/Sevichecc/raycast-akkoma-extension",
"placeholder": "such as: example.dev"
}
],
"dependencies": {
"@raycast/api": "^1.49.2",
"masto": "^5.10.0",
"node-fetch": "^3.3.1"
},
"devDependencies": {
"@raycast/eslint-config": "1.0.5",
"@types/node": "18.8.3",
"@types/react": "18.0.9",
"eslint": "^7.32.0",
"prettier": "^2.5.1",
"typescript": "^4.4.3"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}

1565
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

78
src/index.tsx Normal file
View file

@ -0,0 +1,78 @@
import { useCallback, useEffect } from "react";
import { Form, ActionPanel, Action, showToast } from "@raycast/api";
import { authorize, client } from "./oauth";
import fetch from "node-fetch";
import { getPreferenceValues } from "@raycast/api";
import { Preference,ApiResponse } from "./types";
type Values = {
textfield: string;
textarea: string;
datepicker: Date;
sensitive: boolean;
dropdown: string;
files: { name: string; url: string }[];
};
export default function Command() {
const { instance } = getPreferenceValues<Preference>();
useEffect(() => {
authorize();
}, []);
const handleSubmit = useCallback(
async (values: Values) => {
try {
const token = await client.getTokens();
const response = await fetch(`https://${instance}/api/v1/statuses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token?.accessToken}`,
},
body: JSON.stringify({
status: values.textarea,
visibility: values.dropdown,
spoiler_text: values.textfield,
sensitive: values.sensitive,
scheduled_at: values.datepicker.toISOString(),
}),
});
const data = (await response.json()) as ApiResponse;
console.log(data);
showToast({ title: "Submitted form", message: "Status has been posted!" });
} catch (error) {
console.error(error);
showToast({ title: "Error", message: "Something went wrong!" });
}
},
[instance]
);
return (
<>
<Form
actions={
<ActionPanel>
<Action.SubmitForm onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField id="textfield" title="Content Warning" placeholder="" />
<Form.TextArea id="textarea" title="Post detail" placeholder="" enableMarkdown={true} />
<Form.Separator />
<Form.DatePicker id="datepicker" title="Scheduled Time" />
<Form.Dropdown id="dropdown" title="Visibility" storeValue={true} defaultValue="">
<Form.Dropdown.Item value="direct" title="Direct" />
<Form.Dropdown.Item value="private" title="Private" />
<Form.Dropdown.Item value="unlisted" title="Unlisted" />
<Form.Dropdown.Item value="public" title="Public" />
</Form.Dropdown>
<Form.FilePicker id="files" />
<Form.Checkbox id="sensitive" title="Sensitive" label="Sensitive" />
</Form>
</>
);
}

116
src/oauth.ts Normal file
View file

@ -0,0 +1,116 @@
import { OAuth, getPreferenceValues } from "@raycast/api";
import fetch from "node-fetch";
import { Preference, AppResponse } from "./types";
const redirectUri = "https://raycast.com/redirect?packageName=Extension";
export const client = new OAuth.PKCEClient({
redirectMethod: OAuth.RedirectMethod.Web,
providerName: "Akkoma",
providerIcon: "akkoma-icon.png",
providerId: "akkoma",
description: "Connect to your Akkoma | Pleroma | Mastodon account",
});
const createAkkomaApp = async (): Promise<AppResponse> => {
const { instance } = getPreferenceValues<Preference>();
const response = await fetch(`https://${instance}/api/v1/apps`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
client_name: "raycast-akkoma-extension",
redirect_uris: redirectUri,
scopes: "read write push",
website: "https://raycast.com",
}),
});
if (!response.ok) {
throw new Error("Failed to create Akkoma app");
}
const appResponse = await response.json();
return appResponse as AppResponse;
};
export const requestAccessToken = async (
clientId: string,
clientSecret: string,
authRequest: OAuth.AuthorizationRequest,
authCode: string
): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
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);
const response = await fetch(`https://${instance}/oauth/token`, {
method: "POST",
body: params,
});
if (!response.ok) {
console.error("fetch tokens error:", await response.text());
throw new Error(response.statusText);
}
return (await response.json()) as OAuth.TokenResponse;
};
export const refreshToken = async (
clientId: string,
clientSecret: string,
refreshToken: string
): Promise<OAuth.TokenResponse> => {
const { instance } = getPreferenceValues<Preference>();
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 response = await fetch(`https://${instance}/oauth/token`, {
method: "POST",
body: params,
});
if (!response.ok) {
console.error("refresh tokens error:", await response.text());
throw new Error(response.statusText);
}
const tokenResponse = (await response.json()) as OAuth.TokenResponse;
tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
return tokenResponse;
};
// 授权过程
export const authorize = async (): Promise<void> => {
const { instance } = getPreferenceValues<Preference>();
const tokenSet = await client.getTokens();
if (tokenSet?.accessToken) {
if (tokenSet.refreshToken && tokenSet.isExpired()) {
const { client_id, client_secret } = await createAkkomaApp();
await client.setTokens(await refreshToken(client_id, client_secret, tokenSet.refreshToken));
}
return;
}
const { client_id, client_secret } = await createAkkomaApp();
const authRequest = await client.authorizationRequest({
endpoint: `https://${instance}/oauth/authorize`,
clientId: client_id,
scope: "read write push",
});
const { authorizationCode } = await client.authorize(authRequest);
await client.setTokens(await requestAccessToken(client_id, client_secret, authRequest, authorizationCode));
};

45
src/types.ts Normal file
View file

@ -0,0 +1,45 @@
export interface Preference {
instance: string
}
export interface Credentials {
client_id: string;
client_secret: string;
id: string;
name: string;
redirect_uri: string;
website: string;
vapid_key: string;
}
export interface ApiResponse {
id: number;
created_at: string;
text: string;
};
export interface AppResponse {
client_id: string;
client_secret: string;
id: string;
name: string;
redirect_uri: string;
website: string;
vapid_key: string;
}
export interface Status {
content_type: string;
expires_in: number;
language: string;
media_ids: string[];
preview: boolean | string | number;
scheduled_at: string;
sensitive: string | boolean | number;
spoiler_text: string;
status: string;
to: string[];
visibility: "direct" | "private" | "unlisted" | "public";
}

17
tsconfig.json Normal file
View file

@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 16",
"include": ["src/**/*"],
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"resolveJsonModule": true
}
}