├── .prettierrc ├── public ├── mastopoet.jpg ├── raikasdev.jpg ├── roboto-v30-latin-500.woff2 ├── roboto-v30-latin-regular.woff2 └── favicon.svg ├── src ├── utils │ ├── axios.ts │ ├── use-object-state.ts │ ├── piexif.ts │ ├── use-minmax-state.ts │ ├── buffer.ts │ └── util.ts ├── main.tsx ├── vite-env.d.ts ├── instance │ ├── BaseInstance.ts │ ├── _main.ts │ ├── Mastodon.ts │ ├── Akkoma.ts │ └── Misskey.ts ├── styles │ ├── index.scss │ └── App.scss ├── App.tsx ├── components │ ├── SearchForm.tsx │ ├── HorizontalHandlebar.tsx │ ├── EmbeddedPostContainer.tsx │ ├── CORSAlert.tsx │ ├── VerticalHandlebar.tsx │ ├── DiagonalHandlebar.tsx │ ├── PostContainer.tsx │ ├── OptionsEditor.tsx │ ├── EmbedPostItem.tsx │ └── PostItem.tsx ├── config.ts ├── routes │ ├── embed.tsx │ └── index.tsx ├── lang.ts ├── assets │ └── react.svg └── themes │ ├── BirdUi.scss │ └── Mastodon.scss ├── tsconfig.node.json ├── .gitignore ├── .eslintrc.cjs ├── .dockerignore ├── Dockerfile ├── CONTRIBUTING.md ├── tsconfig.json ├── vite.config.ts ├── LICENSE ├── index.html ├── package.json ├── .stylelintrc └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard"], 3 | "rules": { } 4 | } -------------------------------------------------------------------------------- /public/mastopoet.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/mastopoet/HEAD/public/mastopoet.jpg -------------------------------------------------------------------------------- /public/raikasdev.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/mastopoet/HEAD/public/raikasdev.jpg -------------------------------------------------------------------------------- /public/roboto-v30-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/mastopoet/HEAD/public/roboto-v30-latin-500.woff2 -------------------------------------------------------------------------------- /public/roboto-v30-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/raikasdev/mastopoet/HEAD/public/roboto-v30-latin-regular.woff2 -------------------------------------------------------------------------------- /src/utils/axios.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | export const axiosInstance = axios.create({ 4 | headers: { 5 | "User-Agent": `mastopoet/${__APP_VERSION__}`, 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./styles/index.scss"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/utils/use-object-state.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | export function useObjectState(defaultValue: T): [T, (value: T) => void] { 4 | const [value, setStateValue] = useState(defaultValue); 5 | const setValue = (value: T) => setStateValue({ ...value }); 6 | 7 | return [value, setValue]; 8 | } 9 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 3 | let __APP_VERSION__: string; 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | let __COMMIT_HASH__: string; 6 | 7 | declare module "piexifjs"; 8 | declare module "emojilib"; 9 | -------------------------------------------------------------------------------- /src/instance/BaseInstance.ts: -------------------------------------------------------------------------------- 1 | import { Post } from "../components/PostItem"; 2 | 3 | export default class BaseInstance { 4 | public url: URL = new URL("https://example.com"); 5 | public postId: string = ""; 6 | public async execute(): Promise { 7 | throw new Error("Not implemented"); 8 | } 9 | 10 | constructor(url: URL, postId: string) { 11 | this.url = url; 12 | this.postId = postId; 13 | } 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .stylelintcache 26 | 27 | # Package-lock.json is used 28 | yarn.lock -------------------------------------------------------------------------------- /src/utils/piexif.ts: -------------------------------------------------------------------------------- 1 | import { insert, dump, ImageIFD } from "piexifjs"; 2 | 3 | export default function addExif( 4 | jpegDataString: string, 5 | altText: string, 6 | ): string { 7 | return insert( 8 | dump({ 9 | "0th": { 10 | // 0th = image metadata 11 | [ImageIFD.ImageDescription]: altText, 12 | [ImageIFD.Software]: "Mastopoet", 13 | }, 14 | }), // ImageDescription = 10E = 270 15 | jpegDataString, 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-bg: #232543; 3 | --color-fg: #f7f9f9; 4 | --color-button: #858afa; 5 | background-color: var(--color-bg); 6 | color: var(--color-fg); 7 | font-family: system-ui, -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, Inter, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji; 8 | font-weight: 400; 9 | line-height: 1.5; 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/utils/use-minmax-state.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | /** 4 | * Numeric useState with min and max values 5 | */ 6 | export default function useMinmaxState( 7 | defaultValue: number, 8 | min: number, 9 | max: number 10 | ): [number, (value: number) => void] { 11 | const [value, setStateValue] = useState(defaultValue); 12 | const setValue = (value: number) => 13 | setStateValue(Math.min(Math.max(value, min), max)); 14 | 15 | return [value, setValue]; 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/buffer.ts: -------------------------------------------------------------------------------- 1 | export default async function bufferToBase64( 2 | buffer: Uint8Array, 3 | ): Promise { 4 | // use a FileReader to generate a base64 data URI: 5 | const base64url: string = await new Promise((r) => { 6 | const reader = new FileReader(); 7 | reader.onload = () => r(reader.result as string); 8 | reader.readAsDataURL(new Blob([buffer])); 9 | }); 10 | // remove the `data:...;base64,` part from the start 11 | return base64url.slice(base64url.indexOf(",") + 1); 12 | } 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # config files 2 | **/.DS_Store 3 | .husky/ 4 | .vscode/ 5 | .editorconfig 6 | .eslintrc 7 | .eslintignore 8 | .prettierignore 9 | .prettierrc 10 | .lintstagedrc 11 | .markdownlint.json 12 | .stylelintignore 13 | .stylelintrc 14 | commitlint.config.ts 15 | .dockerignore 16 | Dockerfile* 17 | docker-compose*.yml 18 | 19 | # ignore documentation 20 | LICENSE 21 | CHANGELOG.md 22 | README.md 23 | 24 | # pass env vars through docker-compose 25 | # or mount .env* in dev 26 | .env* 27 | env/ 28 | dist/ 29 | 30 | # named volumes 31 | node_modules -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route } from "wouter"; 2 | 3 | // Main styles 4 | import "./styles/App.scss"; 5 | 6 | // Themes 7 | import "./themes/BirdUi.scss"; 8 | import "./themes/Mastodon.scss"; 9 | 10 | // Routes 11 | import IndexPage from "./routes"; 12 | import EmbedPage from "./routes/embed"; 13 | 14 | function App() { 15 | return ( 16 | <> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # BUILD APPLICATION STEP 2 | FROM node:18-alpine as build 3 | 4 | RUN mkdir -p /usr/src/app 5 | 6 | WORKDIR /usr/src/app 7 | 8 | # Because at vite.config.ts we use "git rev-parse --short HEAD" 9 | RUN apk add git 10 | 11 | COPY package.json . 12 | COPY package-lock.json . 13 | 14 | RUN npm ci --verbose 15 | 16 | COPY . . 17 | 18 | RUN npm run build 19 | 20 | # SERVE APPLICATION IN PRODUCTION 21 | FROM nginx:stable-alpine AS nginx 22 | 23 | COPY --from=build /usr/src/app/dist/ /usr/share/nginx/html/ 24 | 25 | EXPOSE 80 26 | 27 | CMD ["nginx", "-g", "daemon off;" ] -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for having interest in contributing to Mastopoet! 4 | 5 | Firstly, I would recommend discussing your contribution with the developer ([@raikas@mementomori.social](https://mementomori.social/@raikas)), so we don't accidentally work on the same feature. 6 | 7 | ## Project setup 8 | 9 | I recommend using Node LTS (at the moment v18.17) and latest NPM. 10 | 11 | 1. Install dependencies with `npm install`. 12 | 2. Run Vite with `npm run dev` 13 | 3. Open app in http://localhost:5173 (or the address Vite shows you in CLI) 14 | 15 | ## Pull requests 16 | 17 | When your feature is ready, create a pull request for it on Github. I will try to review them quickly 😊 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2021", "ES2021.String", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import stylelint from "vite-plugin-stylelint"; 4 | import { execSync } from "child_process"; 5 | import { readFileSync } from "fs"; 6 | const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); 7 | const packageJson = JSON.parse( 8 | readFileSync("./package.json").toString("utf-8"), 9 | ); 10 | 11 | // https://vitejs.dev/config/ 12 | export default defineConfig({ 13 | define: { 14 | __COMMIT_HASH__: JSON.stringify(commitHash), 15 | __APP_VERSION__: JSON.stringify(packageJson.version), 16 | }, 17 | plugins: [ 18 | react(), 19 | stylelint({ 20 | fix: true, 21 | }), 22 | ], 23 | server: { 24 | host: "0.0.0.0", 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Roni "raikasdev" Äikäs 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | interface SearchFormProps { 4 | submitUrl: (url: string) => void; 5 | } 6 | export default function SearchForm({ submitUrl }: SearchFormProps) { 7 | const [url, setUrl] = useState(""); 8 | 9 | return ( 10 |
{ 13 | event.preventDefault(); 14 | submitUrl(url); 15 | }} 16 | > 17 | setUrl(event.currentTarget.value)} 23 | required 24 | /> 25 | 44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Mastopoet - Beautiful Mastodon post screenshots 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mastopoet", 3 | "private": true, 4 | "version": "1.0.3", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.4.0", 14 | "dompurify": "^3.0.5", 15 | "emoji-regex": "^10.2.1", 16 | "emojilib": "^3.0.11", 17 | "html2canvas": "^1.4.1", 18 | "piexifjs": "^1.0.6", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "wouter": "^2.12.0" 22 | }, 23 | "devDependencies": { 24 | "@ronilaukkarinen/stylelint-a11y": "^1.2.7", 25 | "@ronilaukkarinen/stylelint-declaration-strict-value": "^1.9.2", 26 | "@ronilaukkarinen/stylelint-value-no-unknown-custom-properties": "^4.0.1", 27 | "@types/dompurify": "^3.0.2", 28 | "@types/node": "^20.4.5", 29 | "@types/react": "^18.2.15", 30 | "@types/react-dom": "^18.2.7", 31 | "@typescript-eslint/eslint-plugin": "^6.0.0", 32 | "@typescript-eslint/parser": "^6.0.0", 33 | "@vitejs/plugin-react-swc": "^3.3.2", 34 | "eslint": "^8.45.0", 35 | "eslint-plugin-react-hooks": "^4.6.0", 36 | "eslint-plugin-react-refresh": "^0.4.3", 37 | "postcss": "^8.4.21", 38 | "prettier": "^3.0.0", 39 | "sass": "^1.64.1", 40 | "stylelint": "^15.2.0", 41 | "stylelint-config-standard": "^30.0.1", 42 | "stylelint-config-standard-scss": "^7.0.1", 43 | "stylelint-order": "^6.0.3", 44 | "stylelint-scss": "^4.4.0", 45 | "typescript": "^5.0.2", 46 | "vite": "^4.4.5", 47 | "vite-plugin-stylelint": "^4.3.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/components/HorizontalHandlebar.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "react"; 2 | 3 | interface HandlebarProps { 4 | width: number; 5 | setWidth: (width: number) => void; 6 | side: "right" | "left"; 7 | } 8 | export default function HorizontalHandlerbar({ 9 | width, 10 | setWidth, 11 | side, 12 | }: HandlebarProps) { 13 | const [holding, setHolding] = useState(false); 14 | const ref = useRef(null); 15 | const handleMouseDown = useCallback(() => { 16 | setHolding(true); 17 | 18 | const x = 19 | side === "left" 20 | ? (ref.current?.getBoundingClientRect().left || 0) + width / 2 + 4 // 4 for centering 21 | : (ref.current?.getBoundingClientRect().right || 0) - width / 2 - 4; 22 | 23 | const mouseMove = (event: MouseEvent) => { 24 | const difference = side === "left" ? x - event.pageX : event.pageX - x; 25 | setWidth(difference * 2); 26 | }; 27 | 28 | document.addEventListener( 29 | "mouseup", 30 | () => { 31 | setHolding(false); 32 | document.removeEventListener("mousemove", mouseMove); 33 | }, 34 | { once: true }, 35 | ); 36 | 37 | document.addEventListener("mousemove", mouseMove); 38 | }, [width]); 39 | 40 | return ( 41 |
47 | 53 | 54 | 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/components/EmbeddedPostContainer.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useMemo, useRef } from "react"; 2 | import { Post, PostItemProps } from "./PostItem"; 3 | import { Options } from "../config"; 4 | import EmbedPostItem from "./EmbedPostItem"; 5 | 6 | interface PostContainerProps { 7 | post: Post; 8 | height: number; 9 | width: number; 10 | options: Options; 11 | } 12 | 13 | export default function EmbeddedPostContainer({ 14 | post, 15 | height, 16 | width, 17 | options, 18 | }: PostContainerProps) { 19 | const ref = useRef(null); 20 | const sizeRef = useRef(null); 21 | 22 | const PostItemReffed = useMemo( 23 | () => 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | forwardRef((props, ref) => ( 26 | 27 | )), 28 | [post], 29 | ); 30 | 31 | return ( 32 |
33 | {/** Scaling, width 600px + 8px for handles */} 34 |
35 |
45 |
46 | null} 51 | options={options} 52 | /> 53 |
54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/CORSAlert.tsx: -------------------------------------------------------------------------------- 1 | export default function CORSAlert({ host }: { host: string }) { 2 | return ( 3 |
4 |
5 | 6 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | CORS issue detected! 26 | 27 |

28 | Sorry! Mastopoet could not fetch images in this Mastodon post due to 29 | CORS restrictions. The server you are trying to fetch this post's 30 | images is {host}. Please contact that instance's admin and ask 31 | them to enable anonymous cross origin on their server. You can read 32 | more about this in our{" "} 33 | 34 | documentation 35 | 36 | . 37 |

38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/instance/_main.ts: -------------------------------------------------------------------------------- 1 | import { Post } from "../components/PostItem"; 2 | import BaseInstance from "./BaseInstance"; 3 | import MisskeyInstance from "./Misskey"; 4 | import MastodonInstance from "./Mastodon"; 5 | import AkkomaInstance from "./Akkoma"; 6 | 7 | // Since all script on ReactJS will be imported to frontend, 8 | // we cannot dynamically import them using File System NodeJS. 9 | type InstanceListType = [typeof BaseInstance, RegExp[]][]; 10 | const Instances: InstanceListType = [ 11 | [ 12 | MastodonInstance, 13 | [ 14 | /^\/@\w+(?:@[\w-.]+)?\/(\d+)$/, // instace.social/@user/postid 15 | /^\/users\/\w+(?:@[\w-.]+)?\/statuses\/(\d+)$/, // instance.social/users/user/statuses/postid 16 | ], 17 | ], 18 | [ 19 | AkkomaInstance, 20 | [ 21 | /^\/@\w+(?:@[\w-.]+)?\/posts\/(\w+)$/, // Akkoma, instance.social/@user/posts/postid 22 | /^\/notice\/(\w+)$/, // instance.social/notice/posts/postid 23 | ], 24 | ], 25 | [ 26 | MisskeyInstance, 27 | [ 28 | /^\/notes\/(\w+)$/, // instace.social/notes/postid 29 | ], 30 | ], 31 | ]; 32 | export default async function (inputURL: string): Promise { 33 | return new Promise((resolve, reject) => { 34 | const url = new URL(inputURL); 35 | let found = false; 36 | Instances.find(([instance, reg]) => { 37 | return reg.some((x) => { 38 | const match = url.pathname.match(x); 39 | if (!match) return; 40 | if (found) return; 41 | 42 | const postId = match[1]; 43 | const initInstance = new instance(url, postId); 44 | initInstance 45 | .execute() 46 | .then((res) => resolve(res)) 47 | .catch((err) => reject(err)); 48 | found = true; 49 | }); 50 | }); 51 | if (!found) reject(new Error("Invalid URL")); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /src/components/VerticalHandlebar.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "react"; 2 | 3 | interface HandlebarProps { 4 | height: number; 5 | setHeight: (width: number) => void; 6 | side: "top" | "bottom"; 7 | } 8 | 9 | export default function VerticalHandlerbar({ 10 | height, 11 | setHeight, 12 | side, 13 | }: HandlebarProps) { 14 | const [holding, setHolding] = useState(false); 15 | const ref = useRef(null); 16 | 17 | const globalize = (num: number) => num + window.scrollY; 18 | 19 | const handleMouseDown = useCallback(() => { 20 | setHolding(true); 21 | 22 | const y = 23 | side === "top" 24 | ? globalize(ref.current?.getBoundingClientRect().top || 0) + 25 | height / 2 + 26 | 12 27 | : globalize(ref.current?.getBoundingClientRect().bottom || 0) - 28 | height / 2 - 29 | 4; 30 | 31 | const mouseMove = (event: MouseEvent) => { 32 | const difference = side === "top" ? y - event.pageY : event.pageY - y; 33 | setHeight(difference * 2); 34 | }; 35 | 36 | document.addEventListener( 37 | "mouseup", 38 | () => { 39 | setHolding(false); 40 | document.removeEventListener("mousemove", mouseMove); 41 | }, 42 | { once: true }, 43 | ); 44 | 45 | document.addEventListener("mousemove", mouseMove); 46 | }, [height]); 47 | 48 | return ( 49 |
55 | 61 | 62 | 63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/DiagonalHandlebar.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from "react"; 2 | import { maxWidth } from "../config"; 3 | 4 | interface HandlebarProps { 5 | width: number; 6 | setWidth: (width: number) => void; 7 | height: number; 8 | setHeight: (width: number) => void; 9 | side: "right" | "left"; 10 | } 11 | export default function DiagonalHandlerbar({ 12 | width, 13 | setWidth, 14 | height, 15 | setHeight, 16 | side, 17 | }: HandlebarProps) { 18 | const [holding, setHolding] = useState(false); 19 | const ref = useRef(null); 20 | const handleMouseDown = useCallback(() => { 21 | setHolding(true); 22 | 23 | const ratio = isNaN(height / width) ? 0.5 : height / width; 24 | 25 | const x = 26 | side === "left" 27 | ? (ref.current?.getBoundingClientRect().left || 0) + width / 2 + 4 // 4 for centering 28 | : (ref.current?.getBoundingClientRect().right || 0) - width / 2 - 4; 29 | const mouseMove = (event: MouseEvent) => { 30 | const difference = side === "left" ? x - event.pageX : event.pageX - x; 31 | const newWidth = Math.min(Math.max(difference * 2, 0), maxWidth); 32 | 33 | setWidth(newWidth); 34 | setHeight(newWidth * ratio); 35 | }; 36 | 37 | document.addEventListener( 38 | "mouseup", 39 | () => { 40 | setHolding(false); 41 | document.removeEventListener("mousemove", mouseMove); 42 | }, 43 | { once: true }, 44 | ); 45 | 46 | document.addEventListener("mousemove", mouseMove); 47 | }, [width, height]); 48 | 49 | return ( 50 |
56 | 62 | 63 | 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Post } from "./components/PostItem"; 2 | 3 | export const maxWidth = 400; // Max width (of gradient) after message width 4 | export const defaultWidth = 200; 5 | 6 | export const maxHeight = 200; // Max height (of gradient) after message height 7 | export const defaultHeight = 100; 8 | 9 | export const themes = [ 10 | "bird-ui", 11 | "bird-ui-light", 12 | "mastodon", 13 | "mastodon-light", 14 | ] as const; 15 | 16 | export type Theme = (typeof themes)[number]; 17 | export type InteractionsPreference = 18 | | "normal" 19 | | "normal no-replies" 20 | | "feed" 21 | | "feed no-date" 22 | | "hidden"; 23 | 24 | export interface Options { 25 | theme: Theme; 26 | interactions: InteractionsPreference; 27 | background: string; 28 | scale: number; 29 | language: string; 30 | } 31 | 32 | export const welcomePost: Post = { 33 | username: "@raikas@mementomori.social", 34 | plainUsername: "raikas", 35 | displayName: "Roni Äikäs ⚛️", 36 | attachments: [], 37 | avatarUrl: "/raikasdev.jpg", 38 | boosts: 0, 39 | comments: 0, 40 | favourites: 0, 41 | date: new Date(1690503611282), 42 | content: `

Hello and welcome to !
Paste a Mastodon post URL in the field above and play with the options to create perfect screenshots!

Source code (licensed under MIT) -> github.com/raikasdev/mastopoet
Reach out to me -> mementomori.social/@raikas${import.meta.env.VITE_HIDE_DEVELOPER_KOFI_AD === "true" 43 | ? "" 44 | : '

If you enjoy Mastopoet, consider buying me a coffee ☕
at ko-fi.com/raikasdev, I would really appreciate it ❤️!

' 45 | }`, 46 | postURL: 'https://mastopoet.raikas.dev', 47 | profileURL: 'https://mementomori.social/@raikas' 48 | }; 49 | -------------------------------------------------------------------------------- /src/routes/embed.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Post } from "../components/PostItem"; 3 | import useMinmaxState from "../utils/use-minmax-state"; 4 | import { 5 | InteractionsPreference, 6 | Options, 7 | Theme, 8 | defaultHeight, 9 | defaultWidth, 10 | maxHeight, 11 | maxWidth, 12 | themes, 13 | } from "../config"; 14 | import fetchPost from "../instance/_main"; 15 | import EmbeddedPostContainer from "../components/EmbeddedPostContainer"; 16 | 17 | function parseNumber(val: string | undefined) { 18 | if (!val) return undefined; 19 | return isNaN(parseInt(val)) ? 0 : parseInt(val); 20 | } 21 | 22 | function EmbedPage() { 23 | const [post, setPost] = useState(null); 24 | const [message, setMessage] = useState(""); 25 | const [width, setWidth] = useMinmaxState(defaultWidth, 0, maxWidth); 26 | const [height, setHeight] = useMinmaxState(defaultHeight, 0, maxHeight); 27 | const [options, setOptions] = useState({ 28 | theme: "bird-ui", 29 | interactions: "feed", 30 | background: "linear-gradient(to right, #fc5c7d, #6a82fb)", 31 | scale: 2, 32 | language: "en", 33 | }); 34 | 35 | useEffect(() => { 36 | const params = Object.fromEntries( 37 | new URLSearchParams(window.location.search).entries(), 38 | ) as Record; 39 | const { url, width, height, background } = params; 40 | let { theme, interactions } = params; 41 | 42 | if (!url) return setMessage("Embed URL is invalid."); 43 | if (theme && !themes.includes(theme as Theme)) theme = undefined; 44 | if ( 45 | interactions && 46 | ![ 47 | "feed", 48 | "feed no-date", 49 | "normal", 50 | "normal no-replies", 51 | "hidden", 52 | ].includes(interactions) 53 | ) 54 | interactions = undefined; 55 | 56 | (async () => { 57 | try { 58 | const response = await fetchPost(url); 59 | setPost(response); 60 | setOptions({ 61 | theme: (theme ?? "bird-ui") as Theme, 62 | interactions: (interactions ?? "feed") as InteractionsPreference, 63 | background: 64 | background ?? "linear-gradient(to right, #fc5c7d, #6a82fb)", 65 | scale: 2, 66 | language: "en", 67 | }); 68 | setWidth(parseNumber(width) ?? 0); 69 | setHeight(parseNumber(height) ?? 0); 70 | setMessage(""); 71 | } catch (e) { 72 | setMessage("The embedded post was not found."); 73 | } 74 | })(); 75 | }, []); 76 | 77 | /** End screenshotting */ 78 | 79 | return ( 80 | <> 81 | {message &&

{message}

} 82 | {post && ( 83 | 89 | )} 90 | 91 | ); 92 | } 93 | 94 | export default EmbedPage; 95 | -------------------------------------------------------------------------------- /src/lang.ts: -------------------------------------------------------------------------------- 1 | export interface LangItem { 2 | fetchError: string; 3 | attachments: { 4 | single: { 5 | attachments: string; // Post has one attachment. 6 | has: string; // Attachments alt text is {altText} 7 | hasNot: string; 8 | } 9 | multiple: { 10 | attachments: string; // Post has {count} attachments 11 | has: string; // Attachment {index} alt text is {altText} 12 | hasNot: string; // Attachment {index} do not have alt text 13 | } 14 | } 15 | intro: string; // A screenshot of post by {displayName} ({username})... posted on {date} and has {favourites}, {boosts}, {replies} 16 | poll: { 17 | intro: string; 18 | item: string; // {title} {percentage}% 19 | } 20 | } 21 | 22 | const en: LangItem = { 23 | fetchError: "Failed to fetch post content from Mastopoet", 24 | attachments: { 25 | single: { 26 | attachments: "Post has one attachment.", 27 | has: "The attachments alt text is:\n{altText}", 28 | hasNot: "It does not have an alt text set.", 29 | }, 30 | multiple: { 31 | attachments: "Post has {count} attachments.", 32 | has: "Attachment {index}'s alt text is: {altText}", 33 | hasNot: "Attachment {index} does not have an alt text.", 34 | }, 35 | }, 36 | intro: "A screenshot of post by {displayName} ({username}) beautified by Mastopoet tool. It was posted on {date} and has {favourites} favourites, {boosts} boosts and {replies} replies.", 37 | poll: { 38 | intro: "Poll results:", 39 | item: "{percentage}% {title}", 40 | }, 41 | } 42 | 43 | const fi: LangItem = { 44 | fetchError: "Virhe haettaessa viestin sisältöä Mastopoetista", 45 | attachments: { 46 | single: { 47 | attachments: "Viestillä on yksi liite.", 48 | has: "Liitteen vaihtoehtoinen teksti on:\n{altText}", 49 | hasNot: "Liitteellä ei ole vaihtoehtoista tekstiä.", 50 | }, 51 | multiple: { 52 | attachments: "Viestillä on {count} liitettä.", 53 | has: "Liitteen {index} vaihtoehtoinen teksti on: {altText}", 54 | hasNot: "Liitteellä {index} ei ole vaihtoehtoista tekstiä.", 55 | }, 56 | }, 57 | intro: "Käyttäjän {displayName} ({username}) viesti, josta on luotu kuvakaappaus Mastopoet-työkalulla. Viesti on lähetetty {date} ja sillä on {favourites} tykkäystä, {boosts} boostia ja {replies} vastausta.", 58 | poll: { 59 | intro: "Äänestystulokset:", 60 | item: "{percentage}% {title}", 61 | }, 62 | } 63 | 64 | const de: LangItem = { 65 | fetchError: "Fehler beim Abrufen des Postinhalts von Mastopoet", 66 | attachments: { 67 | single: { 68 | attachments: "Post hat einen Anhang.", 69 | has: "Der Anhang hat einen Alternativtext:\n{altText}", 70 | hasNot: "Der Anhang hat keinen Alternativtext.", 71 | }, 72 | multiple: { 73 | attachments: "Post hat {count} Anhänge.", 74 | has: "Anhang {index} hat einen Alternativtext: {altText}", 75 | hasNot: "Anhang {index} hat keinen Alternativtext.", 76 | }, 77 | }, 78 | intro: "Ein Bildschirmfoto eines Beitrags von {displayName} ({username}) verschönert mit der Software Mastopoet. Er wurde am {date} veröffentlicht und hat {favourites} Favoriten, {boosts} Boosts und {replies} Antworten.", 79 | poll: { 80 | intro: "Umfrageergebnisse:", 81 | item: "{percentage}% {title}", 82 | }, 83 | } 84 | 85 | export default { 86 | 'fi': fi, 87 | 'de': de, 88 | 'en': en, 89 | } as { 90 | [key: string]: LangItem; 91 | } -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/PostContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Ref, forwardRef, useMemo, useRef } from "react"; 2 | import PostItem, { Post, PostItemProps } from "./PostItem"; 3 | import { Options, maxHeight, maxWidth } from "../config"; 4 | import HorizontalHandlerbar from "./HorizontalHandlebar"; 5 | import VerticalHandlerbar from "./VerticalHandlebar"; 6 | import DiagonalHandlerbar from "./DiagonalHandlebar"; 7 | 8 | interface PostContainerProps { 9 | post: Post; 10 | height: number; 11 | setHeight: (val: number) => void; 12 | width: number; 13 | setWidth: (val: number) => void; 14 | rendering: boolean; 15 | screenshotRef: Ref; 16 | options: Options; 17 | onImageLoadError: (host: string) => void; 18 | } 19 | 20 | export default function PostContainer({ 21 | post, 22 | height, 23 | setHeight, 24 | width, 25 | setWidth, 26 | rendering, 27 | screenshotRef, 28 | options, 29 | onImageLoadError, 30 | }: PostContainerProps) { 31 | const ref = useRef(null); 32 | const sizeRef = useRef(null); 33 | 34 | const PostItemReffed = useMemo( 35 | () => 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | forwardRef((props, ref) => ( 38 | 39 | )), 40 | [post], 41 | ); 42 | 43 | useMemo(() => { 44 | if (!sizeRef.current) return; 45 | sizeRef.current.style.height = rendering 46 | ? `${Math.ceil(sizeRef.current.clientHeight)}px` 47 | : ""; 48 | }, [rendering]); 49 | 50 | return ( 51 |
52 | {/** Scaling, width 600px + 8px for handles */} 53 |
608 + maxWidth 57 | ? `${(maxHeight - height) / 2}px ${(maxWidth - width) / 2}px` 58 | : "0", 59 | transform: `scale(${ 60 | window.screen.width > 608 + maxWidth 61 | ? 1 62 | : rendering 63 | ? 1 64 | : window.innerWidth / (608 + width) 65 | })`, 66 | }} 67 | ref={sizeRef} 68 | > 69 |
80 | 81 | 88 | 93 |
94 | 101 |
102 | 107 | 114 | 119 |
120 |
121 |
122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "plugins": [ 4 | "@ronilaukkarinen/stylelint-value-no-unknown-custom-properties", 5 | "stylelint-order" 6 | ], 7 | "extends": [ 8 | "stylelint-config-standard", 9 | "stylelint-config-standard-scss" 10 | ], 11 | "customSyntax": "postcss-scss", 12 | "rules": { 13 | "order/order": [ 14 | { 15 | "type": "at-rule", 16 | "name": "import" 17 | }, 18 | { 19 | "type": "at-rule", 20 | "name": "include" 21 | }, 22 | { 23 | "type": "at-rule", 24 | "name": "extend" 25 | }, 26 | "custom-properties", 27 | "dollar-variables", 28 | "declarations", 29 | "rules", 30 | { 31 | "type": "at-rule", 32 | "name": "media" 33 | } 34 | ], 35 | "declaration-property-value-no-unknown": null, 36 | "order/properties-alphabetical-order": true, 37 | "alpha-value-notation": "number", 38 | "declaration-block-no-redundant-longhand-properties": null, 39 | "custom-property-empty-line-before": "never", 40 | "color-no-invalid-hex": null, 41 | "color-function-notation": null, 42 | "color-hex-length": null, 43 | "selector-type-case": "lower", 44 | "function-name-case": "lower", 45 | "selector-attribute-quotes": "always", 46 | "comment-whitespace-inside": "always", 47 | "selector-max-specificity": "0,8,8", 48 | "block-no-empty": true, 49 | "declaration-empty-line-before": null, 50 | "font-family-no-missing-generic-family-keyword": true, 51 | "font-family-name-quotes": "always-where-required", 52 | "at-rule-no-unknown": null, 53 | "no-invalid-position-at-import-rule": null, 54 | "declaration-no-important": true, 55 | "comment-empty-line-before": null, 56 | "function-url-quotes": "always", 57 | "unit-no-unknown": true, 58 | "property-no-unknown": true, 59 | "no-duplicate-selectors": true, 60 | "length-zero-no-unit": null, 61 | "font-weight-notation": "numeric", 62 | "number-max-precision": null, 63 | "selector-class-pattern": null, 64 | "selector-max-class": 7, 65 | "selector-max-combinators": 7, 66 | "selector-max-compound-selectors": 7, 67 | "selector-max-pseudo-class": 3, 68 | "selector-max-universal": 1, 69 | "property-no-vendor-prefix": true, 70 | "selector-no-vendor-prefix": true, 71 | "selector-no-qualifying-type": null, 72 | "declaration-block-no-duplicate-properties": true, 73 | "no-unknown-animations": true, 74 | "shorthand-property-no-redundant-values": true, 75 | "declaration-block-single-line-max-declarations": 1, 76 | "value-keyword-case": null, 77 | "rule-empty-line-before": [ 78 | "always-multi-line", 79 | { 80 | "except": [ 81 | "first-nested", 82 | "after-single-line-comment" 83 | ], 84 | "ignore": [ 85 | "inside-block" 86 | ] 87 | } 88 | ], 89 | "at-rule-empty-line-before": [ 90 | "always", 91 | { 92 | "ignoreAtRules": [ 93 | "if", 94 | "else" 95 | ], 96 | "except": [ 97 | "first-nested", 98 | "blockless-after-same-name-blockless", 99 | "blockless-after-blockless" 100 | ], 101 | "ignore": [ 102 | "after-comment" 103 | ] 104 | } 105 | ], 106 | "no-descending-specificity": null, 107 | "max-nesting-depth": [ 108 | 6, 109 | { 110 | "ignore": [ 111 | "blockless-at-rules", 112 | "pseudo-classes" 113 | ] 114 | } 115 | ], 116 | "declaration-property-unit-allowed-list": [ 117 | { 118 | "font-size": [ 119 | "rem", 120 | "em", 121 | "px" 122 | ], 123 | "line-height": [ 124 | "px", 125 | "%", 126 | "" 127 | ] 128 | } 129 | ], 130 | "property-disallowed-list": [ 131 | "font" 132 | ] 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/instance/Mastodon.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { Attachment, Post } from "../components/PostItem"; 3 | import { truncateString } from "../utils/util"; 4 | import BaseInstance from "./BaseInstance"; 5 | import { axiosInstance } from "../utils/axios"; 6 | 7 | export default class MastodonInstance extends BaseInstance { 8 | public async execute(): Promise { 9 | if (this.url.protocol !== "https:") 10 | throw new Error("Protocol must be HTTPS"); 11 | 12 | try { 13 | const targetUrl = new URL( 14 | `https://${this.url.host}/api/v1/statuses/${this.postId}`, 15 | ); 16 | const res = await axiosInstance.get(targetUrl.toString()); 17 | 18 | return await this.mastodonStatusToPost(res.data, this.url.host); 19 | } catch (e) { 20 | if (e instanceof AxiosError) { 21 | if (!e.response) throw new Error("Failed to reach API"); 22 | if (e.response.status === 404) 23 | throw new Error("Post not found. Is it private?"); 24 | } 25 | throw new Error("Unknown error trying to reach Mastodon instance"); 26 | } 27 | } 28 | 29 | private async mastodonStatusToPost( 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | obj: any, 32 | host: string, 33 | ): Promise { 34 | let username = obj.account.acct; 35 | if (!username.includes("@")) { 36 | console.debug("Local toot, looking up username from WebFinger..."); 37 | // A local username, time for WebFinger lookup... 38 | const webFingerURL = new URL(`https://${host}/.well-known/webfinger`); 39 | webFingerURL.searchParams.set("resource", `acct:${username}@${host}`); 40 | try { 41 | const res = await axiosInstance(webFingerURL.toString()); 42 | 43 | const subject = res.data.subject; 44 | 45 | username = new URL(subject).pathname; 46 | } catch (e) { 47 | // If WebFinger lookup fails, just resort to lazy domain 48 | username = `${username}@${host}`; 49 | } 50 | } 51 | 52 | username = `@${username}`; 53 | 54 | // Emoji replacer (quality code 100%) 55 | let content = obj.content; 56 | 57 | obj.emojis.forEach((emoji: { url: string; shortcode: string }) => { 58 | content = content.replaceAll( 59 | `:${emoji.shortcode}:`, 60 | ``, 61 | ); 62 | }); 63 | 64 | // Parse attachment 65 | const attachments = obj.media_attachments.map( 66 | (mediaAttachment: { 67 | type: string; 68 | url: string; 69 | meta: { original: { aspect: number } }; 70 | description?: string; 71 | }): Attachment => { 72 | console.log(mediaAttachment); 73 | return { 74 | type: mediaAttachment.type, 75 | url: mediaAttachment.url, 76 | aspectRatio: mediaAttachment.meta.original.aspect, 77 | description: mediaAttachment.description, 78 | }; 79 | }, 80 | ); 81 | 82 | let displayName = truncateString( 83 | obj.account.display_name === "" 84 | ? obj.account.username 85 | : obj.account.display_name, 86 | 30, 87 | ); 88 | 89 | obj.account.emojis.forEach((emoji: { url: string; shortcode: string }) => { 90 | displayName = displayName.replaceAll( 91 | `:${emoji.shortcode}:`, 92 | ``, 93 | ); 94 | }); 95 | 96 | const pollTotal = 97 | obj.poll?.options.reduce( 98 | (acc: number, option: { votes_count: number }) => 99 | acc + option.votes_count, 100 | 0, 101 | ) || 1; 102 | 103 | const poll = obj.poll?.options.map( 104 | (option: { title: string; votes_count: number }) => ({ 105 | title: option.title, 106 | votesCount: option.votes_count, 107 | percentage: Math.round((option.votes_count / pollTotal) * 100), 108 | }), 109 | ); 110 | 111 | return { 112 | username, 113 | plainUsername: obj.account.username, 114 | displayName, 115 | avatarUrl: obj.account.avatar, 116 | boosts: obj.reblogs_count, 117 | comments: obj.replies_count, 118 | postURL: obj.url, 119 | profileURL: obj.account.url, 120 | favourites: obj.favourites_count, 121 | content, 122 | attachments, 123 | poll, 124 | date: new Date(obj.created_at), 125 | }; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/instance/Akkoma.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { Attachment, Post } from "../components/PostItem"; 3 | import { truncateString } from "../utils/util"; 4 | import BaseInstance from "./BaseInstance"; 5 | import { axiosInstance } from "../utils/axios"; 6 | 7 | // Using Akkoma Mastodon Compatible API 8 | export default class AkkomaInstance extends BaseInstance { 9 | public async execute(): Promise { 10 | if (this.url.protocol !== "https:") 11 | throw new Error("Protocol must be HTTPS"); 12 | 13 | try { 14 | const targetUrl = new URL( 15 | `https://${this.url.host}/api/v1/statuses/${this.postId}`, 16 | ); 17 | const res = await axiosInstance.get(targetUrl.toString(), { 18 | headers: { 19 | "User-Agent": undefined, 20 | }, 21 | }); 22 | 23 | return await this.mastodonStatusToPost(res.data, this.url.host); 24 | } catch (e) { 25 | if (e instanceof AxiosError) { 26 | if (!e.response) throw new Error("Failed to reach API"); 27 | if (e.response.status === 404) 28 | throw new Error("Post not found. Is it private?"); 29 | } 30 | throw new Error("Unknown error trying to reach Mastodon instance"); 31 | } 32 | } 33 | 34 | private async mastodonStatusToPost( 35 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 36 | obj: any, 37 | host: string, 38 | ): Promise { 39 | let username = obj.account.acct; 40 | if (!username.includes("@")) { 41 | console.debug("Local toot, looking up username from WebFinger..."); 42 | // A local username, time for WebFinger lookup... 43 | const webFingerURL = new URL(`https://${host}/.well-known/webfinger`); 44 | webFingerURL.searchParams.set("resource", `acct:${username}@${host}`); 45 | try { 46 | const res = await axiosInstance(webFingerURL.toString()); 47 | 48 | const subject = res.data.subject; 49 | 50 | username = new URL(subject).pathname; 51 | } catch (e) { 52 | // If WebFinger lookup fails, just resort to lazy domain 53 | username = `${username}@${host}`; 54 | } 55 | } 56 | 57 | username = `@${username}`; 58 | 59 | // Emoji replacer (quality code 100%) 60 | let content = obj.content; 61 | 62 | obj.emojis.forEach((emoji: { url: string; shortcode: string }) => { 63 | content = content.replaceAll( 64 | `:${emoji.shortcode}:`, 65 | ``, 66 | ); 67 | }); 68 | 69 | // Parse attachment 70 | const attachments = obj.media_attachments.map( 71 | (mediaAttachment: { 72 | type: string; 73 | url: string; 74 | meta: { original: { aspect: number } }; 75 | description?: string; 76 | }): Attachment => { 77 | console.log(mediaAttachment); 78 | return { 79 | type: mediaAttachment.type, 80 | url: mediaAttachment.url, 81 | aspectRatio: mediaAttachment.meta.original.aspect, 82 | description: mediaAttachment.description, 83 | }; 84 | }, 85 | ); 86 | 87 | let displayName = truncateString( 88 | obj.account.display_name === "" 89 | ? obj.account.username 90 | : obj.account.display_name, 91 | 30, 92 | ); 93 | 94 | obj.account.emojis.forEach((emoji: { url: string; shortcode: string }) => { 95 | displayName = displayName.replaceAll( 96 | `:${emoji.shortcode}:`, 97 | ``, 98 | ); 99 | }); 100 | 101 | const pollTotal = 102 | obj.poll?.options.reduce( 103 | (acc: number, option: { votes_count: number }) => 104 | acc + option.votes_count, 105 | 0, 106 | ) || 1; 107 | 108 | const poll = obj.poll?.options.map( 109 | (option: { title: string; votes_count: number }) => ({ 110 | title: option.title, 111 | votesCount: option.votes_count, 112 | percentage: Math.round((option.votes_count / pollTotal) * 100), 113 | }), 114 | ); 115 | 116 | return { 117 | username, 118 | plainUsername: obj.account.username, 119 | displayName, 120 | avatarUrl: obj.account.avatar, 121 | boosts: obj.reblogs_count, 122 | comments: obj.replies_count, 123 | postURL: obj.url, 124 | profileURL: obj.account.url, 125 | favourites: obj.favourites_count, 126 | content, 127 | attachments, 128 | poll, 129 | date: new Date(obj.created_at), 130 | }; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | import { Post } from "../components/PostItem"; 2 | import lang, { LangItem } from "../lang"; 3 | import { Options } from "../config"; 4 | 5 | const POST_REGEXES = [ 6 | /^\/@\w+(?:@[\w-.]+)?\/(\d+)$/, // instance.social/@user/postid 7 | /^\/users\/\w+(?:@[\w-.]+)?\/statuses\/(\d+)$/, // instance.social/users/user/statuses/postid 8 | ]; 9 | 10 | /** 11 | * @deprecated Replacing with multi-instance support 12 | */ 13 | export function parseUrl(inputURL: string) { 14 | try { 15 | const url = new URL(inputURL); 16 | const regex = POST_REGEXES.find((regex) => url.pathname.match(regex)); 17 | if (!regex) return null; 18 | 19 | const regexMatch = url.pathname.match(regex); 20 | if (!regexMatch) return null; 21 | 22 | const postId = parseInt(regexMatch[1]); 23 | 24 | if (isNaN(postId)) return null; 25 | 26 | return { 27 | host: url.host, 28 | protocol: url.protocol, 29 | postId: regexMatch[1], // Not actually returning int, because ID's are too long for JavaScript :/ 30 | }; 31 | } catch (e) { 32 | return null; 33 | } 34 | } 35 | 36 | /** 37 | * @deprecated Replacing with multi-instance support 38 | */ 39 | export const getPostApiPath = (id: string) => `/api/v1/statuses/${id}`; 40 | 41 | export const truncateString = (str: string, num: number) => { 42 | const arr = Array.from(str); 43 | if (arr.length > num) { 44 | return arr.slice(0, num).join("") + "…"; 45 | } else { 46 | return str; 47 | } 48 | }; 49 | 50 | export function downloadURI(uri: string, name: string) { 51 | const link = document.createElement("a"); 52 | link.download = name; 53 | link.href = uri; 54 | document.body.appendChild(link); 55 | link.click(); 56 | document.body.removeChild(link); 57 | } 58 | 59 | export function formatDate(date: Date) { 60 | const months = [ 61 | "Jan", 62 | "Feb", 63 | "Mar", 64 | "Apr", 65 | "May", 66 | "Jun", 67 | "Jul", 68 | "Aug", 69 | "Sep", 70 | "Oct", 71 | "Nov", 72 | "Dec", 73 | ]; 74 | 75 | const formattedDate = new Date(date); 76 | const year = formattedDate.getFullYear(); 77 | const month = months[formattedDate.getMonth()]; 78 | const day = formattedDate.getDate(); 79 | const hours = formattedDate.getHours().toString().padStart(2, "0"); 80 | const minutes = formattedDate.getMinutes().toString().padStart(2, "0"); 81 | 82 | return `${month} ${day}, ${year}, ${hours}:${minutes}`; 83 | } 84 | 85 | export function generateAltText(post: Post, options: Options) { 86 | const language = (lang[options.language] as LangItem) || lang.en; 87 | 88 | const content = 89 | document.getElementById("content")?.innerText || language.fetchError; 90 | 91 | let attachmentsText = ""; 92 | if (post.attachments.length === 1) { 93 | const altText = post.attachments[0].description; 94 | attachmentsText = `${language.attachments.single.attachments} ${ 95 | altText 96 | ? language.attachments.single.has.replace("{altText}", altText) 97 | : language.attachments.single.hasNot 98 | }`; 99 | } else if (post.attachments.length > 1) { 100 | attachmentsText = language.attachments.multiple.attachments.replace( 101 | "{count}", 102 | `${post.attachments.length}`, 103 | ); 104 | post.attachments.forEach((attachment, index) => { 105 | attachmentsText += attachment.description 106 | ? language.attachments.multiple.has 107 | .replace("{index}", `${index + 1}`) 108 | .replace("{altText}", attachment.description) 109 | : language.attachments.multiple.hasNot.replace( 110 | "{index}", 111 | `${index + 1}`, 112 | ); 113 | }); 114 | } 115 | 116 | const pollText = post.poll 117 | ? `${language.poll.intro} ${post.poll 118 | .map((i) => 119 | language.poll.item 120 | .replace("{percentage}", `${i.percentage}`) 121 | .replace("{title}", i.title), 122 | ) 123 | .join(", ")}` 124 | : ""; 125 | const intro = language.intro 126 | .replace("{displayName}", post.displayName) 127 | .replace("{username}", post.username) 128 | .replace("{date}", formatDate(post.date)) 129 | .replace("{favourites}", `${post.favourites}`) 130 | .replace("{boosts}", `${post.boosts}`) 131 | .replace("{replies}", `${post.comments}`); 132 | 133 | return [intro, attachmentsText, pollText, content] 134 | .filter((i) => i !== "") 135 | .join("\n\n"); 136 | } 137 | 138 | export async function copyAltText(post: Post, options: Options) { 139 | try { 140 | const permRes = await navigator.permissions.query({ 141 | name: "clipboard-write", 142 | } as never); 143 | 144 | if (permRes.state === "denied" || permRes.state === "prompt") 145 | throw new Error("Access to clipboard was blocked"); 146 | } catch (e) { 147 | if (e instanceof TypeError) { 148 | console.log("Browser does not support clipboard-write permission"); 149 | } else { 150 | throw new Error("Access to clipboard was blocked"); 151 | } 152 | } 153 | 154 | navigator.clipboard.writeText(generateAltText(post, options)); 155 | } 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Mastopoet Banner, AI generated, by @rolle@mementomori.social](https://raw.githubusercontent.com/raikasdev/mastopoet/main/public/mastopoet.jpg) 2 | 3 | # Mastopoet 4 | 5 | The post screenshot tool for Mastodon. Developed by [@raikas@mementomori.social](https://mementomori.social/@raikas) 6 | 7 | **If you like Mastopoet, consider supporting the developer:** 8 | 9 | [![Buy me a coffee!](https://img.shields.io/badge/Buy_me_a_coffee!-F16061.svg?logo=ko-fi&logoColor=white&style=for-the-badge)](https://ko-fi.com/raikasdev) 10 | 11 | ## What? 12 | 13 | Mastopoet is an open source screenshot tool for [Mastodon](https://joinmastodon.org), inspired by [poet.so](https://poet.so). 14 | It allows you to create ✨ stunning ✨ screenshots of your posts, with ability to remove stuff you don't want (bookmark button, publicity symbol, settings button...). And with a theme that's not dependent on the theme of the instance! 15 | 16 | ## Demo 17 | 18 | - [mastopoet.raikas.dev](https://mastopoet.raikas.dev) (developer's instance, always on latest commit) 19 | - [poet.bolha.us](https://poet.bolha.us) 20 | 21 | ## URL Query feature 22 | 23 | Add ?url= to the end of the Mastopoet URL to generate links that immediately open the post on Mastopoet. 24 | 25 | ## Embedding 26 | 27 | You can embed a post with Mastopoet using an iframe. 28 | 29 | ```html 30 | 36 | ``` 37 | 38 | You can also use the following query parameters (`&key=value`) to customize the embed: 39 | 40 | - `theme` (bird-ui, bird-ui-light, mastodon, mastodon-light) 41 | - `interactions` (feed, feed no-date, normal, normal no-replies, hidden) 42 | - `background` Any valid value for CSS `background` (hex values, linear-gradient...) 43 | - `width` The background width in pixels (0-400, default 0), also remember to modify the iframe width! 44 | - `height` The background width in pixels (0-200, default 0) 45 | 46 | ## Themes available 47 | 48 | - [Bird UI](https://github.com/ronilaukkarinen/mastodon-bird-ui) (Dark, Light) 49 | - Mastodon (Dark, Dark + light interaction labels, Light) 50 | 51 | ## Images/profile pictures not working? 52 | 53 | This is due to CORS. It's a security feature of browsers, when assets (like profile pictures) are accessed from other websites. ~~I cannot do anything about it, as the whole process is done client-side.~~ From version 1.0.3, Mastopoet uses a proxy server if CORS is blocked, so this shouldn't happen anymore. If it does, please contact me at [roni@raikas.dev](mailto:roni@raikas.dev). Thank you! 54 | 55 | Help for old versions: 56 | 57 | The images are (almost) always fetched from the instance that your link is from. So, if your link is https://instance1.social/@user@instace2.social/0000000000, the data is fetched from instance1.social, even if the post is posted originally on instance2.social. 58 | 59 | If you want to get Mastopoet working, contact that instances admin and ask them to allow anonymous CORS (crossOrigin: "anonymous") requests for their Mastodon media server. 60 | 61 | OR: You can try using an other instance and find the post. Here's a few working instances: 62 | 63 | - [mstdn.social](https://mstdn.social) 64 | - [mementomori.social](https://mementomori.social) 65 | - [bolha.us](https://bolha.us) 66 | 67 | **For admins** this means they need to add the `Access-Control-Allow-Origin` header to the server providing your users with images hosted on Mastodon. You can set it to '\*', or allow just Mastopoet with 'https://mastopoet.raikas.dev'. There's more technical information on [MDN Docs](https://developer.mozilla.org/en-US/docs/Web/HTML/CORS_enabled_image). 68 | 69 | ## Deploying 70 | 71 | You can use Docker (instructions below), or simply host the website on a static platform. Mastopoet is a React SPA, compiled by Vite, allowing it to be deployed to pretty much any hosting service. My recommendations are Cloudflare Pages, Netlify and Github Pages. 72 | 73 | First, install dependencies with `npm install` and then build the app with `npm run build`. Simple as that! The application is in the `dist` directory. 74 | 75 | ### Ko-fi advert 76 | 77 | By default, the default post shows a link to my Ko-fi profile. If you want to disable it, you can customize the defaultPost in `src/config.ts`, or set environment value `VITE_HIDE_DEVELOPER_KOFI_AD` to false. I appreciate if you decide to keep it. 78 | 79 | ## Building with docker 80 | 81 | You can use docker for deploying a production ready instance of Mastopoet. 82 | 83 | You can build with: 84 | 85 | ```console 86 | docker build -t mastopoet . 87 | ``` 88 | 89 | It will build the application and deploy in an nginx instance, when the image is built you can run using: 90 | 91 | ```console 92 | docker run -d -p 80:80 mastopoet 93 | ``` 94 | 95 | For more options, see [nginx container options at dockerhub](https://hub.docker.com/_/nginx) 96 | 97 | ## TODO 98 | 99 | - [x] Customizable gradient 100 | - [x] Bird UI light theme port 101 | - [x] Mastodon theme port 102 | - [ ] Customizable date format 103 | - [x] A logo (to website embed) 104 | - [x] Read toot URL from query 105 | - [x] Default toot with information 106 | - [x] Fix multi image image galleries 107 | - [x] Support for non-Mastodon links 108 | - [x] Alt text generator 109 | - [ ] PDF export with link ([idea](https://mementomori.social/@JMTee@mstdn.social/110790253659999588)) 110 | - [x] Detect CORS failed images and show user info box 111 | - [x] If toot div is not an full integer, weird bars show in image. 112 | - [x] Add support for Misskey/Firefish (src/instance/Misskey.ts) 113 | 114 | ## Credits 115 | 116 | - [Mastodon Bird UI](https://github.com/ronilaukkarinen/mastodon-bird-ui/) by Roni Laukkarinen, licensed under MIT 117 | - [Mastodon](https://github.com/mastodon/mastodon) 118 | - [Tabler Icons](https://tabler-icons.io), licensed under MIT 119 | - The beautiful AI generated OpenGraph image, created by [Roni Laukkarinen](https://mementomori.social/@rolle) 120 | -------------------------------------------------------------------------------- /src/components/OptionsEditor.tsx: -------------------------------------------------------------------------------- 1 | import { InteractionsPreference, Options, Theme } from "../config"; 2 | 3 | interface OptionsProps { 4 | options: Options; 5 | setOptions: (options: Options) => void; 6 | width: number; 7 | } 8 | 9 | const colorPresets = [ 10 | "linear-gradient(to bottom right, #fc5c7d, #6a82fb)", 11 | "linear-gradient(to bottom right, #FF61D2, #FE9090)", 12 | "linear-gradient(to bottom right, #FD8451, #FFBD6F)", 13 | "linear-gradient(to bottom right, #00C0FF, #4218B8)", 14 | "linear-gradient(to bottom right, #FDFCFB, #E2D1C3)", 15 | "linear-gradient(to bottom right, #FF3E9D, #0E1F40)", 16 | "linear-gradient(to bottom right, #12c2e9, #c471ed, #f64f59)", 17 | ]; 18 | 19 | export default function OptionsEditor({ 20 | options, 21 | setOptions, 22 | width, 23 | }: OptionsProps) { 24 | return ( 25 |
26 |
27 |
28 | 29 | 48 |
49 |
50 | 51 | 68 |
69 |
70 |
71 |
72 | 73 | 91 |
92 |
93 | 94 | 109 |
110 |
111 |
112 | 113 |
114 | {colorPresets.map((color, index) => ( 115 | 156 |
157 |
158 |
159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from "react"; 2 | import { Post } from "../components/PostItem"; 3 | import html2canvas from "html2canvas"; 4 | import { copyAltText, downloadURI, generateAltText } from "../utils/util"; 5 | import emojiRegex from "emoji-regex"; 6 | import emoji from "emojilib"; 7 | import PostContainer from "../components/PostContainer"; 8 | import useMinmaxState from "../utils/use-minmax-state"; 9 | import { 10 | Options, 11 | defaultHeight, 12 | defaultWidth, 13 | maxHeight, 14 | maxWidth, 15 | welcomePost, 16 | } from "../config"; 17 | import SearchForm from "../components/SearchForm"; 18 | import { useObjectState } from "../utils/use-object-state"; 19 | import OptionsEditor from "../components/OptionsEditor"; 20 | import fetchPost from "../instance/_main"; 21 | 22 | import CORSAlert from "../components/CORSAlert"; 23 | import addExif from "../utils/piexif"; 24 | 25 | function IndexPage() { 26 | const [post, setPost] = useState(welcomePost); 27 | const [message, setMessage] = useState(""); 28 | const [rendering, setRendering] = useState(false); 29 | const [options, setOptions] = useObjectState( 30 | localStorage.getItem("options") 31 | ? JSON.parse(localStorage.getItem("options") as string) 32 | : { 33 | theme: "bird-ui", 34 | interactions: "feed", 35 | background: "linear-gradient(to right, #fc5c7d, #6a82fb)", 36 | scale: 2, 37 | language: "en", 38 | }, 39 | ); 40 | const [width, setWidth] = useMinmaxState(defaultWidth, 0, maxWidth); 41 | const [height, setHeight] = useMinmaxState(defaultHeight, 0, maxHeight); 42 | 43 | const [corsHost, setCorsHost] = useState(""); 44 | 45 | // Saving options to local storage 46 | useEffect(() => { 47 | localStorage.setItem("options", JSON.stringify(options)); 48 | }, [options]); 49 | 50 | /** Screenshotting */ 51 | const screenshotRef = useRef(null); 52 | const exportImage = async () => { 53 | setRendering(true); 54 | }; 55 | 56 | useEffect(() => { 57 | if (!rendering) return; 58 | (async () => { 59 | if (screenshotRef.current) { 60 | const canvas = await html2canvas(screenshotRef.current, { 61 | allowTaint: true, 62 | useCORS: true, 63 | scale: options.scale, 64 | backgroundColor: "#1e2028", // TODO: From theme! 65 | ignoreElements: (element) => element.classList.contains("handlebar"), 66 | }); 67 | const timeStamp = post?.date 68 | .toLocaleDateString("en-GB") 69 | .split("/") 70 | .join(""); // DDMMYYYY 71 | 72 | const dataUri = canvas.toDataURL("image/jpeg", 1.0); 73 | const altText = generateAltText(post, options).replaceAll( 74 | emojiRegex(), 75 | (value) => { 76 | if (!emoji[value]) return "unknown emoji"; 77 | return `${(emoji[value][0] ?? "unknown").replaceAll( 78 | "_", 79 | " ", 80 | )} emoji`; 81 | }, 82 | ); // Need to make it safe for EXIF 83 | 84 | try { 85 | downloadURI( 86 | addExif(dataUri, altText), 87 | `mastopoet-${ 88 | post?.plainUsername || "unknown-user" 89 | }-${timeStamp}.jpg`, 90 | ); 91 | } catch (e) { 92 | console.error(e); 93 | try { 94 | downloadURI( 95 | dataUri, 96 | `mastopoet-${ 97 | post?.plainUsername || "unknown-user" 98 | }-${timeStamp}.jpg`, 99 | ); 100 | } catch (e) { 101 | setMessage("Saving failed due to CORS issues."); 102 | return; 103 | } 104 | setMessage("Saving with EXIF metadata failed."); 105 | } 106 | } 107 | setRendering(false); 108 | })(); 109 | }, [rendering]); 110 | 111 | useEffect(() => { 112 | const params = new URLSearchParams(window.location.search); 113 | const url = params.get("url"); 114 | 115 | if (url) { 116 | (async () => { 117 | try { 118 | const response = await fetchPost(url); 119 | setPost(response); 120 | setHeight(defaultHeight); 121 | setWidth(defaultWidth); 122 | setCorsHost(""); 123 | } catch (e) { 124 | setMessage("Query URL is not a valid post"); 125 | } 126 | })(); 127 | } 128 | }, []); 129 | 130 | /** End screenshotting */ 131 | 132 | return ( 133 | <> 134 |
135 |

Mastopoet

136 |

137 | The Mastodon post screenshot tool, running v{__APP_VERSION__} ( 138 | 142 | {__COMMIT_HASH__} 143 | 144 | ) 145 |

146 |

{message}

147 |
148 | {corsHost !== "" && } 149 | 150 | { 152 | setMessage(""); 153 | try { 154 | const response = await fetchPost(url); 155 | setPost(response); 156 | setHeight(defaultHeight); 157 | setWidth(defaultWidth); 158 | setCorsHost(""); 159 | } catch (e) { 160 | if (e instanceof Error) { 161 | return setMessage(e.message); 162 | } 163 | setMessage("Unknown error occurred"); 164 | } 165 | }} 166 | /> 167 | {post && ( 168 | 173 | )} 174 |
178 | {post && ( 179 | <> 180 | 183 | 189 | 190 | )} 191 |
192 | {post && ( 193 | setCorsHost(host)} 203 | /> 204 | )} 205 | 206 | ); 207 | } 208 | 209 | export default IndexPage; 210 | -------------------------------------------------------------------------------- /src/components/EmbedPostItem.tsx: -------------------------------------------------------------------------------- 1 | import { formatDate } from "../utils/util"; 2 | import DOMPurify from "dompurify"; 3 | import { PostItemProps } from "./PostItem"; 4 | 5 | const CORS_PROXY = "https://corsproxy.io"; 6 | 7 | export default function EmbedPostItem({ 8 | post, 9 | refInstance, 10 | interactionsPref, 11 | onImageLoadError, 12 | options, 13 | }: PostItemProps) { 14 | const { 15 | displayName, 16 | username, 17 | avatarUrl, 18 | content, 19 | favourites, 20 | boosts, 21 | comments, 22 | attachments, 23 | date, 24 | postURL, 25 | profileURL, 26 | } = post; 27 | 28 | return ( 29 |
30 | 69 |
74 | {attachments.length !== 0 && ( 75 |
76 |
83 | {attachments.map((attachment) => { 84 | if (attachment.type === "image") 85 | return ( 86 | { 93 | if (currentTarget.src.startsWith(CORS_PROXY)) { 94 | onImageLoadError(new URL(avatarUrl).host); 95 | } else { 96 | // Try CORS proxy 97 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent( 98 | avatarUrl, 99 | )}`; 100 | } 101 | }} 102 | /> 103 | ); 104 | if (attachment.type === "gifv") 105 | return ( 106 |
130 |
131 | )} 132 | {post.poll && ( 133 |
134 | {post.poll.map((option) => ( 135 |
136 |

137 | {option.percentage}% {option.title} 138 |

139 |
i.votesCount) 142 | .sort((a, b) => b - a)[0] === option.votesCount 143 | ? "winner" 144 | : "" 145 | }`} 146 | style={{ width: `${option.percentage}% ` }} 147 | /> 148 |
149 | ))} 150 |
151 | )} 152 | {post.reactions && ( 153 |
154 | {post.reactions?.map((val, index) => ( 155 |
156 | {val.value && ( 157 | {val.value} 158 | )} 159 | {val.url && ( 160 | 161 | )} 162 | {val.count} 163 |
164 | ))} 165 |
166 | )} 167 |
168 | {formatDate(date)} 169 |
170 | 171 | {comments} 172 | Replies 173 |
174 |
175 | 176 | {boosts} 177 | Boosts 178 |
179 | {!post.reactions && ( 180 |
181 | 182 | {favourites} 183 | Favourites 184 |
185 | )} 186 |
187 |
188 | ); 189 | } 190 | -------------------------------------------------------------------------------- /src/styles/App.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | .app { 7 | display: flex; 8 | flex-direction: column; 9 | justify-items: center; 10 | } 11 | 12 | .center-text { 13 | text-align: center; 14 | } 15 | 16 | .search-form { 17 | display: flex; 18 | font-family: 19 | system-ui, 20 | -apple-system, 21 | BlinkMacSystemFont, 22 | avenir next, 23 | avenir, 24 | segoe ui, 25 | Inter, 26 | helvetica neue, 27 | helvetica, 28 | Cantarell, 29 | Ubuntu, 30 | roboto, 31 | noto, 32 | arial, 33 | sans-serif, 34 | " Apple Color Emoji", 35 | Segoe UI Emoji, 36 | Segoe UI Symbol, 37 | Noto Color Emoji; 38 | gap: 0rem; 39 | justify-content: center; 40 | margin-bottom: 2rem; 41 | } 42 | 43 | .search { 44 | border: none; 45 | border-radius: 5rem 0 0 5rem; 46 | border-right: none; 47 | max-width: 500px; 48 | padding: 0.75rem; 49 | width: 80vw; 50 | 51 | &:focus { 52 | z-index: 1; 53 | } 54 | } 55 | 56 | .search-button { 57 | aspect-ratio: 1 / 1; 58 | background-color: var(--color-fg); 59 | border: none; 60 | border-left: none; 61 | border-radius: 0 5rem 5rem 0; 62 | color: var(--color-button); 63 | transition: background-color 0.1s; 64 | 65 | &:hover { 66 | background-color: var(--color-button); 67 | color: var(--color-fg); 68 | cursor: pointer; 69 | } 70 | } 71 | 72 | .options-editor { 73 | display: flex; 74 | flex-wrap: wrap; 75 | gap: 1rem; 76 | justify-content: center; 77 | margin: 1rem 0 2rem; 78 | 79 | .option-stack { 80 | display: flex; 81 | flex-direction: column; 82 | gap: 0.5rem; 83 | } 84 | 85 | .option { 86 | display: flex; 87 | flex-direction: column; 88 | 89 | select { 90 | appearance: none; 91 | 92 | /* stylelint-disable-next-line property-disallowed-list */ 93 | background: url("data:image/svg+xml, ") 94 | no-repeat; 95 | background-color: #15202b; 96 | background-position: calc(100% - 0.75rem) center; 97 | border: 0; 98 | border-radius: 0.25rem; 99 | color: white; 100 | padding: 0.5rem 2rem 0.5rem 1rem; 101 | } 102 | } 103 | 104 | label { 105 | margin-bottom: 0.5rem; 106 | } 107 | 108 | .color-grid { 109 | display: grid; 110 | gap: 0.5rem; 111 | grid-template-columns: repeat(4, minmax(0, 1fr)); 112 | 113 | .color { 114 | border: 0.15rem white solid; 115 | border-radius: 0.5rem; 116 | color: var(--color-button); 117 | content: ""; 118 | height: 50px; 119 | transition: all 0.2s; 120 | width: 50px; 121 | 122 | &:hover { 123 | color: var(--color-fg); 124 | cursor: pointer; 125 | outline: 0.25rem #f3f3f350 solid; 126 | } 127 | } 128 | } 129 | 130 | @media (max-width: 630px) { 131 | display: grid; 132 | } 133 | 134 | @media screen and (max-width: 400px) { 135 | flex-direction: column; 136 | } 137 | } 138 | 139 | .alert { 140 | background-color: #f91880bf; 141 | border-radius: 1rem; 142 | margin: 0.5rem 0; 143 | max-width: 600px; 144 | padding: 1rem; 145 | 146 | .alert-title { 147 | align-items: center; 148 | display: flex; 149 | gap: 0.25rem; 150 | justify-content: center; 151 | margin-top: 1rem; 152 | } 153 | 154 | a { 155 | color: white; 156 | } 157 | } 158 | 159 | .flex-center { 160 | display: flex; 161 | justify-content: center; 162 | margin-bottom: 2rem; 163 | 164 | &.button-grid { 165 | gap: 1rem; 166 | } 167 | } 168 | 169 | .toot { 170 | border-radius: 1rem; 171 | } 172 | 173 | .render-button { 174 | background-color: #6364ff; 175 | border: 0; 176 | border-radius: 5rem; 177 | color: var(--color-fg); 178 | font-family: 179 | system-ui, 180 | -apple-system, 181 | BlinkMacSystemFont, 182 | avenir next, 183 | avenir, 184 | segoe ui, 185 | Inter, 186 | helvetica neue, 187 | helvetica, 188 | Cantarell, 189 | Ubuntu, 190 | roboto, 191 | noto, 192 | arial, 193 | sans-serif, 194 | " Apple Color Emoji", 195 | Segoe UI Emoji, 196 | Segoe UI Symbol, 197 | Noto Color Emoji; 198 | font-size: 15px; 199 | font-weight: 500; 200 | line-height: 22px; 201 | padding: 7px 18px; 202 | transition: all 0.2s; 203 | 204 | &:hover { 205 | background-color: var(--color-button); 206 | cursor: pointer; 207 | } 208 | } 209 | 210 | .gradient-box { 211 | /* W3C, IE 10+/ Edge, Firefox 16+, Chrome 26+, Opera 12+, Safari 7+ */ 212 | position: relative; 213 | } 214 | 215 | .gradient { 216 | /* fallback for old browsers */ 217 | /* Chrome 10-25, Safari 5.1-6 */ 218 | @media screen and (max-width: 1024px) { 219 | background: transparent; 220 | } 221 | } 222 | 223 | .handlebar { 224 | position: absolute; 225 | touch-action: none; 226 | z-index: 1000; 227 | 228 | &.diagonal-right { 229 | bottom: -8px; 230 | right: -4px; 231 | 232 | svg { 233 | &:hover { 234 | cursor: nwse-resize; 235 | } 236 | } 237 | } 238 | 239 | &.diagonal-left { 240 | left: -4px; 241 | top: -16px; 242 | 243 | svg { 244 | &:hover { 245 | cursor: nwse-resize; 246 | } 247 | } 248 | } 249 | 250 | &.left { 251 | left: -4px; 252 | top: calc(50% - 8px); 253 | 254 | svg { 255 | &:hover { 256 | cursor: e-resize; 257 | } 258 | } 259 | } 260 | 261 | &.right { 262 | right: -4px; 263 | top: calc(50% - 8px); 264 | 265 | svg { 266 | &:hover { 267 | cursor: e-resize; 268 | } 269 | } 270 | } 271 | 272 | &.top { 273 | left: 50%; 274 | top: -12px; 275 | 276 | svg { 277 | &:hover { 278 | cursor: n-resize; 279 | } 280 | } 281 | } 282 | 283 | &.bottom { 284 | bottom: -12px; 285 | left: 50%; 286 | 287 | svg { 288 | &:hover { 289 | cursor: n-resize; 290 | } 291 | } 292 | } 293 | 294 | svg { 295 | color: white; 296 | touch-action: none; 297 | transition: all 0.2s; 298 | 299 | &.active { 300 | color: var(--color-button); 301 | transform: scale(2); 302 | } 303 | 304 | @media screen and (max-width: 1024px) { 305 | display: none; 306 | } 307 | } 308 | } 309 | 310 | .commit-link { 311 | color: #6364ff; 312 | text-decoration: none; 313 | transition: all 0.2s; 314 | 315 | &:hover { 316 | color: var(--color-button); 317 | } 318 | } 319 | 320 | .action-bar { 321 | flex-wrap: wrap; 322 | 323 | .emoji-reaction { 324 | align-items: center; 325 | background-color: #2a2c38; 326 | border-radius: 5px; 327 | display: flex; 328 | margin: 0.5rem; 329 | padding: 0.3rem; 330 | 331 | .emoji-reaction-custom { 332 | max-width: 20px; 333 | object-fit: cover; 334 | } 335 | 336 | .emoji-reaction-count { 337 | margin-left: 0.45rem; 338 | } 339 | } 340 | } 341 | -------------------------------------------------------------------------------- /src/components/PostItem.tsx: -------------------------------------------------------------------------------- 1 | import { ForwardedRef } from "react"; 2 | import { formatDate } from "../utils/util"; 3 | import { InteractionsPreference, Options } from "../config"; 4 | import DOMPurify from "dompurify"; 5 | 6 | export interface Post { 7 | displayName: string; 8 | plainUsername: string; 9 | username: string; 10 | postURL: string; // Embed 11 | profileURL: string; // Embed 12 | avatarUrl: string; 13 | content: string; // HTML! 14 | favourites: number; 15 | boosts: number; 16 | comments: number; 17 | attachments: Attachment[]; 18 | date: Date; 19 | poll?: { 20 | title: string; 21 | votesCount: number; 22 | percentage: number; 23 | }[]; 24 | reactions?: Reactions[]; 25 | } 26 | 27 | export interface Reactions { 28 | value?: string; 29 | url?: string; 30 | count: number; 31 | } 32 | 33 | export interface Attachment { 34 | type: string; 35 | url: string; 36 | aspectRatio: number; 37 | description?: string; 38 | } 39 | 40 | export interface PostItemProps { 41 | post: Post; 42 | refInstance?: ForwardedRef; 43 | interactionsPref: InteractionsPreference; 44 | onImageLoadError: (host: string) => void; 45 | options: Options; 46 | } 47 | 48 | const CORS_PROXY = "https://corsproxy.io"; 49 | 50 | export default function PostItem({ 51 | post, 52 | refInstance, 53 | interactionsPref, 54 | onImageLoadError, 55 | options, 56 | }: PostItemProps) { 57 | const { 58 | displayName, 59 | username, 60 | avatarUrl, 61 | content, 62 | favourites, 63 | boosts, 64 | comments, 65 | attachments, 66 | date, 67 | } = post; 68 | 69 | return ( 70 |
71 |
72 |
73 | {displayName} { 78 | if (currentTarget.src.startsWith(CORS_PROXY)) { 79 | onImageLoadError(new URL(avatarUrl).host); 80 | } else { 81 | // Try CORS proxy 82 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent( 83 | avatarUrl, 84 | )}`; 85 | } 86 | }} 87 | /> 88 |
89 | 90 | 91 | 96 | 97 | {username} 98 | {/** Replace with :has when Firefox starts supporting it */} 99 | {options.interactions === "feed" && ( 100 | {formatDate(date)} 101 | )} 102 | 103 |
104 |
109 | {attachments.length !== 0 && ( 110 |
111 |
118 | {attachments.map((attachment) => { 119 | if (attachment.type === "image") 120 | return ( 121 | { 128 | if (currentTarget.src.startsWith(CORS_PROXY)) { 129 | onImageLoadError(new URL(avatarUrl).host); 130 | } else { 131 | // Try CORS proxy 132 | currentTarget.src = `${CORS_PROXY}?url=${encodeURIComponent( 133 | avatarUrl, 134 | )}`; 135 | } 136 | }} 137 | /> 138 | ); 139 | if (attachment.type === "gifv") 140 | return ( 141 |
165 |
166 | )} 167 | {post.poll && ( 168 |
169 | {post.poll.map((option) => ( 170 |
171 |

172 | {option.percentage}% {option.title} 173 |

174 |
i.votesCount) 177 | .sort((a, b) => b - a)[0] === option.votesCount 178 | ? "winner" 179 | : "" 180 | }`} 181 | style={{ width: `${option.percentage}% ` }} 182 | /> 183 |
184 | ))} 185 |
186 | )} 187 | {post.reactions && ( 188 |
189 | {post.reactions?.map((val, index) => ( 190 |
191 | {val.value && ( 192 | {val.value} 193 | )} 194 | {val.url && ( 195 | 196 | )} 197 | {val.count} 198 |
199 | ))} 200 |
201 | )} 202 |
203 | {formatDate(date)} 204 |
205 | 206 | {comments} 207 | Replies 208 |
209 |
210 | 211 | {boosts} 212 | Boosts 213 |
214 | {!post.reactions && ( 215 |
216 | 217 | {favourites} 218 | Favourites 219 |
220 | )} 221 |
222 |
223 | ); 224 | } 225 | -------------------------------------------------------------------------------- /src/themes/BirdUi.scss: -------------------------------------------------------------------------------- 1 | .theme-bird-ui, 2 | .theme-bird-ui-light { 3 | $font-stack: system-ui, 4 | -apple-system, 5 | BlinkMacSystemFont, 6 | avenir next, 7 | avenir, 8 | segoe ui, 9 | Inter, 10 | helvetica neue, 11 | helvetica, 12 | Cantarell, 13 | Ubuntu, 14 | roboto, 15 | noto, 16 | arial, 17 | sans-serif, 18 | " Apple Color Emoji", 19 | "Segoe UI Emoji", 20 | "Segoe UI Symbol", 21 | "Noto Color Emoji"; 22 | $line-height: 22px; 23 | $profile-name-gap: 6px; 24 | $message-gap: 12px; 25 | $avatar-size: 48px; 26 | $icon-boost: url("data:image/svg+xml, %0A%3Csvg viewBox='0 0 24 24' color='inherit' width='18' height='18' xmlns='http://www.w3.org/2000/svg' %3E%3Cpath fill='%23717c9b' d='M6 4h15a1 1 0 0 1 1 1v7h-2V6H6v3L1 5l5-4v3zm12 16H3a1 1 0 0 1-1-1v-7h2v6h14v-3l5 4l-5 4v-3z'/%3E%3C/svg%3E"); 27 | $icon-reply: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='18' height='18' fill='%23717c9b' aria-hidden='true'%3E%3Cg%3E%3Cpath d='M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z'%3E%3C/path%3E%3C/g%3E%3C/svg%3E"); 28 | $icon-star: url('data:image/svg+xml, %3Csvg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" stroke="%23717c9b" stroke-width="5.5" viewBox="0 0 68 68"%3E%3Cpath d="M31.4 3.8c-.7.4-2.5 5-4.1 10.2l-2.9 9.5-9.9.5c-5.5.3-10.6.9-11.3 1.3-.6.5-1.2 1.9-1.2 3.3 0 2 1.5 3.4 8 7.5 4.4 2.8 8 5.5 8 6s-1.3 4.5-3 9.1c-3.6 9.7-3.7 11.4-.9 12.8 2.7 1.5 3.5 1.1 12.4-5.6l7.5-5.6 8.2 6.1c8.4 6.3 11.2 7.1 13.2 3.9.8-1.4.3-3.9-2.2-11-1.8-5.1-3.2-9.6-3.2-9.9 0-.4 3.6-3 8-5.8 6.5-4.1 8-5.5 8-7.5 0-1.4-.6-2.8-1.2-3.3-.7-.4-5.8-1-11.3-1.3l-9.9-.5-2.9-9.5C37.8 4.6 36.9 3 34 3c-.8 0-2 .4-2.6.8z"/%3E%3C/svg%3E%0A'); 29 | font-family: $font-stack; 30 | width: 600px; 31 | 32 | &.theme-bird-ui { 33 | --color-bg: #1e2028; 34 | --color-text: #f7f9f9; 35 | --color-text-dim: #717c9b; 36 | --color-text-link: #858afa; 37 | --color-border: #38384d; 38 | } 39 | 40 | &.theme-bird-ui-light { 41 | --color-bg: #f7f9f9; 42 | --color-text: #1e2028; 43 | --color-text-dim: #9388a6; 44 | --color-text-link: #858afa; 45 | --color-border: #e6e1ed; 46 | } 47 | 48 | .toot { 49 | background-color: var(--color-bg); 50 | color: var(--color-text); 51 | display: block; 52 | max-width: calc(600px - 4rem); 53 | padding: 2rem; 54 | width: 100%; 55 | 56 | .profile { 57 | display: flex; 58 | font-size: 15px; 59 | gap: 0.75em; 60 | height: 0.75rem; 61 | 62 | .display-name { 63 | display: flex; 64 | gap: $profile-name-gap; 65 | min-height: 30px; 66 | overflow: hidden; 67 | text-overflow: ellipsis; 68 | 69 | strong { 70 | font-weight: 500; 71 | overflow: hidden; 72 | text-overflow: ellipsis; 73 | white-space: nowrap; 74 | 75 | .emoji { 76 | height: 1em; 77 | margin: -1px 0 0; 78 | object-fit: contain; 79 | vertical-align: middle; 80 | width: 1em; 81 | } 82 | } 83 | 84 | a { 85 | color: inherit; 86 | text-decoration: none; 87 | } 88 | 89 | a:hover { 90 | text-decoration: underline; 91 | } 92 | 93 | .username { 94 | color: var(--color-text-dim); 95 | height: 1.5rem; 96 | overflow: hidden; 97 | text-overflow: ellipsis; 98 | white-space: nowrap; 99 | } 100 | 101 | .datetime { 102 | color: var(--color-text-dim); 103 | 104 | overflow: hidden; 105 | text-overflow: ellipsis; 106 | white-space: nowrap; 107 | 108 | &::before { 109 | align-items: center; 110 | color: var(--color-text-dim); 111 | content: "·"; 112 | line-height: 22px; 113 | margin-right: 8px; 114 | } 115 | } 116 | } 117 | 118 | .avatar img { 119 | border-radius: 100rem; 120 | height: $avatar-size; 121 | width: $avatar-size; 122 | } 123 | } 124 | 125 | .content { 126 | font-size: 15px; 127 | padding-left: calc($avatar-size + $message-gap); 128 | 129 | p { 130 | font-feature-settings: "kern"; 131 | font-style: normal; 132 | font-weight: 400; 133 | line-height: $line-height; 134 | text-rendering: optimizelegibility; 135 | text-size-adjust: none; 136 | } 137 | 138 | a { 139 | color: var(--color-text-link); 140 | text-decoration: none; 141 | unicode-bidi: isolate; 142 | } 143 | 144 | .emoji { 145 | height: 24px; 146 | margin: -1px 0 0; 147 | object-fit: contain; 148 | vertical-align: middle; 149 | width: 24px; 150 | } 151 | 152 | .invisible { 153 | display: none; 154 | } 155 | 156 | .ellipsis::after { 157 | content: "…"; 158 | } 159 | } 160 | 161 | .poll { 162 | font-size: 14px; 163 | padding-left: calc($avatar-size + $message-gap); 164 | 165 | .poll-option { 166 | margin-bottom: 10px; 167 | 168 | .option-title { 169 | line-height: 16px; 170 | margin: 0; 171 | padding: 6px 0; 172 | 173 | strong { 174 | display: inline-block; 175 | width: 45px; 176 | } 177 | } 178 | 179 | .option-bar { 180 | background-color: var(--color-text-dim); 181 | border-radius: 4px; 182 | display: block; 183 | height: 5px; 184 | min-width: 1%; 185 | } 186 | } 187 | } 188 | 189 | // Notice! Image attachments are not inside content. 190 | .gallery-holder { 191 | padding-left: calc($avatar-size + $message-gap); 192 | .image-gallery { 193 | border: 1px solid var(--color-border); 194 | border-radius: 16px; 195 | box-sizing: border-box; 196 | display: grid; 197 | 198 | width: 100%; 199 | 200 | .attachment { 201 | border-radius: 8px; 202 | height: 100%; 203 | object-fit: cover; 204 | width: 100%; 205 | } 206 | } 207 | } 208 | 209 | .action-bar { 210 | display: flex; 211 | margin-top: 12px; 212 | padding-left: calc($avatar-size + $message-gap); 213 | 214 | &.action-bar-hidden { 215 | display: none; 216 | } 217 | 218 | &.action-bar-feed { 219 | // Action bar is 568px wide, split to 5 (space-between) 220 | // -> 133.6, remove 50px (each buttons width) 221 | // -> 83.6px 222 | gap: 83.6px; 223 | margin-top: 24px; 224 | 225 | .action-bar-datetime { 226 | display: none; 227 | } 228 | 229 | .action { 230 | color: var(--color-text-dim); 231 | display: inline-flex; 232 | width: 50px; 233 | 234 | .icon-boost::before { 235 | content: $icon-boost; 236 | } 237 | 238 | .icon-reply::before { 239 | content: $icon-reply; 240 | } 241 | 242 | .icon-star::before { 243 | content: $icon-star; 244 | } 245 | 246 | .action-counter { 247 | display: inline-block; 248 | font-size: 13px; 249 | font-weight: 500; 250 | margin-inline-start: 4px; 251 | } 252 | 253 | .action-label { 254 | display: none; 255 | } 256 | } 257 | } 258 | 259 | &.action-bar-normal { 260 | font-size: 15px; 261 | gap: 6px; 262 | 263 | .action-bar-datetime { 264 | color: var(--color-text-dim); 265 | } 266 | 267 | &.no-replies { 268 | div:first-of-type { 269 | display: none; 270 | } 271 | } 272 | 273 | .action { 274 | display: inline-flex; 275 | gap: 4px; 276 | 277 | .action-counter { 278 | font-weight: 700; 279 | } 280 | 281 | .action-label { 282 | color: var(--color-text-dim); 283 | font-weight: 500; 284 | } 285 | 286 | &::before { 287 | align-items: center; 288 | color: var(--color-text-dim); 289 | content: "·"; 290 | line-height: 22px; 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/instance/Misskey.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { Attachment, Post, Reactions } from "../components/PostItem"; 3 | import BaseInstance from "./BaseInstance"; 4 | import { axiosInstance } from "../utils/axios"; 5 | 6 | interface MisskeyReaction { 7 | [emojiName: string]: number; 8 | } 9 | 10 | interface MisskeyGuestReaction { 11 | [emojiName: string]: string; 12 | } 13 | 14 | interface MisskeyInstanceInterface { 15 | name: string; 16 | softwareName: string; 17 | softwareVersion: string; 18 | iconUrl: string; 19 | faviconUrl: string; 20 | themeColor: string; 21 | } 22 | 23 | interface MisskeyUser { 24 | id: string; 25 | name: string; 26 | username: string; 27 | host?: string; 28 | avatarUrl: string; 29 | avatarBlurhash: string; 30 | isAdmin: boolean; 31 | isModerator: boolean; 32 | isBot?: boolean; 33 | isCat?: boolean; 34 | isLocked?: boolean; 35 | speakAsCat?: boolean; 36 | instance?: MisskeyInstanceInterface; 37 | emojis: MisskeyReaction; 38 | } 39 | 40 | interface MisskeyFileProperty { 41 | width: number; 42 | height: number; 43 | orientation: number; 44 | avgColor: string; 45 | } 46 | 47 | interface MisskeyFile { 48 | id: string; 49 | createdAt: string; 50 | name: string; 51 | type: string; 52 | md5: string; 53 | size: number; 54 | isSensitive: boolean; 55 | blurHash: string; 56 | properties: MisskeyFileProperty; 57 | url: string; 58 | thumbnailUrl: string; 59 | comment: string; 60 | userId: string; 61 | user: MisskeyUser; 62 | } 63 | 64 | interface MisskeyNotesResponse { 65 | id: string; 66 | createdAt: string; 67 | deletedAt: string; 68 | text?: string; 69 | cw: string; 70 | userId: string; 71 | user: MisskeyUser; 72 | replyId?: string; 73 | renoteId?: string; 74 | reply?: MisskeyNotesResponse; 75 | renote?: MisskeyNotesResponse; 76 | isHidden: boolean; 77 | visibility: string; 78 | mentions: string[]; 79 | visibleUserIds: string[]; 80 | fileIds: string[]; 81 | files: MisskeyFile[]; 82 | tags: string; 83 | channelId: string; 84 | channel: object; 85 | localOnly: boolean; 86 | reactions: MisskeyReaction; 87 | reactionEmojis: MisskeyGuestReaction; 88 | renoteCount: number; 89 | repliesCount: number; 90 | emojis: MisskeyReaction; 91 | uri?: string; 92 | url?: string; 93 | } 94 | 95 | type TEmojiReplacer = { [emoji: string]: string }; 96 | 97 | class MisskeyCrossingInstanceException extends Error {} 98 | 99 | export default class MisskeyInstance extends BaseInstance { 100 | private regexEmojiMatch: RegExp = /:[^:\s]*:/gm; 101 | private regexURIMatch: RegExp = 102 | /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/gm; 103 | private regexUserMatch: RegExp = 104 | /(\B@[\w\d-_]+@([\w\d-]+\.)+[\w-]{2,4})|(\B@[\w\d-_]+)/gm; 105 | 106 | public async execute(): Promise { 107 | // TODO: instance/api/notes/show [POST] 108 | // TODO: instance/api/users/show [POST] [If there's mentions over there] 109 | if (this.url.protocol !== "https:") 110 | throw new Error("Protocol must be HTTPS"); 111 | 112 | try { 113 | const uri = new URL(`https://${this.url.host}/api/notes/show`); 114 | const note = await axiosInstance.post(uri.toString(), { 115 | noteId: this.postId, 116 | }); 117 | const dataNote: MisskeyNotesResponse = note.data; 118 | 119 | if (dataNote.user.instance) 120 | throw new MisskeyCrossingInstanceException( 121 | `MISKEY_CROSSING_INSTANCE_${dataNote.user.instance.name}`, 122 | ); 123 | 124 | const username = `@${dataNote.user.username}@${this.url.host}`; 125 | 126 | const attachments: Attachment[] = dataNote.files.map((val) => { 127 | return { 128 | type: "image", 129 | url: val.url, 130 | aspectRatio: 1, 131 | description: val.comment, 132 | }; 133 | }); 134 | 135 | const avatarUrl = dataNote.user.avatarUrl; 136 | 137 | let content = ""; 138 | if (dataNote.text) { 139 | const regexContentEmoji = dataNote.text.match(this.regexEmojiMatch); 140 | const contentEmoji = await this.parseArrayEmoji( 141 | !regexContentEmoji ? [] : regexContentEmoji, 142 | ); 143 | content = this.parseContent(contentEmoji, dataNote.text); 144 | } 145 | 146 | const regexDisplayNameEmoji = dataNote.user.name.match( 147 | this.regexEmojiMatch, 148 | ); 149 | const displayNameEmoji = await this.parseArrayEmoji( 150 | !regexDisplayNameEmoji ? [] : regexDisplayNameEmoji, 151 | ); 152 | const displayName = this.replaceEmoji( 153 | displayNameEmoji, 154 | dataNote.user.name, 155 | ); 156 | 157 | const reactions = await this.fetchReaction( 158 | dataNote.reactions, 159 | dataNote.reactionEmojis, 160 | ); 161 | console.log(reactions); 162 | 163 | return { 164 | username, 165 | attachments, 166 | avatarUrl, 167 | content, 168 | displayName, 169 | reactions, 170 | plainUsername: dataNote.user.username, 171 | boosts: dataNote.renoteCount, 172 | comments: dataNote.repliesCount, 173 | postURL: dataNote.url || dataNote.uri || "", 174 | profileURL: `https://${dataNote.user.host}/@${dataNote.user.username}`, 175 | favourites: this.parseReactionToFavourites(dataNote.reactions), 176 | date: new Date(dataNote.createdAt), 177 | }; 178 | } catch (e) { 179 | console.error(e); 180 | if (e instanceof AxiosError) { 181 | if (!e.response) throw new Error("Failed to reach API"); 182 | if (e.response.status === 404) 183 | throw new Error("Post not found. Is it private?"); 184 | } 185 | if (e instanceof MisskeyCrossingInstanceException) 186 | throw new Error("Crossing instance for Misskey is not supported!"); 187 | 188 | throw new Error("Unknown error trying to reach Misskey instance"); 189 | } 190 | } 191 | 192 | // TODO: Can you change this to reaction list like Misskey things? 193 | private parseReactionToFavourites(reaction: MisskeyReaction): number { 194 | let count = 0; 195 | Object.keys(reaction).forEach((react) => { 196 | if (typeof reaction[react] !== "number") return; 197 | count += reaction[react]; 198 | }); 199 | return count; 200 | } 201 | 202 | private async getEmojiFromInstance( 203 | emoji: string, 204 | host: string = this.url.host, 205 | ): Promise { 206 | const uri = new URL(`https://${host}/api/emoji`); 207 | const emojiFetch = await axiosInstance.post(uri.toString(), { 208 | name: emoji, 209 | }); 210 | return emojiFetch.data.url as string; 211 | } 212 | 213 | private async fetchReaction( 214 | reaction: MisskeyReaction, 215 | guestReaction: MisskeyGuestReaction, 216 | ): Promise { 217 | const data: Reactions[] = []; 218 | 219 | await Promise.all( 220 | Object.keys(reaction).map(async (react) => { 221 | if (react[0] !== ":" && react[react.length - 1] !== ":") { 222 | data.push({ value: react, count: reaction[react] }); 223 | return; 224 | } 225 | 226 | const originalReact = react; 227 | react = react.replace("@.:", ":"); 228 | react = react.substring(1, react.length - 1); 229 | 230 | if (Object.keys(guestReaction).includes(react)) { 231 | data.push({ 232 | url: guestReaction[react], 233 | count: reaction[originalReact], 234 | }); 235 | return; 236 | } 237 | 238 | const guestDomain = react.match("@") 239 | ? react.split("@")[1] 240 | : this.url.host; 241 | react = guestDomain === this.url.host ? react : react.split("@")[0]; 242 | const emoji = await this.getEmojiFromInstance(react, guestDomain); 243 | 244 | data.push({ url: emoji, count: reaction[originalReact] }); 245 | }), 246 | ); 247 | 248 | return data; 249 | } 250 | 251 | private async parseArrayEmoji(setlist: string[]): Promise { 252 | setlist = setlist.map((val) => val.substring(1, val.length - 1)); 253 | const newSetlist = [...new Set(setlist)]; 254 | const retSetlist: { 255 | [emoji: string]: string; 256 | } = {}; 257 | 258 | await Promise.all( 259 | newSetlist.map(async (val) => { 260 | retSetlist[val] = await this.getEmojiFromInstance(val); 261 | }), 262 | ); 263 | 264 | return retSetlist; 265 | } 266 | 267 | private replaceEmoji(emojiList: TEmojiReplacer, text: string): string { 268 | Object.keys(emojiList).forEach((val) => { 269 | text = text.replaceAll( 270 | `:${val}:`, 271 | ``, 272 | ); 273 | }); 274 | return text; 275 | } 276 | 277 | private parseContent(emoji: TEmojiReplacer, text: string): string { 278 | // Set paragraph 279 | text = 280 | "

" + 281 | text.replace(/\n([ \t]*\n)+/g, "

").replace("\n", "
") + 282 | "

"; 283 | 284 | // Parch URI 285 | const matchURI = text.match(this.regexURIMatch); 286 | if (matchURI) 287 | matchURI.forEach((val) => { 288 | const uri = new URL(val); 289 | text = text.replace(val, `${val}`); 290 | }); 291 | 292 | // Parse hashtags 293 | const hashtags = text 294 | .replace(/\s+/g, " ") 295 | .split(" ") 296 | .filter((x) => x[0] === "#"); 297 | hashtags.forEach((val) => { 298 | text = text.replace( 299 | val, 300 | `${val}`, 303 | ); 304 | }); 305 | 306 | // Parse user 307 | const users = text.match(this.regexUserMatch); 308 | if (users) 309 | users.forEach((val) => { 310 | const uri = new URL(`https://${this.url.host}/${val}`); 311 | text = text.replace(val, `${val}`); 312 | }); 313 | 314 | // Parse Emoji 315 | text = this.replaceEmoji(emoji, text); 316 | 317 | return text; 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/themes/Mastodon.scss: -------------------------------------------------------------------------------- 1 | /* roboto-regular - latin */ 2 | @font-face { 3 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 4 | font-family: Roboto; 5 | font-style: normal; 6 | font-weight: 400; 7 | src: url("/roboto-v30-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 8 | } 9 | 10 | @font-face { 11 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 12 | font-family: Roboto; 13 | font-style: normal; 14 | font-weight: 500; 15 | src: url("/roboto-v30-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 16 | } 17 | 18 | .theme-mastodon, 19 | .theme-mastodon-light, 20 | .theme-mastodon-white-interactions { 21 | --color-poll-winner: #5e64f8; 22 | 23 | $font-stack: roboto, 24 | system-ui, 25 | -apple-system, 26 | BlinkMacSystemFont, 27 | avenir next, 28 | avenir, 29 | segoe ui, 30 | Inter, 31 | helvetica neue, 32 | helvetica, 33 | Cantarell, 34 | Ubuntu, 35 | noto, 36 | arial, 37 | sans-serif, 38 | " Apple Color Emoji", 39 | "Segoe UI Emoji", 40 | "Segoe UI Symbol", 41 | "Noto Color Emoji"; 42 | $line-height: 22px; 43 | $message-gap: 12px; 44 | $avatar-size: 48px; 45 | $icon-boost: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='22' height='22px' fill='%23606984' stroke='%23606984' stroke-width='4rem' viewBox='-96 224 2240 1472'%3E%3Cpath d='M1344 1504q0 13-9.5 22.5t-22.5 9.5h-960q-8 0-13.5-2t-9-7-5.5-8-3-11.5-1-11.5v-600h-192q-26 0-45-19t-19-45q0-24 15-41l320-384q19-22 49-22t49 22l320 384q15 17 15 41 0 26-19 45t-45 19h-192v384h576q16 0 25 11l160 192q7 10 7 21zm640-416q0 24-15 41l-320 384q-20 23-49 23t-49-23l-320-384q-15-17-15-41 0-26 19-45t45-19h192v-384h-576q-16 0-25-12l-160-192q-7-9-7-20 0-13 9.5-22.5t22.5-9.5h960q8 0 13.5 2t9 7 5.5 8 3 11.5 1 11.5v600h192q26 0 45 19t19 45z'%3E%3C/path%3E%3C/svg%3E"); 46 | $icon-reply: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18px' viewBox='0 0 512 512'%3E%3C!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cstyle%3Esvg%7Bfill:%23606984%7D%3C/style%3E%3Cpath d='M205 34.8c11.5 5.1 19 16.6 19 29.2v64H336c97.2 0 176 78.8 176 176c0 113.3-81.5 163.9-100.2 174.1c-2.5 1.4-5.3 1.9-8.1 1.9c-10.9 0-19.7-8.9-19.7-19.7c0-7.5 4.3-14.4 9.8-19.5c9.4-8.8 22.2-26.4 22.2-56.7c0-53-43-96-96-96H224v64c0 12.6-7.4 24.1-19 29.2s-25 3-34.4-5.4l-160-144C3.9 225.7 0 217.1 0 208s3.9-17.7 10.6-23.8l160-144c9.4-8.5 22.9-10.6 34.4-5.4z'/%3E%3C/svg%3E"); 47 | $icon-star: url("data:image/svg+xml, %3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18px' viewBox='0 0 576 512'%3E%3C!--! Font Awesome Free 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --%3E%3Cstyle%3Esvg%7Bfill:%23606984%7D%3C/style%3E%3Cpath d='M316.9 18C311.6 7 300.4 0 288.1 0s-23.4 7-28.8 18L195 150.3 51.4 171.5c-12 1.8-22 10.2-25.7 21.7s-.7 24.2 7.9 32.7L137.8 329 113.2 474.7c-2 12 3 24.2 12.9 31.3s23 8 33.8 2.3l128.3-68.5 128.3 68.5c10.8 5.7 23.9 4.9 33.8-2.3s14.9-19.3 12.9-31.3L438.5 329 542.7 225.9c8.6-8.5 11.7-21.2 7.9-32.7s-13.7-19.9-25.7-21.7L381.2 150.3 316.9 18z'/%3E%3C/svg%3E"); 48 | font-family: $font-stack; 49 | width: 600px; 50 | 51 | &.theme-mastodon { 52 | --color-bg: #313543; 53 | --color-text: #fff; 54 | --color-text-dim: #9baec8; 55 | --color-text-link: #8c8dff; 56 | --color-text-ultradim: #606984; 57 | --color-text-username: #fff; 58 | --color-interaction: var(--color-text-ultradim); 59 | --interaction-text-weight: 500; 60 | --color-border: #38384d; 61 | --color-hashtag: #d9e1e8; 62 | } 63 | 64 | &.theme-mastodon-white-interactions { 65 | --color-bg: #313543; 66 | --color-text: #f7f9f9; 67 | --color-text-dim: #9baec8; 68 | --color-text-link: #8c8dff; 69 | --color-text-ultradim: #606984; 70 | --color-text-username: #fff; 71 | --color-interaction: #d9e1e8; 72 | --interaction-text-weight: 700; 73 | --color-border: #38384d; 74 | --color-hashtag: #d9e1e8; 75 | } 76 | 77 | &.theme-mastodon-light { 78 | --color-bg: #fff; 79 | --color-text: #040404; 80 | --color-text-dim: #444b5d; 81 | --color-text-link: #5e64f8; 82 | --color-text-ultradim: #606984; 83 | --color-text-username: #000; 84 | --color-interaction: var(--color-text-ultradim); 85 | --interaction-text-weight: 500; 86 | --color-border: #38384d; 87 | --color-hashtag: #5e64f8; 88 | } 89 | 90 | .toot { 91 | background-color: var(--color-bg); 92 | color: var(--color-text); 93 | display: block; 94 | gap: 0rem; 95 | max-width: calc(600px - 4rem); 96 | padding: 2rem; 97 | position: relative; 98 | width: 100%; 99 | 100 | .profile { 101 | display: flex; 102 | gap: 0.75em; 103 | margin-bottom: 1rem; 104 | 105 | .display-name { 106 | display: flex; 107 | flex-direction: column; 108 | font-size: 15px; 109 | min-height: 30px; 110 | overflow: hidden; 111 | text-overflow: ellipsis; 112 | 113 | strong { 114 | font-weight: 500; 115 | overflow: hidden; 116 | text-overflow: ellipsis; 117 | white-space: nowrap; 118 | 119 | .emoji { 120 | height: 1em; 121 | margin: -1px 0 0; 122 | object-fit: contain; 123 | vertical-align: middle; 124 | width: 1em; 125 | } 126 | } 127 | 128 | .username { 129 | color: var(--color-text-username); 130 | height: 1.5rem; 131 | overflow: hidden; 132 | text-overflow: ellipsis; 133 | white-space: nowrap; 134 | } 135 | 136 | .datetime { 137 | color: var(--color-text-ultradim); 138 | overflow: hidden; 139 | position: absolute; 140 | right: 2rem; 141 | text-overflow: ellipsis; 142 | white-space: nowrap; 143 | } 144 | } 145 | 146 | .avatar img { 147 | border-radius: 4px; 148 | height: $avatar-size; 149 | width: $avatar-size; 150 | } 151 | } 152 | 153 | .content { 154 | font-size: 19px; 155 | 156 | p { 157 | font-feature-settings: "kern"; 158 | font-style: normal; 159 | font-weight: 400; 160 | line-height: $line-height; 161 | margin: 0; 162 | margin-bottom: 20px; 163 | text-rendering: optimizelegibility; 164 | text-size-adjust: none; 165 | } 166 | 167 | a { 168 | color: var(--color-text-link); 169 | text-decoration: none; 170 | unicode-bidi: isolate; 171 | } 172 | 173 | .emoji { 174 | height: 24px; 175 | margin: -1px 0 0; 176 | object-fit: contain; 177 | vertical-align: middle; 178 | width: 24px; 179 | } 180 | 181 | .invisible { 182 | display: none; 183 | } 184 | 185 | .ellipsis::after { 186 | content: "…"; 187 | } 188 | 189 | .hashtag { 190 | color: var(--color-hashtag); 191 | } 192 | } 193 | 194 | .poll { 195 | font-size: 14px; 196 | 197 | .poll-option { 198 | margin-bottom: 10px; 199 | 200 | .option-title { 201 | line-height: 16px; 202 | margin: 0; 203 | padding: 6px 0; 204 | 205 | strong { 206 | display: inline-block; 207 | width: 45px; 208 | } 209 | } 210 | 211 | .option-bar { 212 | background-color: var(--color-text-dim); 213 | border-radius: 4px; 214 | display: block; 215 | height: 5px; 216 | min-width: 1%; 217 | 218 | &.winner { 219 | background-color: var(--color-poll-winner); 220 | } 221 | } 222 | } 223 | } 224 | 225 | // Notice! Image attachments are not inside content. 226 | .gallery-holder { 227 | .image-gallery { 228 | border: 1px solid var(--color-border); 229 | border-radius: 16px; 230 | box-sizing: border-box; 231 | display: grid; 232 | 233 | width: 100%; 234 | 235 | .attachment { 236 | border-radius: 8px; 237 | height: 100%; 238 | object-fit: cover; 239 | width: 100%; 240 | } 241 | } 242 | } 243 | 244 | .action-bar { 245 | display: flex; 246 | margin-top: 12px; 247 | 248 | &.action-bar-hidden { 249 | display: none; 250 | } 251 | 252 | &.action-bar-feed { 253 | // Action bar is 568px wide, split to 5 (space-between) 254 | // -> 133.6, remove 50px (each buttons width) 255 | // -> 83.6px 256 | gap: 83.6px; 257 | margin-top: 24px; 258 | 259 | .action-bar-datetime { 260 | display: none; 261 | } 262 | 263 | .action { 264 | align-items: center; 265 | color: var(--color-interaction); 266 | display: inline-flex; 267 | width: 50px; 268 | 269 | .icon-boost { 270 | height: 22px; 271 | width: 22px; 272 | &::before { 273 | content: $icon-boost; 274 | } 275 | } 276 | 277 | .icon-reply { 278 | height: 18px; 279 | width: 20px; 280 | 281 | &::before { 282 | content: $icon-reply; 283 | } 284 | } 285 | 286 | .icon-star { 287 | height: 18px; 288 | width: 21px; 289 | 290 | &::before { 291 | content: $icon-star; 292 | } 293 | } 294 | 295 | .action-counter { 296 | display: inline-block; 297 | font-size: 12px; 298 | font-weight: var(--interaction-text-weight); 299 | margin-inline-start: 4px; 300 | margin-left: 4px; 301 | } 302 | 303 | .action-label { 304 | display: none; 305 | } 306 | } 307 | } 308 | 309 | &.action-bar-normal { 310 | font-size: 14px; 311 | gap: 6px; 312 | 313 | .action-bar-datetime { 314 | color: var(--color-text-ultradim); 315 | } 316 | 317 | &.no-replies { 318 | div:first-of-type { 319 | display: none; 320 | } 321 | } 322 | 323 | .action { 324 | color: var(--color-text-ultradim); 325 | display: inline-flex; 326 | gap: 4px; 327 | 328 | .action-counter { 329 | color: var(--color-interaction); 330 | font-weight: 500; 331 | } 332 | 333 | &::before { 334 | align-items: center; 335 | content: "·"; 336 | font-weight: 500; 337 | line-height: 22px; 338 | } 339 | } 340 | } 341 | } 342 | } 343 | } 344 | --------------------------------------------------------------------------------