Merge branch 'develop' of https://codeberg.org/calckey/calckey into keyboard-accessability
This commit is contained in:
commit
6631f77d5b
@ -1,16 +1,48 @@
|
|||||||
.autogen
|
# Visual Studio Code
|
||||||
.github
|
/.vscode
|
||||||
.travis
|
!/.vscode/extensions.json
|
||||||
.vscode
|
|
||||||
.config
|
# Intelij-IDEA
|
||||||
|
/.idea
|
||||||
|
packages/backend/.idea/backend.iml
|
||||||
|
packages/backend/.idea/modules.xml
|
||||||
|
packages/backend/.idea/vcs.xml
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules
|
||||||
|
report.*.json
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
cypress/screenshots
|
||||||
|
cypress/videos
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# config
|
||||||
|
/.config/*
|
||||||
|
!/.config/example.yml
|
||||||
|
!/.config/docker_example.env
|
||||||
|
|
||||||
|
#docker dev config
|
||||||
|
/dev/docker-compose.yml
|
||||||
|
|
||||||
|
# misskey
|
||||||
|
built
|
||||||
|
db
|
||||||
|
elasticsearch
|
||||||
|
redis
|
||||||
|
npm-debug.log
|
||||||
|
*.pem
|
||||||
|
run.bat
|
||||||
|
api-docs.json
|
||||||
|
*.log
|
||||||
|
*.code-workspace
|
||||||
|
.DS_Store
|
||||||
|
files
|
||||||
|
ormconfig.json
|
||||||
|
packages/backend/assets/instance.css
|
||||||
|
|
||||||
|
# dockerignore custom
|
||||||
|
.git
|
||||||
Dockerfile
|
Dockerfile
|
||||||
build/
|
|
||||||
built/
|
|
||||||
db/
|
|
||||||
docker-compose.yml
|
|
||||||
elasticsearch/
|
|
||||||
node_modules/
|
|
||||||
redis/
|
|
||||||
files/
|
|
||||||
misskey-assets/
|
|
||||||
.pnp.*
|
|
||||||
|
12
.gitignore
vendored
12
.gitignore
vendored
@ -28,23 +28,19 @@ coverage
|
|||||||
/dev/docker-compose.yml
|
/dev/docker-compose.yml
|
||||||
|
|
||||||
# misskey
|
# misskey
|
||||||
/build
|
|
||||||
built
|
built
|
||||||
/data
|
db
|
||||||
/.cache-loader
|
elasticsearch
|
||||||
/db
|
redis
|
||||||
/elasticsearch
|
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
*.pem
|
*.pem
|
||||||
run.bat
|
run.bat
|
||||||
api-docs.json
|
api-docs.json
|
||||||
*.log
|
*.log
|
||||||
/redis
|
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/files
|
files
|
||||||
ormconfig.json
|
ormconfig.json
|
||||||
/custom
|
|
||||||
packages/backend/assets/instance.css
|
packages/backend/assets/instance.css
|
||||||
|
|
||||||
# blender backups
|
# blender backups
|
||||||
|
@ -12,4 +12,4 @@ pipeline:
|
|||||||
# Secret 'docker_password' needs to be set in the CI settings
|
# Secret 'docker_password' needs to be set in the CI settings
|
||||||
from_secret: docker_password
|
from_secret: docker_password
|
||||||
|
|
||||||
branch: main
|
branches: main
|
||||||
|
15
.woodpecker/dockerHubReleaseCandidate.yml
Normal file
15
.woodpecker/dockerHubReleaseCandidate.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
pipeline:
|
||||||
|
publish-docker-latest:
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
repo: thatonecalculator/calckey
|
||||||
|
tags: rc
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
username:
|
||||||
|
# Secret 'docker_username' needs to be set in the CI settings
|
||||||
|
from_secret: docker_username
|
||||||
|
password:
|
||||||
|
# Secret 'docker_password' needs to be set in the CI settings
|
||||||
|
from_secret: docker_password
|
||||||
|
|
||||||
|
branches: beta
|
@ -8,4 +8,4 @@ pipeline:
|
|||||||
no_push: true
|
no_push: true
|
||||||
|
|
||||||
branches:
|
branches:
|
||||||
include: [ main, develop ]
|
include: [ main, develop, beta ]
|
||||||
|
45
CHANGELOG.md
45
CHANGELOG.md
@ -2,7 +2,34 @@
|
|||||||
|
|
||||||
All changes from v13.0.0 onwards, for a full list of differences read CALCKEY.md
|
All changes from v13.0.0 onwards, for a full list of differences read CALCKEY.md
|
||||||
|
|
||||||
## [13.1.1] - 2023-02-04
|
## [13.1.3] - 2023-02-09
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Hide unmute option when the user is blocked
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Feat: Mute and unfollow when blocking a user
|
||||||
|
|
||||||
|
- Feat: Unblock with follow button
|
||||||
|
|
||||||
|
- Refresh user when changed
|
||||||
|
|
||||||
|
- Feature/help_menu ([#9587](https://github.com/orhun/git-cliff/issues/9587))
|
||||||
|
|
||||||
|
Co-authored-by: ThatOneCalculator <kainoa@t1c.dev>
|
||||||
|
Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9587
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Chore: up vite
|
||||||
|
|
||||||
|
- Chore: update credits
|
||||||
|
|
||||||
|
|
||||||
|
## [13.1.2] - 2023-02-06
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
@ -95,6 +122,14 @@ Closes #9426
|
|||||||
|
|
||||||
- Fix: Use ❤️ instead of ♥️
|
- Fix: Use ❤️ instead of ♥️
|
||||||
|
|
||||||
|
- Fix: :bug: following issues
|
||||||
|
|
||||||
|
Closes #9544
|
||||||
|
|
||||||
|
- Fix: :lock: improve tag search security
|
||||||
|
|
||||||
|
- Fix: reactions using unicode weren't processed
|
||||||
|
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
@ -129,6 +164,10 @@ closes #9501
|
|||||||
|
|
||||||
- Feat: PWA icons
|
- Feat: PWA icons
|
||||||
|
|
||||||
|
- Feat: :sparkles: dialog to remove follower
|
||||||
|
|
||||||
|
co-authored-by: atsu1125 <atsu1125@github>
|
||||||
|
|
||||||
|
|
||||||
### Miscellaneous Tasks
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
@ -170,6 +209,10 @@ why was this ever needed
|
|||||||
|
|
||||||
- Chore: update german translations
|
- Chore: update german translations
|
||||||
|
|
||||||
|
- Update changelog
|
||||||
|
|
||||||
|
- Chore: formatting
|
||||||
|
|
||||||
|
|
||||||
### Performance
|
### Performance
|
||||||
|
|
||||||
|
50
Dockerfile
50
Dockerfile
@ -1,25 +1,51 @@
|
|||||||
FROM node:19-alpine
|
## Install dev and compilation dependencies, build files
|
||||||
ARG NODE_ENV=production
|
FROM node:19-alpine as build
|
||||||
WORKDIR /calckey
|
WORKDIR /calckey
|
||||||
|
|
||||||
# Copy Files
|
# Install compilation dependencies
|
||||||
COPY . ./
|
RUN apk add --no-cache --no-progress git alpine-sdk python3
|
||||||
|
|
||||||
# Install Dependencies
|
# Copy only the dependency-related files first, to cache efficiently
|
||||||
RUN apk update
|
COPY package.json pnpm*.yaml ./
|
||||||
RUN apk add git ffmpeg tini alpine-sdk python3
|
COPY packages/backend/package.json packages/backend/package.json
|
||||||
|
COPY packages/client/package.json packages/client/package.json
|
||||||
|
COPY packages/sw/package.json packages/sw/package.json
|
||||||
|
|
||||||
# Configure corepack and pnpm
|
# Configure corepack and pnpm
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@latest --activate
|
RUN corepack prepare pnpm@latest --activate
|
||||||
RUN pnpm i --frozen-lockfile
|
|
||||||
ARG NODE_ENV=production
|
|
||||||
|
|
||||||
# Build project (pnp dependencies are installed)
|
# Install dev mode dependencies for compilation
|
||||||
|
RUN pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy in the rest of the files, to compile from TS to JS
|
||||||
|
COPY . ./
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Remove git files
|
# Trim down the dependencies to only the prod deps
|
||||||
RUN rm -rf .git
|
RUN pnpm i --prod --frozen-lockfile
|
||||||
|
|
||||||
|
|
||||||
|
## Runtime container
|
||||||
|
FROM node:19-alpine
|
||||||
|
WORKDIR /calckey
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache --no-progress tini ffmpeg
|
||||||
|
|
||||||
|
COPY . ./
|
||||||
|
|
||||||
|
# Copy node modules
|
||||||
|
COPY --from=build /calckey/node_modules /calckey/node_modules
|
||||||
|
COPY --from=build /calckey/packages/backend/node_modules /calckey/packages/backend/node_modules
|
||||||
|
COPY --from=build /calckey/packages/sw/node_modules /calckey/packages/sw/node_modules
|
||||||
|
COPY --from=build /calckey/packages/client/node_modules /calckey/packages/client/node_modules
|
||||||
|
|
||||||
|
# Copy the finished compiled files
|
||||||
|
COPY --from=build /calckey/built /calckey/built
|
||||||
|
COPY --from=build /calckey/packages/backend/built /calckey/packages/backend/built
|
||||||
|
COPY --from=build /calckey/packages/backend/assets/instance.css /calckey/packages/backend/assets/instance.css
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
ENTRYPOINT [ "/sbin/tini", "--" ]
|
ENTRYPOINT [ "/sbin/tini", "--" ]
|
||||||
CMD [ "pnpm", "run", "migrateandstart" ]
|
CMD [ "pnpm", "run", "migrateandstart" ]
|
||||||
|
@ -97,9 +97,10 @@ If you have access to a server that supports one of the sources below, I recomme
|
|||||||
```sh
|
```sh
|
||||||
git clone --depth 1 https://codeberg.org/calckey/calckey.git
|
git clone --depth 1 https://codeberg.org/calckey/calckey.git
|
||||||
cd calckey/
|
cd calckey/
|
||||||
# git checkout main # if you want only stable versions
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
By default, you're on the development branch. Run `git checkout beta` or `git checkout main` to switch to the Beta/Main branches.
|
||||||
|
|
||||||
## 📩 Install dependencies
|
## 📩 Install dependencies
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
@ -1786,3 +1786,20 @@ _deck:
|
|||||||
list: "List"
|
list: "List"
|
||||||
mentions: "Mentions"
|
mentions: "Mentions"
|
||||||
direct: "Direct messages"
|
direct: "Direct messages"
|
||||||
|
_apps:
|
||||||
|
apps: "Apps"
|
||||||
|
crossPlatform: "Cross platform"
|
||||||
|
mobile: "Mobile"
|
||||||
|
firstParty: "First party"
|
||||||
|
firstClass: "First class"
|
||||||
|
secondClass: "Second class"
|
||||||
|
thirdClass: "Third class"
|
||||||
|
free: "Free"
|
||||||
|
paid: "Paid"
|
||||||
|
pwa: "Install PWA"
|
||||||
|
kaiteki: "Kaiteki"
|
||||||
|
milktea: "Milktea"
|
||||||
|
subwayTooter: "Subway Tooter"
|
||||||
|
kimis: "Kimis"
|
||||||
|
theDesk: "TheDesk"
|
||||||
|
lesskey: "Lesskey"
|
||||||
|
21
package.json
21
package.json
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "calckey",
|
"name": "calckey",
|
||||||
"version": "13.1.2",
|
"version": "13.2.0-dev",
|
||||||
"codename": "aqua",
|
"codename": "aqua",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://codeberg.org/calckey/calckey.git"
|
"url": "https://codeberg.org/calckey/calckey.git"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@7.26.3",
|
"packageManager": "pnpm@7.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
|
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
|
||||||
@ -20,6 +20,7 @@
|
|||||||
"gulp": "gulp build",
|
"gulp": "gulp build",
|
||||||
"watch": "pnpm run dev",
|
"watch": "pnpm run dev",
|
||||||
"dev": "pnpm node ./scripts/dev.js",
|
"dev": "pnpm node ./scripts/dev.js",
|
||||||
|
"dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development pnpm run build && pnpm run start",
|
||||||
"lint": "pnpm -r run lint",
|
"lint": "pnpm -r run lint",
|
||||||
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
|
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||||
"cy:run": "cypress run",
|
"cy:run": "cypress run",
|
||||||
@ -32,22 +33,14 @@
|
|||||||
"cleanall": "pnpm run clean-all"
|
"cleanall": "pnpm run clean-all"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"chokidar": "^3.3.1",
|
"chokidar": "^3.3.1"
|
||||||
"lodash": "^4.17.21"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bull-board/api": "^4.10.2",
|
"@bull-board/api": "^4.10.2",
|
||||||
"@bull-board/ui": "^4.10.2",
|
"@bull-board/ui": "^4.10.2",
|
||||||
"@tensorflow/tfjs": "^3.21.0",
|
"@tensorflow/tfjs": "^3.21.0",
|
||||||
"calckey-js": "^0.0.20",
|
"calckey-js": "^0.0.20",
|
||||||
"execa": "5.1.1",
|
|
||||||
"gulp": "4.0.2",
|
|
||||||
"gulp-cssnano": "2.1.3",
|
|
||||||
"gulp-rename": "2.0.0",
|
|
||||||
"gulp-replace": "1.1.4",
|
|
||||||
"gulp-terser": "2.1.0",
|
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"long": "^5.2.1",
|
|
||||||
"phosphor-icons": "^1.4.2",
|
"phosphor-icons": "^1.4.2",
|
||||||
"seedrandom": "^3.0.5"
|
"seedrandom": "^3.0.5"
|
||||||
},
|
},
|
||||||
@ -56,6 +49,12 @@
|
|||||||
"@types/gulp-rename": "2.0.1",
|
"@types/gulp-rename": "2.0.1",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "10.11.0",
|
"cypress": "10.11.0",
|
||||||
|
"execa": "5.1.1",
|
||||||
|
"gulp": "4.0.2",
|
||||||
|
"gulp-cssnano": "2.1.3",
|
||||||
|
"gulp-rename": "2.0.0",
|
||||||
|
"gulp-replace": "1.1.4",
|
||||||
|
"gulp-terser": "2.1.0",
|
||||||
"install-peers": "^1.0.4",
|
"install-peers": "^1.0.4",
|
||||||
"rome": "^11.0.0",
|
"rome": "^11.0.0",
|
||||||
"start-server-and-test": "1.15.2",
|
"start-server-and-test": "1.15.2",
|
||||||
|
BIN
packages/backend/assets/favicon.svg
Normal file
BIN
packages/backend/assets/favicon.svg
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
BIN
packages/backend/assets/inverse wordmark.svg
Normal file
BIN
packages/backend/assets/inverse wordmark.svg
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
@ -15,8 +15,7 @@
|
|||||||
"test": "pnpm run mocha"
|
"test": "pnpm run mocha"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"chokidar": "^3.3.1",
|
"chokidar": "^3.3.1"
|
||||||
"lodash": "^4.17.21"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@swc/core-android-arm64": "1.3.11",
|
"@swc/core-android-arm64": "1.3.11",
|
||||||
@ -34,13 +33,14 @@
|
|||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@redocly/openapi-core": "1.0.0-beta.120",
|
"@redocly/openapi-core": "1.0.0-beta.120",
|
||||||
"@sinonjs/fake-timers": "9.1.2",
|
"@sinonjs/fake-timers": "9.1.2",
|
||||||
"@swc/cli": "^0.1.59",
|
|
||||||
"@swc/core": "^1.3.26",
|
|
||||||
"@syuilo/aiscript": "0.11.1",
|
"@syuilo/aiscript": "0.11.1",
|
||||||
"@tensorflow/tfjs": "^4.2.0",
|
"@tensorflow/tfjs": "^4.2.0",
|
||||||
"ajv": "8.11.2",
|
"ajv": "8.11.2",
|
||||||
"archiver": "5.3.1",
|
"archiver": "5.3.1",
|
||||||
|
"koa-body": "^6.0.1",
|
||||||
"autobind-decorator": "2.4.0",
|
"autobind-decorator": "2.4.0",
|
||||||
|
"autolinker": "4.0.0",
|
||||||
|
"axios": "^1.3.2",
|
||||||
"autwh": "0.1.0",
|
"autwh": "0.1.0",
|
||||||
"aws-sdk": "2.1277.0",
|
"aws-sdk": "2.1277.0",
|
||||||
"bcryptjs": "2.4.3",
|
"bcryptjs": "2.4.3",
|
||||||
@ -68,8 +68,6 @@
|
|||||||
"is-svg": "4.3.2",
|
"is-svg": "4.3.2",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"jsdom": "20.0.3",
|
"jsdom": "20.0.3",
|
||||||
"json5": "2.2.3",
|
|
||||||
"json5-loader": "4.0.1",
|
|
||||||
"jsonld": "6.0.0",
|
"jsonld": "6.0.0",
|
||||||
"jsrsasign": "10.6.1",
|
"jsrsasign": "10.6.1",
|
||||||
"koa": "2.13.4",
|
"koa": "2.13.4",
|
||||||
@ -81,9 +79,9 @@
|
|||||||
"koa-send": "5.0.1",
|
"koa-send": "5.0.1",
|
||||||
"koa-slow": "2.1.0",
|
"koa-slow": "2.1.0",
|
||||||
"koa-views": "7.0.2",
|
"koa-views": "7.0.2",
|
||||||
|
"@cutls/megalodon": "5.1.15",
|
||||||
"mfm-js": "0.23.2",
|
"mfm-js": "0.23.2",
|
||||||
"mime-types": "2.1.35",
|
"mime-types": "2.1.35",
|
||||||
"mocha": "10.2.0",
|
|
||||||
"multer": "1.4.4-lts.1",
|
"multer": "1.4.4-lts.1",
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.0",
|
"node-fetch": "3.3.0",
|
||||||
@ -96,7 +94,6 @@
|
|||||||
"private-ip": "2.3.4",
|
"private-ip": "2.3.4",
|
||||||
"probe-image-size": "7.2.3",
|
"probe-image-size": "7.2.3",
|
||||||
"promise-limit": "2.7.0",
|
"promise-limit": "2.7.0",
|
||||||
"pug": "3.0.2",
|
|
||||||
"punycode": "2.1.1",
|
"punycode": "2.1.1",
|
||||||
"pureimage": "0.3.15",
|
"pureimage": "0.3.15",
|
||||||
"qrcode": "1.5.1",
|
"qrcode": "1.5.1",
|
||||||
@ -108,13 +105,11 @@
|
|||||||
"rename": "1.0.4",
|
"rename": "1.0.4",
|
||||||
"rndstr": "1.0.0",
|
"rndstr": "1.0.0",
|
||||||
"rss-parser": "3.12.0",
|
"rss-parser": "3.12.0",
|
||||||
"s-age": "1.1.2",
|
|
||||||
"sanitize-html": "2.8.1",
|
"sanitize-html": "2.8.1",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"semver": "7.3.8",
|
"semver": "7.3.8",
|
||||||
"sharp": "0.31.3",
|
"sharp": "0.31.3",
|
||||||
"speakeasy": "2.0.0",
|
"speakeasy": "2.0.0",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"summaly": "2.7.0",
|
"summaly": "2.7.0",
|
||||||
"syslog-pro": "1.0.0",
|
"syslog-pro": "1.0.0",
|
||||||
@ -122,9 +117,6 @@
|
|||||||
"tesseract.js": "^3.0.3",
|
"tesseract.js": "^3.0.3",
|
||||||
"tinycolor2": "1.5.2",
|
"tinycolor2": "1.5.2",
|
||||||
"tmp": "0.2.1",
|
"tmp": "0.2.1",
|
||||||
"ts-loader": "9.4.2",
|
|
||||||
"ts-node": "10.9.1",
|
|
||||||
"tsconfig-paths": "4.1.2",
|
|
||||||
"twemoji-parser": "14.0.0",
|
"twemoji-parser": "14.0.0",
|
||||||
"typeorm": "0.3.11",
|
"typeorm": "0.3.11",
|
||||||
"ulid": "2.3.0",
|
"ulid": "2.3.0",
|
||||||
@ -132,10 +124,11 @@
|
|||||||
"uuid": "9.0.0",
|
"uuid": "9.0.0",
|
||||||
"web-push": "3.5.0",
|
"web-push": "3.5.0",
|
||||||
"websocket": "1.0.34",
|
"websocket": "1.0.34",
|
||||||
"ws": "8.11.0",
|
|
||||||
"xev": "3.0.2"
|
"xev": "3.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc/cli": "^0.1.59",
|
||||||
|
"@swc/core": "^1.3.26",
|
||||||
"@types/bcryptjs": "2.4.2",
|
"@types/bcryptjs": "2.4.2",
|
||||||
"@types/bull": "3.15.9",
|
"@types/bull": "3.15.9",
|
||||||
"@types/cbor": "6.0.0",
|
"@types/cbor": "6.0.0",
|
||||||
@ -179,11 +172,21 @@
|
|||||||
"@types/web-push": "3.3.2",
|
"@types/web-push": "3.3.2",
|
||||||
"@types/websocket": "1.0.5",
|
"@types/websocket": "1.0.5",
|
||||||
"@types/ws": "8.5.3",
|
"@types/ws": "8.5.3",
|
||||||
|
"autobind-decorator": "2.4.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint": "^8.31.0",
|
"eslint": "^8.31.0",
|
||||||
"execa": "6.1.0",
|
"execa": "6.1.0",
|
||||||
|
"json5": "2.2.3",
|
||||||
|
"json5-loader": "4.0.1",
|
||||||
|
"mocha": "10.2.0",
|
||||||
|
"pug": "3.0.2",
|
||||||
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"swc-loader": "^0.2.3",
|
"swc-loader": "^0.2.3",
|
||||||
|
"ts-loader": "9.4.2",
|
||||||
|
"ts-node": "10.9.1",
|
||||||
|
"tsconfig-paths": "4.1.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"webpack": "^5.75.0"
|
"webpack": "^5.75.0",
|
||||||
|
"ws": "8.11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,3 +2,4 @@ import twemoji from "twemoji-parser/dist/lib/regex.js";
|
|||||||
const twemojiRegex = twemoji.default;
|
const twemojiRegex = twemoji.default;
|
||||||
|
|
||||||
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
|
export const emojiRegex = new RegExp(`(${twemojiRegex.source})`);
|
||||||
|
export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`);
|
||||||
|
@ -197,6 +197,11 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||||||
.map((x) => decodeReaction(x).reaction)
|
.map((x) => decodeReaction(x).reaction)
|
||||||
.map((x) => x.replace(/:/g, ""));
|
.map((x) => x.replace(/:/g, ""));
|
||||||
|
|
||||||
|
const noteEmoji = await populateEmojis(
|
||||||
|
note.emojis.concat(reactionEmojiNames),
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
const reactionEmoji = await populateEmojis(reactionEmojiNames, host);
|
||||||
const packed: Packed<"Note"> = await awaitAll({
|
const packed: Packed<"Note"> = await awaitAll({
|
||||||
id: note.id,
|
id: note.id,
|
||||||
createdAt: note.createdAt.toISOString(),
|
createdAt: note.createdAt.toISOString(),
|
||||||
@ -213,8 +218,9 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||||||
renoteCount: note.renoteCount,
|
renoteCount: note.renoteCount,
|
||||||
repliesCount: note.repliesCount,
|
repliesCount: note.repliesCount,
|
||||||
reactions: convertLegacyReactions(note.reactions),
|
reactions: convertLegacyReactions(note.reactions),
|
||||||
|
reactionEmojis: reactionEmoji,
|
||||||
|
emojis: noteEmoji,
|
||||||
tags: note.tags.length > 0 ? note.tags : undefined,
|
tags: note.tags.length > 0 ? note.tags : undefined,
|
||||||
emojis: populateEmojis(note.emojis.concat(reactionEmojiNames), host),
|
|
||||||
fileIds: note.fileIds,
|
fileIds: note.fileIds,
|
||||||
files: DriveFiles.packMany(note.fileIds),
|
files: DriveFiles.packMany(note.fileIds),
|
||||||
replyId: note.replyId,
|
replyId: note.replyId,
|
||||||
|
@ -161,27 +161,10 @@ export const packedNoteSchema = {
|
|||||||
nullable: false,
|
nullable: false,
|
||||||
},
|
},
|
||||||
emojis: {
|
emojis: {
|
||||||
type: "array",
|
|
||||||
optional: false,
|
|
||||||
nullable: false,
|
|
||||||
items: {
|
|
||||||
type: "object",
|
type: "object",
|
||||||
optional: false,
|
optional: true,
|
||||||
nullable: false,
|
|
||||||
properties: {
|
|
||||||
name: {
|
|
||||||
type: "string",
|
|
||||||
optional: false,
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
url: {
|
|
||||||
type: "string",
|
|
||||||
optional: false,
|
|
||||||
nullable: true,
|
nullable: true,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
reactions: {
|
reactions: {
|
||||||
type: "object",
|
type: "object",
|
||||||
optional: false,
|
optional: false,
|
||||||
|
@ -111,6 +111,16 @@ export async function createNote(
|
|||||||
|
|
||||||
const note: IPost = object;
|
const note: IPost = object;
|
||||||
|
|
||||||
|
if (note.id && !note.id.startsWith("https://")) {
|
||||||
|
throw new Error(`unexpected shcema of note.id: ${note.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = getOneApHrefNullable(note.url);
|
||||||
|
|
||||||
|
if (url && !url.startsWith("https://")) {
|
||||||
|
throw new Error(`unexpected shcema of note url: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
logger.info(`Creating the Note: ${note.id}`);
|
logger.info(`Creating the Note: ${note.id}`);
|
||||||
@ -123,7 +133,10 @@ export async function createNote(
|
|||||||
|
|
||||||
// Skip if author is suspended.
|
// Skip if author is suspended.
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
throw new Error("actor has been suspended");
|
logger.debug(
|
||||||
|
`User ${actor.usernameLower}@${actor.host} suspended; discarding.`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const noteAudience = await parseAudience(actor, note.to, note.cc);
|
const noteAudience = await parseAudience(actor, note.to, note.cc);
|
||||||
@ -344,7 +357,7 @@ export async function createNote(
|
|||||||
apEmojis,
|
apEmojis,
|
||||||
poll,
|
poll,
|
||||||
uri: note.id,
|
uri: note.id,
|
||||||
url: getOneApHrefNullable(note.url),
|
url: url,
|
||||||
},
|
},
|
||||||
silent,
|
silent,
|
||||||
);
|
);
|
||||||
|
@ -195,6 +195,12 @@ export async function createPerson(
|
|||||||
|
|
||||||
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
|
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
|
if (url && !url.startsWith("https://")) {
|
||||||
|
throw new Error(`unexpected shcema of person url: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
let user: IRemoteUser;
|
let user: IRemoteUser;
|
||||||
try {
|
try {
|
||||||
@ -237,7 +243,7 @@ export async function createPerson(
|
|||||||
description: person.summary
|
description: person.summary
|
||||||
? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
|
? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
|
||||||
: null,
|
: null,
|
||||||
url: getOneApHrefNullable(person.url),
|
url: url,
|
||||||
fields,
|
fields,
|
||||||
birthday: bday ? bday[0] : null,
|
birthday: bday ? bday[0] : null,
|
||||||
location: person["vcard:Address"] || null,
|
location: person["vcard:Address"] || null,
|
||||||
@ -387,6 +393,12 @@ export async function updatePerson(
|
|||||||
|
|
||||||
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
|
const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/);
|
||||||
|
|
||||||
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
|
if (url && !url.startsWith("https://")) {
|
||||||
|
throw new Error(`unexpected shcema of person url: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
inbox: person.inbox,
|
inbox: person.inbox,
|
||||||
@ -430,7 +442,7 @@ export async function updatePerson(
|
|||||||
await UserProfiles.update(
|
await UserProfiles.update(
|
||||||
{ userId: exist.id },
|
{ userId: exist.id },
|
||||||
{
|
{
|
||||||
url: getOneApHrefNullable(person.url),
|
url: url,
|
||||||
fields,
|
fields,
|
||||||
description: person.summary
|
description: person.summary
|
||||||
? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
|
? htmlToMfm(truncate(person.summary, summaryLength), person.tag)
|
||||||
|
@ -198,6 +198,7 @@ import * as ep___i_readAnnouncement from "./endpoints/i/read-announcement.js";
|
|||||||
import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js";
|
import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js";
|
||||||
import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js";
|
import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js";
|
||||||
import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js";
|
import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js";
|
||||||
|
import * as ep___i_registry_getUnsecure from "./endpoints/i/registry/get-unsecure.js";
|
||||||
import * as ep___i_registry_get from "./endpoints/i/registry/get.js";
|
import * as ep___i_registry_get from "./endpoints/i/registry/get.js";
|
||||||
import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js";
|
import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js";
|
||||||
import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js";
|
import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js";
|
||||||
@ -538,6 +539,7 @@ const eps = [
|
|||||||
["i/regenerate-token", ep___i_regenerateToken],
|
["i/regenerate-token", ep___i_regenerateToken],
|
||||||
["i/registry/get-all", ep___i_registry_getAll],
|
["i/registry/get-all", ep___i_registry_getAll],
|
||||||
["i/registry/get-detail", ep___i_registry_getDetail],
|
["i/registry/get-detail", ep___i_registry_getDetail],
|
||||||
|
["i/registry/get-unsecure", ep___i_registry_getUnsecure],
|
||||||
["i/registry/get", ep___i_registry_get],
|
["i/registry/get", ep___i_registry_get],
|
||||||
["i/registry/keys-with-type", ep___i_registry_keysWithType],
|
["i/registry/keys-with-type", ep___i_registry_keysWithType],
|
||||||
["i/registry/keys", ep___i_registry_keys],
|
["i/registry/keys", ep___i_registry_keys],
|
||||||
@ -766,16 +768,16 @@ export interface IEndpointMeta {
|
|||||||
|
|
||||||
export interface IEndpoint {
|
export interface IEndpoint {
|
||||||
name: string;
|
name: string;
|
||||||
exec: any;
|
exec: any; // TODO: may be obosolete @ThatOneCalculator
|
||||||
meta: IEndpointMeta;
|
meta: IEndpointMeta;
|
||||||
params: Schema;
|
params: Schema;
|
||||||
}
|
}
|
||||||
|
|
||||||
const endpoints: IEndpoint[] = eps.map(([name, ep]) => {
|
const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => {
|
||||||
return {
|
return {
|
||||||
name: name,
|
name: name,
|
||||||
exec: ep.default,
|
exec: ep.default,
|
||||||
meta: ep.meta || {},
|
meta: ep.meta ?? {},
|
||||||
params: ep.paramDef,
|
params: ep.paramDef,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
@ -0,0 +1,50 @@
|
|||||||
|
import { ApiError } from "../../../error.js";
|
||||||
|
import define from "../../../define.js";
|
||||||
|
import { RegistryItems } from "@/models/index.js";
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
requireCredential: true,
|
||||||
|
|
||||||
|
secure: false,
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchKey: {
|
||||||
|
message: "No such key.",
|
||||||
|
code: "NO_SUCH_KEY",
|
||||||
|
id: "ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
key: { type: "string" },
|
||||||
|
scope: {
|
||||||
|
type: "array",
|
||||||
|
default: [],
|
||||||
|
items: {
|
||||||
|
type: "string",
|
||||||
|
pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["key"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default define(meta, paramDef, async (ps, user) => {
|
||||||
|
if (ps.key !== "reactions") return;
|
||||||
|
const query = RegistryItems.createQueryBuilder("item")
|
||||||
|
.where("item.domain IS NULL")
|
||||||
|
.andWhere("item.userId = :userId", { userId: user.id })
|
||||||
|
.andWhere("item.key = :key", { key: ps.key })
|
||||||
|
.andWhere("item.scope = :scope", { scope: ps.scope });
|
||||||
|
|
||||||
|
const item = await query.getOne();
|
||||||
|
|
||||||
|
if (item == null) {
|
||||||
|
throw new ApiError(meta.errors.noSuchKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.value;
|
||||||
|
});
|
@ -7,6 +7,7 @@ import Router from "@koa/router";
|
|||||||
import multer from "@koa/multer";
|
import multer from "@koa/multer";
|
||||||
import bodyParser from "koa-bodyparser";
|
import bodyParser from "koa-bodyparser";
|
||||||
import cors from "@koa/cors";
|
import cors from "@koa/cors";
|
||||||
|
import { apiMastodonCompatible } from "./mastodon/ApiMastodonCompatibleService.js";
|
||||||
import { Instances, AccessTokens, Users } from "@/models/index.js";
|
import { Instances, AccessTokens, Users } from "@/models/index.js";
|
||||||
import config from "@/config/index.js";
|
import config from "@/config/index.js";
|
||||||
import endpoints from "./endpoints.js";
|
import endpoints from "./endpoints.js";
|
||||||
@ -18,6 +19,7 @@ import signupPending from "./private/signup-pending.js";
|
|||||||
import discord from "./service/discord.js";
|
import discord from "./service/discord.js";
|
||||||
import github from "./service/github.js";
|
import github from "./service/github.js";
|
||||||
import twitter from "./service/twitter.js";
|
import twitter from "./service/twitter.js";
|
||||||
|
import { koaBody } from "koa-body";
|
||||||
|
|
||||||
// Init app
|
// Init app
|
||||||
const app = new Koa();
|
const app = new Koa();
|
||||||
@ -34,16 +36,10 @@ app.use(async (ctx, next) => {
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(
|
// Init router
|
||||||
bodyParser({
|
const router = new Router();
|
||||||
// リクエストが multipart/form-data でない限りはJSONだと見なす
|
const mastoRouter = new Router();
|
||||||
detectJSON: (ctx) =>
|
const errorRouter = new Router();
|
||||||
!(
|
|
||||||
ctx.is("multipart/form-data") ||
|
|
||||||
ctx.is("application/x-www-form-urlencoded")
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Init multer instance
|
// Init multer instance
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
@ -54,8 +50,23 @@ const upload = multer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Init router
|
router.use(
|
||||||
const router = new Router();
|
bodyParser({
|
||||||
|
// リクエストが multipart/form-data でない限りはJSONだと見なす
|
||||||
|
detectJSON: (ctx) =>
|
||||||
|
!(
|
||||||
|
ctx.is("multipart/form-data") ||
|
||||||
|
ctx.is("application/x-www-form-urlencoded")
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
mastoRouter.use(koaBody({
|
||||||
|
multipart: true,
|
||||||
|
urlencoded: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiMastodonCompatible(mastoRouter);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register endpoint handlers
|
* Register endpoint handlers
|
||||||
@ -141,11 +152,13 @@ router.post("/miauth/:session/check", async (ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return 404 for unknown API
|
// Return 404 for unknown API
|
||||||
router.all("(.*)", async (ctx) => {
|
errorRouter.all("(.*)", async (ctx) => {
|
||||||
ctx.status = 404;
|
ctx.status = 404;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register router
|
// Register router
|
||||||
|
app.use(mastoRouter.routes());
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
|
app.use(errorRouter.routes());
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
@ -0,0 +1,64 @@
|
|||||||
|
import Router from "@koa/router";
|
||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import { apiAuthMastodon } from "./endpoints/auth.js";
|
||||||
|
import { apiAccountMastodon } from "./endpoints/account.js";
|
||||||
|
import { apiStatusMastodon } from "./endpoints/status.js";
|
||||||
|
import { apiFilterMastodon } from "./endpoints/filter.js";
|
||||||
|
import { apiTimelineMastodon } from "./endpoints/timeline.js";
|
||||||
|
import { apiNotificationsMastodon } from "./endpoints/notifications.js";
|
||||||
|
import { apiSearchMastodon } from "./endpoints/search.js";
|
||||||
|
import { getInstance } from "./endpoints/meta.js";
|
||||||
|
|
||||||
|
export function getClient(
|
||||||
|
BASE_URL: string,
|
||||||
|
authorization: string | undefined,
|
||||||
|
): MegalodonInterface {
|
||||||
|
const accessTokenArr = authorization?.split(" ") ?? [null];
|
||||||
|
const accessToken = accessTokenArr[accessTokenArr.length - 1];
|
||||||
|
const generator = (megalodon as any).default;
|
||||||
|
const client = generator(
|
||||||
|
"misskey",
|
||||||
|
BASE_URL,
|
||||||
|
accessToken,
|
||||||
|
) as MegalodonInterface;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiMastodonCompatible(router: Router): void {
|
||||||
|
apiAuthMastodon(router);
|
||||||
|
apiAccountMastodon(router);
|
||||||
|
apiStatusMastodon(router);
|
||||||
|
apiFilterMastodon(router);
|
||||||
|
apiTimelineMastodon(router);
|
||||||
|
apiNotificationsMastodon(router);
|
||||||
|
apiSearchMastodon(router);
|
||||||
|
|
||||||
|
router.get("/v1/custom_emojis", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getInstanceCustomEmojis();
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/v1/instance", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens); // we are using this here, because in private mode some info isnt
|
||||||
|
// displayed without being logged in
|
||||||
|
try {
|
||||||
|
const data = await client.getInstance();
|
||||||
|
ctx.body = getInstance(data.data);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
376
packages/backend/src/server/api/mastodon/endpoints/account.ts
Normal file
376
packages/backend/src/server/api/mastodon/endpoints/account.ts
Normal file
@ -0,0 +1,376 @@
|
|||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import Router from "@koa/router";
|
||||||
|
import { koaBody } from "koa-body";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
import { toLimitToInt } from "./timeline.js";
|
||||||
|
|
||||||
|
export function apiAccountMastodon(router: Router): void {
|
||||||
|
router.get("/v1/accounts/verify_credentials", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.verifyAccountCredentials();
|
||||||
|
const acct = data.data;
|
||||||
|
acct.url = `${BASE_URL}/@${acct.url}`;
|
||||||
|
acct.note = "";
|
||||||
|
acct.avatar_static = acct.avatar;
|
||||||
|
acct.header = acct.header || "";
|
||||||
|
acct.header_static = acct.header || "";
|
||||||
|
acct.source = {
|
||||||
|
note: acct.note,
|
||||||
|
fields: acct.fields,
|
||||||
|
privacy: "public",
|
||||||
|
sensitive: false,
|
||||||
|
language: "",
|
||||||
|
};
|
||||||
|
ctx.body = acct;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.patch("/v1/accounts/update_credentials", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.updateCredentials(
|
||||||
|
(ctx.request as any).body as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccount(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/statuses",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccountStatuses(
|
||||||
|
ctx.params.id,
|
||||||
|
toLimitToInt(ctx.query as any),
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/followers",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccountFollowers(
|
||||||
|
ctx.params.id,
|
||||||
|
ctx.query as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/following",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccountFollowing(
|
||||||
|
ctx.params.id,
|
||||||
|
ctx.query as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/lists",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccountLists(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/follow",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.followAccount(ctx.params.id);
|
||||||
|
const acct = data.data;
|
||||||
|
acct.following = true;
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/unfollow",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.unfollowAccount(ctx.params.id);
|
||||||
|
const acct = data.data;
|
||||||
|
acct.following = false;
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/block",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.blockAccount(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/unblock",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.unblockAccount(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/mute",
|
||||||
|
async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.muteAccount(
|
||||||
|
ctx.params.id,
|
||||||
|
(ctx.request as any).body as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/accounts/:id/unmute",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.unmuteAccount(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get("/v1/accounts/relationships", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const idsRaw = (ctx.query as any)["id[]"];
|
||||||
|
const ids = typeof idsRaw === "string" ? [idsRaw] : idsRaw;
|
||||||
|
const data = (await client.getRelationships(ids)) as any;
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/bookmarks", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = (await client.getBookmarks(ctx.query as any)) as any;
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/favourites", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getFavourites(ctx.query as any);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/mutes", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getMutes(ctx.query as any);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/blocks", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getBlocks(ctx.query as any);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/follow_ctxs", async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getFollowRequests(
|
||||||
|
((ctx.query as any) || { limit: 20 }).limit,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/follow_ctxs/:id/authorize",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.acceptFollowRequest(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/follow_ctxs/:id/reject",
|
||||||
|
async (ctx, next) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.rejectFollowRequest(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
84
packages/backend/src/server/api/mastodon/endpoints/auth.ts
Normal file
84
packages/backend/src/server/api/mastodon/endpoints/auth.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import Router from "@koa/router";
|
||||||
|
import { koaBody } from "koa-body";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
import bodyParser from "koa-bodyparser";
|
||||||
|
|
||||||
|
const readScope = [
|
||||||
|
"read:account",
|
||||||
|
"read:drive",
|
||||||
|
"read:blocks",
|
||||||
|
"read:favorites",
|
||||||
|
"read:following",
|
||||||
|
"read:messaging",
|
||||||
|
"read:mutes",
|
||||||
|
"read:notifications",
|
||||||
|
"read:reactions",
|
||||||
|
"read:pages",
|
||||||
|
"read:page-likes",
|
||||||
|
"read:user-groups",
|
||||||
|
"read:channels",
|
||||||
|
"read:gallery",
|
||||||
|
"read:gallery-likes",
|
||||||
|
];
|
||||||
|
const writeScope = [
|
||||||
|
"write:account",
|
||||||
|
"write:drive",
|
||||||
|
"write:blocks",
|
||||||
|
"write:favorites",
|
||||||
|
"write:following",
|
||||||
|
"write:messaging",
|
||||||
|
"write:mutes",
|
||||||
|
"write:notes",
|
||||||
|
"write:notifications",
|
||||||
|
"write:reactions",
|
||||||
|
"write:votes",
|
||||||
|
"write:pages",
|
||||||
|
"write:page-likes",
|
||||||
|
"write:user-groups",
|
||||||
|
"write:channels",
|
||||||
|
"write:gallery",
|
||||||
|
"write:gallery-likes",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function apiAuthMastodon(router: Router): void {
|
||||||
|
router.post("/v1/apps", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
let scope = body.scopes;
|
||||||
|
console.log(body);
|
||||||
|
if (typeof scope === "string") scope = scope.split(" ");
|
||||||
|
const pushScope = new Set<string>();
|
||||||
|
for (const s of scope) {
|
||||||
|
if (s.match(/^read/)) for (const r of readScope) pushScope.add(r);
|
||||||
|
if (s.match(/^write/)) for (const r of writeScope) pushScope.add(r);
|
||||||
|
}
|
||||||
|
const scopeArr = Array.from(pushScope);
|
||||||
|
|
||||||
|
let red = body.redirect_uris;
|
||||||
|
if (red === "urn:ietf:wg:oauth:2.0:oob") {
|
||||||
|
red = "https://thedesk.top/hello.html";
|
||||||
|
}
|
||||||
|
const appData = await client.registerApp(body.client_name, {
|
||||||
|
scopes: scopeArr,
|
||||||
|
redirect_uris: red,
|
||||||
|
website: body.website,
|
||||||
|
});
|
||||||
|
ctx.body = {
|
||||||
|
id: appData.id,
|
||||||
|
name: appData.name,
|
||||||
|
website: appData.website,
|
||||||
|
redirect_uri: red,
|
||||||
|
client_id: Buffer.from(appData.url || "").toString("base64"),
|
||||||
|
client_secret: appData.clientSecret,
|
||||||
|
};
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
84
packages/backend/src/server/api/mastodon/endpoints/filter.ts
Normal file
84
packages/backend/src/server/api/mastodon/endpoints/filter.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import Router from "@koa/router";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
|
||||||
|
export function apiFilterMastodon(router: Router): void {
|
||||||
|
router.get("/v1/filters", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.getFilters();
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/v1/filters/:id", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.getFilter(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/v1/filters", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.createFilter(body.phrase, body.context, body);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/v1/filters/:id", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.updateFilter(
|
||||||
|
ctx.params.id,
|
||||||
|
body.phrase,
|
||||||
|
body.context,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/v1/filters/:id", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.deleteFilter(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
97
packages/backend/src/server/api/mastodon/endpoints/meta.ts
Normal file
97
packages/backend/src/server/api/mastodon/endpoints/meta.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Entity } from "@cutls/megalodon";
|
||||||
|
// TODO: add calckey features
|
||||||
|
export function getInstance(response: Entity.Instance) {
|
||||||
|
return {
|
||||||
|
uri: response.uri,
|
||||||
|
title: response.title || "",
|
||||||
|
short_description: response.description || "",
|
||||||
|
description: response.description || "",
|
||||||
|
email: response.email || "",
|
||||||
|
version: "3.0.0 compatible (Calckey)",
|
||||||
|
urls: response.urls,
|
||||||
|
stats: response.stats,
|
||||||
|
thumbnail: response.thumbnail || "",
|
||||||
|
languages: ["en", "de", "ja"],
|
||||||
|
registrations: response.registrations,
|
||||||
|
approval_required: !response.registrations,
|
||||||
|
invites_enabled: response.registrations,
|
||||||
|
configuration: {
|
||||||
|
accounts: {
|
||||||
|
max_featured_tags: 20,
|
||||||
|
},
|
||||||
|
statuses: {
|
||||||
|
max_characters: 3000,
|
||||||
|
max_media_attachments: 4,
|
||||||
|
characters_reserved_per_url: response.uri.length,
|
||||||
|
},
|
||||||
|
media_attachments: {
|
||||||
|
supported_mime_types: [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
|
"image/webp",
|
||||||
|
"image/avif",
|
||||||
|
"video/webm",
|
||||||
|
"video/mp4",
|
||||||
|
"video/quicktime",
|
||||||
|
"video/ogg",
|
||||||
|
"audio/wave",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/x-wav",
|
||||||
|
"audio/x-pn-wave",
|
||||||
|
"audio/vnd.wave",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/vorbis",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/mp3",
|
||||||
|
"audio/webm",
|
||||||
|
"audio/flac",
|
||||||
|
"audio/aac",
|
||||||
|
"audio/m4a",
|
||||||
|
"audio/x-m4a",
|
||||||
|
"audio/mp4",
|
||||||
|
"audio/3gpp",
|
||||||
|
"video/x-ms-asf",
|
||||||
|
],
|
||||||
|
image_size_limit: 10485760,
|
||||||
|
image_matrix_limit: 16777216,
|
||||||
|
video_size_limit: 41943040,
|
||||||
|
video_frame_rate_limit: 60,
|
||||||
|
video_matrix_limit: 2304000,
|
||||||
|
},
|
||||||
|
polls: {
|
||||||
|
max_options: 8,
|
||||||
|
max_characters_per_option: 50,
|
||||||
|
min_expiration: 300,
|
||||||
|
max_expiration: 2629746,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contact_account: {
|
||||||
|
id: "1",
|
||||||
|
username: "admin",
|
||||||
|
acct: "admin",
|
||||||
|
display_name: "admin",
|
||||||
|
locked: true,
|
||||||
|
bot: true,
|
||||||
|
discoverable: false,
|
||||||
|
group: false,
|
||||||
|
created_at: "1971-01-01T00:00:00.000Z",
|
||||||
|
note: "",
|
||||||
|
url: "https://http.cat/404",
|
||||||
|
avatar: "https://http.cat/404",
|
||||||
|
avatar_static: "https://http.cat/404",
|
||||||
|
header: "https://http.cat/404",
|
||||||
|
header_static: "https://http.cat/404",
|
||||||
|
followers_count: -1,
|
||||||
|
following_count: 0,
|
||||||
|
statuses_count: 0,
|
||||||
|
last_status_at: "1971-01-01T00:00:00.000Z",
|
||||||
|
noindex: true,
|
||||||
|
emojis: [],
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
|
rules: [],
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import Router from "@koa/router";
|
||||||
|
import { koaBody } from "koa-body";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
import { toTextWithReaction } from "./timeline.js";
|
||||||
|
function toLimitToInt(q: any) {
|
||||||
|
if (q.limit) if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiNotificationsMastodon(router: Router): void {
|
||||||
|
router.get("/v1/notifications", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.getNotifications(toLimitToInt(ctx.query));
|
||||||
|
const notfs = data.data;
|
||||||
|
const ret = notfs.map((n) => {
|
||||||
|
if (n.type !== "follow" && n.type !== "follow_request") {
|
||||||
|
if (n.type === "reaction") n.type = "favourite";
|
||||||
|
n.status = toTextWithReaction(
|
||||||
|
n.status ? [n.status] : [],
|
||||||
|
ctx.hostname,
|
||||||
|
)[0];
|
||||||
|
return n;
|
||||||
|
} else {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.body = ret;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/v1/notification/:id", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const dataRaw = await client.getNotification(ctx.params.id);
|
||||||
|
const data = dataRaw.data;
|
||||||
|
if (data.type !== "follow" && data.type !== "follow_request") {
|
||||||
|
if (data.type === "reaction") data.type = "favourite";
|
||||||
|
ctx.body = toTextWithReaction([data as any], ctx.request.hostname)[0];
|
||||||
|
} else {
|
||||||
|
ctx.body = data;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/v1/notifications/clear", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.dismissNotifications();
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/v1/notification/:id/dismiss", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const data = await client.dismissNotification(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
22
packages/backend/src/server/api/mastodon/endpoints/search.ts
Normal file
22
packages/backend/src/server/api/mastodon/endpoints/search.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import Router from "@koa/router";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
|
||||||
|
export function apiSearchMastodon(router: Router): void {
|
||||||
|
router.get("/v1/search", async (ctx) => {
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const accessTokens = ctx.request.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
try {
|
||||||
|
const query: any = ctx.query;
|
||||||
|
const type = query.type || "";
|
||||||
|
const data = await client.search(query.q, type, query);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
483
packages/backend/src/server/api/mastodon/endpoints/status.ts
Normal file
483
packages/backend/src/server/api/mastodon/endpoints/status.ts
Normal file
@ -0,0 +1,483 @@
|
|||||||
|
import Router from "@koa/router";
|
||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
import fs from "fs";
|
||||||
|
import { pipeline } from "node:stream";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { createTemp } from "@/misc/create-temp.js";
|
||||||
|
import { emojiRegex, emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
|
||||||
|
import axios from "axios";
|
||||||
|
const pump = promisify(pipeline);
|
||||||
|
|
||||||
|
export function apiStatusMastodon(router: Router): void {
|
||||||
|
router.post("/v1/statuses", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
const text = body.status;
|
||||||
|
const removed = text.replace(/@\S+/g, "").replaceAll(" ", "");
|
||||||
|
const isDefaultEmoji = emojiRegexAtStartToEnd.test(removed);
|
||||||
|
const isCustomEmoji = /^:[a-zA-Z0-9@_]+:$/.test(removed);
|
||||||
|
if ((body.in_reply_to_id && isDefaultEmoji) || isCustomEmoji) {
|
||||||
|
const a = await client.createEmojiReaction(
|
||||||
|
body.in_reply_to_id,
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
ctx.body = a.data;
|
||||||
|
}
|
||||||
|
if (body.in_reply_to_id && removed === "/unreact") {
|
||||||
|
try {
|
||||||
|
const id = body.in_reply_to_id;
|
||||||
|
const post = await client.getStatus(id);
|
||||||
|
const react = post.data.emoji_reactions.filter((e) => e.me)[0].name;
|
||||||
|
const data = await client.deleteEmojiReaction(id, react);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!body.media_ids) body.media_ids = undefined;
|
||||||
|
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
|
||||||
|
const data = await client.postStatus(text, body);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.delete<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.deleteStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
interface IReaction {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
user: MisskeyEntity.User;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/context",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const id = ctx.params.id;
|
||||||
|
const data = await client.getStatusContext(id, ctx.query as any);
|
||||||
|
const status = await client.getStatus(id);
|
||||||
|
const reactionsAxios = await axios.get(
|
||||||
|
`${BASE_URL}/api/notes/reactions?noteId=${id}`,
|
||||||
|
);
|
||||||
|
const reactions: IReaction[] = reactionsAxios.data;
|
||||||
|
const text = reactions
|
||||||
|
.map((r) => `${r.type.replace("@.", "")} ${r.user.username}`)
|
||||||
|
.join("<br />");
|
||||||
|
data.data.descendants.unshift(
|
||||||
|
statusModel(
|
||||||
|
status.data.id,
|
||||||
|
status.data.account.id,
|
||||||
|
status.data.emojis,
|
||||||
|
text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/reblogged_by",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getStatusRebloggedBy(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/favourited_by",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
ctx.body = [];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/favourite",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const react = await getFirstReaction(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const a = (await client.createEmojiReaction(
|
||||||
|
ctx.params.id,
|
||||||
|
react,
|
||||||
|
)) as any;
|
||||||
|
//const data = await client.favouriteStatus(ctx.params.id) as any;
|
||||||
|
ctx.body = a.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/unfavourite",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
const react = await getFirstReaction(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.deleteEmojiReaction(ctx.params.id, react);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/reblog",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.reblogStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/unreblog",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.unreblogStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/bookmark",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.bookmarkStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/unbookmark",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = (await client.unbookmarkStatus(ctx.params.id)) as any;
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/pin",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.pinStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/statuses/:id/unpin",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.unpinStatus(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post("/v1/media", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const multipartData = await ctx.file;
|
||||||
|
if (!multipartData) {
|
||||||
|
ctx.body = { error: "No image" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [path] = await createTemp();
|
||||||
|
await pump(multipartData.buffer, fs.createWriteStream(path));
|
||||||
|
const image = fs.readFileSync(path);
|
||||||
|
const data = await client.uploadMedia(image);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post("/v2/media", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const multipartData = await ctx.file;
|
||||||
|
if (!multipartData) {
|
||||||
|
ctx.body = { error: "No image" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [path] = await createTemp();
|
||||||
|
await pump(multipartData.buffer, fs.createWriteStream(path));
|
||||||
|
const image = fs.readFileSync(path);
|
||||||
|
const data = await client.uploadMedia(image);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/media/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getMedia(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.put<{ Params: { id: string } }>(
|
||||||
|
"/v1/media/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.updateMedia(
|
||||||
|
ctx.params.id,
|
||||||
|
ctx.request.body as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/polls/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getPoll(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/polls/:id/votes",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.votePoll(
|
||||||
|
ctx.params.id,
|
||||||
|
(ctx.request.body as any).choices,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFirstReaction(
|
||||||
|
BASE_URL: string,
|
||||||
|
accessTokens: string | undefined,
|
||||||
|
) {
|
||||||
|
const accessTokenArr = accessTokens?.split(" ") ?? [null];
|
||||||
|
const accessToken = accessTokenArr[accessTokenArr.length - 1];
|
||||||
|
let react = "👍";
|
||||||
|
try {
|
||||||
|
const api = await axios.post(`${BASE_URL}/api/i/registry/get-unsecure`, {
|
||||||
|
scope: ["client", "base"],
|
||||||
|
key: "reactions",
|
||||||
|
i: accessToken,
|
||||||
|
});
|
||||||
|
const reactRaw = api.data;
|
||||||
|
react = Array.isArray(reactRaw) ? api.data[0] : "👍";
|
||||||
|
console.log(api.data);
|
||||||
|
return react;
|
||||||
|
} catch (e) {
|
||||||
|
return react;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusModel(
|
||||||
|
id: string | null,
|
||||||
|
acctId: string | null,
|
||||||
|
emojis: MastodonEntity.Emoji[],
|
||||||
|
content: string,
|
||||||
|
) {
|
||||||
|
const now = "1970-01-02T00:00:00.000Z";
|
||||||
|
return {
|
||||||
|
id: "9atm5frjhb",
|
||||||
|
uri: "https://http.cat/404", // ""
|
||||||
|
url: "https://http.cat/404", // "",
|
||||||
|
account: {
|
||||||
|
id: "9arzuvv0sw",
|
||||||
|
username: "ReactionBot",
|
||||||
|
acct: "ReactionBot",
|
||||||
|
display_name: "ReactionOfThisPost",
|
||||||
|
locked: false,
|
||||||
|
created_at: now,
|
||||||
|
followers_count: 0,
|
||||||
|
following_count: 0,
|
||||||
|
statuses_count: 0,
|
||||||
|
note: "",
|
||||||
|
url: "https://http.cat/404",
|
||||||
|
avatar: "https://http.cat/404",
|
||||||
|
avatar_static: "https://http.cat/404",
|
||||||
|
header: "https://http.cat/404", // ""
|
||||||
|
header_static: "https://http.cat/404", // ""
|
||||||
|
emojis: [],
|
||||||
|
fields: [],
|
||||||
|
moved: null,
|
||||||
|
bot: false,
|
||||||
|
},
|
||||||
|
in_reply_to_id: id,
|
||||||
|
in_reply_to_account_id: acctId,
|
||||||
|
reblog: null,
|
||||||
|
content: `<p>${content}</p>`,
|
||||||
|
plain_content: null,
|
||||||
|
created_at: now,
|
||||||
|
emojis: emojis,
|
||||||
|
replies_count: 0,
|
||||||
|
reblogs_count: 0,
|
||||||
|
favourites_count: 0,
|
||||||
|
favourited: false,
|
||||||
|
reblogged: false,
|
||||||
|
muted: false,
|
||||||
|
sensitive: false,
|
||||||
|
spoiler_text: "",
|
||||||
|
visibility: "public" as const,
|
||||||
|
media_attachments: [],
|
||||||
|
mentions: [],
|
||||||
|
tags: [],
|
||||||
|
card: null,
|
||||||
|
poll: null,
|
||||||
|
application: null,
|
||||||
|
language: null,
|
||||||
|
pinned: false,
|
||||||
|
emoji_reactions: [],
|
||||||
|
bookmarked: false,
|
||||||
|
quote: false,
|
||||||
|
};
|
||||||
|
}
|
305
packages/backend/src/server/api/mastodon/endpoints/timeline.ts
Normal file
305
packages/backend/src/server/api/mastodon/endpoints/timeline.ts
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
import Router from "@koa/router";
|
||||||
|
import megalodon, { Entity, MegalodonInterface } from "@cutls/megalodon";
|
||||||
|
import { getClient } from "../ApiMastodonCompatibleService.js";
|
||||||
|
import { statusModel } from "./status.js";
|
||||||
|
import Autolinker from "autolinker";
|
||||||
|
import { ParsedUrlQuery } from "querystring";
|
||||||
|
|
||||||
|
export function toLimitToInt(q: ParsedUrlQuery) {
|
||||||
|
if (q.limit)
|
||||||
|
if (typeof q.limit === "string") q.limit = parseInt(q.limit, 10).toString();
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTextWithReaction(status: Entity.Status[], host: string) {
|
||||||
|
return status.map((t) => {
|
||||||
|
if (!t) return statusModel(null, null, [], "no content");
|
||||||
|
if (!t.emoji_reactions) return t;
|
||||||
|
if (t.reblog) t.reblog = toTextWithReaction([t.reblog], host)[0];
|
||||||
|
const reactions = t.emoji_reactions.map(
|
||||||
|
(r) => `${r.name.replace("@.", "")} (${r.count}${r.me ? "* " : ""})`,
|
||||||
|
);
|
||||||
|
//t.emojis = getEmoji(t.content, host)
|
||||||
|
t.content = `<p>${autoLinker(t.content, host)}</p><p>${reactions.join(
|
||||||
|
", ",
|
||||||
|
)}</p>`;
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export function autoLinker(input: string, host: string) {
|
||||||
|
return Autolinker.link(input, {
|
||||||
|
hashtag: "twitter",
|
||||||
|
mention: "twitter",
|
||||||
|
email: false,
|
||||||
|
stripPrefix: false,
|
||||||
|
replaceFn: function (match) {
|
||||||
|
switch (match.type) {
|
||||||
|
case "url":
|
||||||
|
return true;
|
||||||
|
case "mention":
|
||||||
|
console.log("Mention: ", match.getMention());
|
||||||
|
console.log("Mention Service Name: ", match.getServiceName());
|
||||||
|
return `<a href="https://${host}/@${encodeURIComponent(
|
||||||
|
match.getMention(),
|
||||||
|
)}" target="_blank">@${match.getMention()}</a>`;
|
||||||
|
case "hashtag":
|
||||||
|
console.log("Hashtag: ", match.getHashtag());
|
||||||
|
return `<a href="https://${host}/tags/${encodeURIComponent(
|
||||||
|
match.getHashtag(),
|
||||||
|
)}" target="_blank">#${match.getHashtag()}</a>`;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiTimelineMastodon(router: Router): void {
|
||||||
|
router.get("/v1/timelines/public", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const query: any = ctx.query;
|
||||||
|
const data = query.local
|
||||||
|
? await client.getLocalTimeline(toLimitToInt(query))
|
||||||
|
: await client.getPublicTimeline(toLimitToInt(query));
|
||||||
|
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get<{ Params: { hashtag: string } }>(
|
||||||
|
"/v1/timelines/tag/:hashtag",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getTagTimeline(
|
||||||
|
ctx.params.hashtag,
|
||||||
|
toLimitToInt(ctx.query),
|
||||||
|
);
|
||||||
|
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { hashtag: string } }>(
|
||||||
|
"/v1/timelines/home",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getHomeTimeline(toLimitToInt(ctx.query));
|
||||||
|
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { listId: string } }>(
|
||||||
|
"/v1/timelines/list/:listId",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getListTimeline(
|
||||||
|
ctx.params.listId,
|
||||||
|
toLimitToInt(ctx.query),
|
||||||
|
);
|
||||||
|
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get("/v1/conversations", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getConversationTimeline(
|
||||||
|
toLimitToInt(ctx.query),
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get("/v1/lists", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getLists();
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getList(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post("/v1/lists", async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.createList((ctx.query as any).title);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.put<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.updateList(ctx.params.id, ctx.query as any);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.delete<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.deleteList(ctx.params.id);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id/accounts",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.getAccountsInList(
|
||||||
|
ctx.params.id,
|
||||||
|
ctx.query as any,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.post<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id/accounts",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.addAccountsToList(
|
||||||
|
ctx.params.id,
|
||||||
|
(ctx.query as any).account_ids,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.delete<{ Params: { id: string } }>(
|
||||||
|
"/v1/lists/:id/accounts",
|
||||||
|
async (ctx, reply) => {
|
||||||
|
const BASE_URL = `${ctx.protocol}://${ctx.hostname}`;
|
||||||
|
const accessTokens = ctx.headers.authorization;
|
||||||
|
const client = getClient(BASE_URL, accessTokens);
|
||||||
|
try {
|
||||||
|
const data = await client.deleteAccountsFromList(
|
||||||
|
ctx.params.id,
|
||||||
|
(ctx.query as any).account_ids,
|
||||||
|
);
|
||||||
|
ctx.body = data.data;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = e.response.data;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function escapeHTML(str: string) {
|
||||||
|
if (!str) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
function nl2br(str: string) {
|
||||||
|
if (!str) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
str = str.replace(/\r\n/g, "<br />");
|
||||||
|
str = str.replace(/(\n|\r)/g, "<br />");
|
||||||
|
return str;
|
||||||
|
}
|
@ -24,6 +24,9 @@ import { readNotification } from "../common/read-notification.js";
|
|||||||
import channels from "./channels/index.js";
|
import channels from "./channels/index.js";
|
||||||
import type Channel from "./channel.js";
|
import type Channel from "./channel.js";
|
||||||
import type { StreamEventEmitter, StreamMessages } from "./types.js";
|
import type { StreamEventEmitter, StreamMessages } from "./types.js";
|
||||||
|
import { Converter } from "@cutls/megalodon";
|
||||||
|
import { getClient } from "../mastodon/ApiMastodonCompatibleService.js";
|
||||||
|
import { toTextWithReaction } from "../mastodon/endpoints/timeline.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main stream connection
|
* Main stream connection
|
||||||
@ -41,17 +44,27 @@ export default class Connection {
|
|||||||
private channels: Channel[] = [];
|
private channels: Channel[] = [];
|
||||||
private subscribingNotes: any = {};
|
private subscribingNotes: any = {};
|
||||||
private cachedNotes: Packed<"Note">[] = [];
|
private cachedNotes: Packed<"Note">[] = [];
|
||||||
|
private isMastodonCompatible: boolean = false;
|
||||||
|
private host: string;
|
||||||
|
private accessToken: string;
|
||||||
|
private currentSubscribe: string[][] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
wsConnection: websocket.connection,
|
wsConnection: websocket.connection,
|
||||||
subscriber: EventEmitter,
|
subscriber: EventEmitter,
|
||||||
user: User | null | undefined,
|
user: User | null | undefined,
|
||||||
token: AccessToken | null | undefined,
|
token: AccessToken | null | undefined,
|
||||||
|
host: string,
|
||||||
|
accessToken: string,
|
||||||
|
prepareStream: string | undefined,
|
||||||
) {
|
) {
|
||||||
|
console.log("constructor", prepareStream);
|
||||||
this.wsConnection = wsConnection;
|
this.wsConnection = wsConnection;
|
||||||
this.subscriber = subscriber;
|
this.subscriber = subscriber;
|
||||||
if (user) this.user = user;
|
if (user) this.user = user;
|
||||||
if (token) this.token = token;
|
if (token) this.token = token;
|
||||||
|
if (host) this.host = host;
|
||||||
|
if (accessToken) this.accessToken = accessToken;
|
||||||
|
|
||||||
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
|
this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this);
|
||||||
this.onUserEvent = this.onUserEvent.bind(this);
|
this.onUserEvent = this.onUserEvent.bind(this);
|
||||||
@ -73,6 +86,13 @@ export default class Connection {
|
|||||||
|
|
||||||
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
|
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
|
||||||
}
|
}
|
||||||
|
console.log("prepare", prepareStream);
|
||||||
|
if (prepareStream) {
|
||||||
|
this.onWsConnectionMessage({
|
||||||
|
type: "utf8",
|
||||||
|
utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private onUserEvent(data: StreamMessages["user"]["payload"]) {
|
private onUserEvent(data: StreamMessages["user"]["payload"]) {
|
||||||
@ -125,16 +145,106 @@ export default class Connection {
|
|||||||
if (data.type !== "utf8") return;
|
if (data.type !== "utf8") return;
|
||||||
if (data.utf8Data == null) return;
|
if (data.utf8Data == null) return;
|
||||||
|
|
||||||
let obj: Record<string, any>;
|
let objs: Record<string, any>[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
obj = JSON.parse(data.utf8Data);
|
objs = [JSON.parse(data.utf8Data)];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, body } = obj;
|
const simpleObj = objs[0];
|
||||||
|
if (simpleObj.stream) {
|
||||||
|
// is Mastodon Compatible
|
||||||
|
this.isMastodonCompatible = true;
|
||||||
|
if (simpleObj.type === "subscribe") {
|
||||||
|
let forSubscribe = [];
|
||||||
|
if (simpleObj.stream === "user") {
|
||||||
|
this.currentSubscribe.push(["user"]);
|
||||||
|
objs = [
|
||||||
|
{
|
||||||
|
type: "connect",
|
||||||
|
body: {
|
||||||
|
channel: "main",
|
||||||
|
id: simpleObj.stream,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "connect",
|
||||||
|
body: {
|
||||||
|
channel: "homeTimeline",
|
||||||
|
id: simpleObj.stream,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const client = getClient(this.host, this.accessToken);
|
||||||
|
try {
|
||||||
|
const tl = await client.getHomeTimeline();
|
||||||
|
for (const t of tl.data) forSubscribe.push(t.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(e);
|
||||||
|
console.error(e.response.data);
|
||||||
|
}
|
||||||
|
} else if (simpleObj.stream === "public:local") {
|
||||||
|
this.currentSubscribe.push(["public:local"]);
|
||||||
|
objs = [
|
||||||
|
{
|
||||||
|
type: "connect",
|
||||||
|
body: {
|
||||||
|
channel: "localTimeline",
|
||||||
|
id: simpleObj.stream,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const client = getClient(this.host, this.accessToken);
|
||||||
|
const tl = await client.getLocalTimeline();
|
||||||
|
for (const t of tl.data) forSubscribe.push(t.id);
|
||||||
|
} else if (simpleObj.stream === "public") {
|
||||||
|
this.currentSubscribe.push(["public"]);
|
||||||
|
objs = [
|
||||||
|
{
|
||||||
|
type: "connect",
|
||||||
|
body: {
|
||||||
|
channel: "globalTimeline",
|
||||||
|
id: simpleObj.stream,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const client = getClient(this.host, this.accessToken);
|
||||||
|
const tl = await client.getPublicTimeline();
|
||||||
|
for (const t of tl.data) forSubscribe.push(t.id);
|
||||||
|
} else if (simpleObj.stream === "list") {
|
||||||
|
this.currentSubscribe.push(["list", simpleObj.list]);
|
||||||
|
objs = [
|
||||||
|
{
|
||||||
|
type: "connect",
|
||||||
|
body: {
|
||||||
|
channel: "list",
|
||||||
|
id: simpleObj.stream,
|
||||||
|
params: {
|
||||||
|
listId: simpleObj.list,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const client = getClient(this.host, this.accessToken);
|
||||||
|
const tl = await client.getListTimeline(simpleObj.list);
|
||||||
|
for (const t of tl.data) forSubscribe.push(t.id);
|
||||||
|
}
|
||||||
|
for (const s of forSubscribe) {
|
||||||
|
objs.push({
|
||||||
|
type: "s",
|
||||||
|
body: {
|
||||||
|
id: s,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const obj of objs) {
|
||||||
|
const { type, body } = obj;
|
||||||
|
console.log(type, body);
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "readNotification":
|
case "readNotification":
|
||||||
this.onReadNotification(body);
|
this.onReadNotification(body);
|
||||||
@ -179,6 +289,7 @@ export default class Connection {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) {
|
private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) {
|
||||||
this.sendMessageToWs(data.type, data.body);
|
this.sendMessageToWs(data.type, data.body);
|
||||||
@ -280,6 +391,68 @@ export default class Connection {
|
|||||||
* クライアントにメッセージ送信
|
* クライアントにメッセージ送信
|
||||||
*/
|
*/
|
||||||
public sendMessageToWs(type: string, payload: any) {
|
public sendMessageToWs(type: string, payload: any) {
|
||||||
|
console.log(payload, this.isMastodonCompatible);
|
||||||
|
if (this.isMastodonCompatible) {
|
||||||
|
if (payload.type === "note") {
|
||||||
|
this.wsConnection.send(
|
||||||
|
JSON.stringify({
|
||||||
|
stream: [payload.id],
|
||||||
|
event: "update",
|
||||||
|
payload: JSON.stringify(
|
||||||
|
toTextWithReaction(
|
||||||
|
[Converter.note(payload.body, this.host)],
|
||||||
|
this.host,
|
||||||
|
)[0],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.onSubscribeNote({
|
||||||
|
id: payload.body.id,
|
||||||
|
});
|
||||||
|
} else if (payload.type === "reacted" || payload.type === "unreacted") {
|
||||||
|
// reaction
|
||||||
|
const client = getClient(this.host, this.accessToken);
|
||||||
|
client.getStatus(payload.id).then((data) => {
|
||||||
|
const newPost = toTextWithReaction([data.data], this.host);
|
||||||
|
for (const stream of this.currentSubscribe) {
|
||||||
|
this.wsConnection.send(
|
||||||
|
JSON.stringify({
|
||||||
|
stream,
|
||||||
|
event: "status.update",
|
||||||
|
payload: JSON.stringify(newPost[0]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (payload.type === "deleted") {
|
||||||
|
// delete
|
||||||
|
for (const stream of this.currentSubscribe) {
|
||||||
|
this.wsConnection.send(
|
||||||
|
JSON.stringify({
|
||||||
|
stream,
|
||||||
|
event: "delete",
|
||||||
|
payload: payload.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (payload.type === "unreadNotification") {
|
||||||
|
if (payload.id === "user") {
|
||||||
|
const body = Converter.notification(payload.body, this.host);
|
||||||
|
if (body.type === "reaction") body.type = "favourite";
|
||||||
|
body.status = toTextWithReaction(
|
||||||
|
body.status ? [body.status] : [],
|
||||||
|
"",
|
||||||
|
)[0];
|
||||||
|
this.wsConnection.send(
|
||||||
|
JSON.stringify({
|
||||||
|
stream: ["user"],
|
||||||
|
event: "notification",
|
||||||
|
payload: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.wsConnection.send(
|
this.wsConnection.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: type,
|
type: type,
|
||||||
@ -287,6 +460,7 @@ export default class Connection {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* チャンネルに接続
|
* チャンネルに接続
|
||||||
|
@ -16,10 +16,13 @@ export const initializeStreamingServer = (server: http.Server) => {
|
|||||||
|
|
||||||
ws.on("request", async (request) => {
|
ws.on("request", async (request) => {
|
||||||
const q = request.resourceURL.query as ParsedUrlQuery;
|
const q = request.resourceURL.query as ParsedUrlQuery;
|
||||||
|
const headers = request.httpRequest.headers["sec-websocket-protocol"] || "";
|
||||||
|
const cred = q.i || q.access_token || headers;
|
||||||
|
const accessToken = cred.toString();
|
||||||
|
|
||||||
const [user, app] = await authenticate(
|
const [user, app] = await authenticate(
|
||||||
request.httpRequest.headers.authorization,
|
request.httpRequest.headers.authorization,
|
||||||
q.i,
|
accessToken,
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
request.reject(403, err.message);
|
request.reject(403, err.message);
|
||||||
return [];
|
return [];
|
||||||
@ -43,8 +46,19 @@ export const initializeStreamingServer = (server: http.Server) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
redisClient.on("message", onRedisMessage);
|
redisClient.on("message", onRedisMessage);
|
||||||
|
const host = `https://${request.host}`;
|
||||||
|
const prepareStream = q.stream?.toString();
|
||||||
|
console.log("start", q);
|
||||||
|
|
||||||
const main = new MainStreamConnection(connection, ev, user, app);
|
const main = new MainStreamConnection(
|
||||||
|
connection,
|
||||||
|
ev,
|
||||||
|
user,
|
||||||
|
app,
|
||||||
|
host,
|
||||||
|
accessToken,
|
||||||
|
prepareStream,
|
||||||
|
);
|
||||||
|
|
||||||
const intervalId = user
|
const intervalId = user
|
||||||
? setInterval(() => {
|
? setInterval(() => {
|
||||||
|
@ -20,6 +20,7 @@ import { createTemp } from "@/misc/create-temp.js";
|
|||||||
import { publishMainStream } from "@/services/stream.js";
|
import { publishMainStream } from "@/services/stream.js";
|
||||||
import * as Acct from "@/misc/acct.js";
|
import * as Acct from "@/misc/acct.js";
|
||||||
import { envOption } from "@/env.js";
|
import { envOption } from "@/env.js";
|
||||||
|
import megalodon, { MegalodonInterface } from "@cutls/megalodon";
|
||||||
import activityPub from "./activitypub.js";
|
import activityPub from "./activitypub.js";
|
||||||
import nodeinfo from "./nodeinfo.js";
|
import nodeinfo from "./nodeinfo.js";
|
||||||
import wellKnown from "./well-known.js";
|
import wellKnown from "./well-known.js";
|
||||||
@ -28,6 +29,7 @@ import fileServer from "./file/index.js";
|
|||||||
import proxyServer from "./proxy/index.js";
|
import proxyServer from "./proxy/index.js";
|
||||||
import webServer from "./web/index.js";
|
import webServer from "./web/index.js";
|
||||||
import { initializeStreamingServer } from "./api/streaming.js";
|
import { initializeStreamingServer } from "./api/streaming.js";
|
||||||
|
import { koaBody } from "koa-body";
|
||||||
|
|
||||||
export const serverLogger = new Logger("server", "gray", false);
|
export const serverLogger = new Logger("server", "gray", false);
|
||||||
|
|
||||||
@ -68,6 +70,11 @@ app.use(mount("/proxy", proxyServer));
|
|||||||
|
|
||||||
// Init router
|
// Init router
|
||||||
const router = new Router();
|
const router = new Router();
|
||||||
|
const mastoRouter = new Router();
|
||||||
|
|
||||||
|
mastoRouter.use(koaBody({
|
||||||
|
urlencoded: true
|
||||||
|
}));
|
||||||
|
|
||||||
// Routing
|
// Routing
|
||||||
router.use(activityPub.routes());
|
router.use(activityPub.routes());
|
||||||
@ -133,7 +140,52 @@ router.get("/verify-email/:code", async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
mastoRouter.get("/oauth/authorize", async (ctx) => {
|
||||||
|
const client_id = ctx.request.query.client_id;
|
||||||
|
console.log(ctx.request.req);
|
||||||
|
ctx.redirect(Buffer.from(client_id?.toString() || "", "base64").toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
mastoRouter.post("/oauth/token", async (ctx) => {
|
||||||
|
const body: any = ctx.request.body;
|
||||||
|
let client_id: any = ctx.request.query.client_id;
|
||||||
|
const BASE_URL = `${ctx.request.protocol}://${ctx.request.hostname}`;
|
||||||
|
const generator = (megalodon as any).default;
|
||||||
|
const client = generator("misskey", BASE_URL, null) as MegalodonInterface;
|
||||||
|
let m = null;
|
||||||
|
if (body.code) {
|
||||||
|
m = body.code.match(/^[a-zA-Z0-9-]+/);
|
||||||
|
if (!m.length) {
|
||||||
|
ctx.body = { error: "Invalid code" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (client_id instanceof Array) {
|
||||||
|
client_id = client_id.toString();;
|
||||||
|
} else if (!client_id) {
|
||||||
|
client_id = null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const atData = await client.fetchAccessToken(
|
||||||
|
client_id,
|
||||||
|
body.client_secret,
|
||||||
|
m ? m[0] : '',
|
||||||
|
);
|
||||||
|
ctx.body = {
|
||||||
|
access_token: atData.accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
scope: "read write follow",
|
||||||
|
created_at: Math.floor(new Date().getTime() / 1000),
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
ctx.status = 401;
|
||||||
|
ctx.body = err.response.data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Register router
|
// Register router
|
||||||
|
app.use(mastoRouter.routes());
|
||||||
app.use(router.routes());
|
app.use(router.routes());
|
||||||
|
|
||||||
app.use(mount(webServer));
|
app.use(mount(webServer));
|
||||||
|
@ -634,6 +634,10 @@ router.get("/streaming", async (ctx) => {
|
|||||||
ctx.status = 503;
|
ctx.status = 503;
|
||||||
ctx.set("Cache-Control", "private, max-age=0");
|
ctx.set("Cache-Control", "private, max-age=0");
|
||||||
});
|
});
|
||||||
|
router.get("/api/v1/streaming", async (ctx) => {
|
||||||
|
ctx.status = 503;
|
||||||
|
ctx.set("Cache-Control", "private, max-age=0");
|
||||||
|
});
|
||||||
|
|
||||||
// Render base html for all requests
|
// Render base html for all requests
|
||||||
router.get("(.*)", async (ctx) => {
|
router.get("(.*)", async (ctx) => {
|
||||||
|
@ -44,6 +44,23 @@ export const urlPreviewHandler = async (ctx: Koa.Context) => {
|
|||||||
|
|
||||||
logger.succ(`Got preview of ${url}: ${summary.title}`);
|
logger.succ(`Got preview of ${url}: ${summary.title}`);
|
||||||
|
|
||||||
|
if (
|
||||||
|
summary.url &&
|
||||||
|
!(summary.url.startsWith("http://") || summary.url.startsWith("https://"))
|
||||||
|
) {
|
||||||
|
throw new Error("unsupported schema included");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
summary.player?.url &&
|
||||||
|
!(
|
||||||
|
summary.player.url.startsWith("http://") ||
|
||||||
|
summary.player.url.startsWith("https://")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("unsupported schema included");
|
||||||
|
}
|
||||||
|
|
||||||
summary.icon = wrap(summary.icon);
|
summary.icon = wrap(summary.icon);
|
||||||
summary.thumbnail = wrap(summary.thumbnail);
|
summary.thumbnail = wrap(summary.thumbnail);
|
||||||
|
|
||||||
|
BIN
packages/client/assets/dummy.png
Normal file
BIN
packages/client/assets/dummy.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
packages/client/assets/dummy_original.png
Normal file
BIN
packages/client/assets/dummy_original.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 68 KiB |
@ -7,11 +7,25 @@
|
|||||||
"lint": "pnpm rome check \"src/**/*.{ts,vue}\""
|
"lint": "pnpm rome check \"src/**/*.{ts,vue}\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@khmyznikov/pwa-install": "^0.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
"@discordapp/twemoji": "14.0.2",
|
"@discordapp/twemoji": "14.0.2",
|
||||||
"@rollup/plugin-alias": "3.1.9",
|
"@rollup/plugin-alias": "3.1.9",
|
||||||
"@rollup/plugin-json": "4.1.0",
|
"@rollup/plugin-json": "4.1.0",
|
||||||
"@rollup/pluginutils": "^4.2.1",
|
"@rollup/pluginutils": "^4.2.1",
|
||||||
"@syuilo/aiscript": "0.11.1",
|
"@syuilo/aiscript": "0.11.1",
|
||||||
|
"@types/escape-regexp": "0.0.1",
|
||||||
|
"@types/glob": "8.0.0",
|
||||||
|
"@types/gulp": "4.0.10",
|
||||||
|
"@types/gulp-rename": "2.0.1",
|
||||||
|
"@types/katex": "0.14.0",
|
||||||
|
"@types/matter-js": "0.18.2",
|
||||||
|
"@types/punycode": "2.1.0",
|
||||||
|
"@types/seedrandom": "3.0.4",
|
||||||
|
"@types/throttle-debounce": "5.0.0",
|
||||||
|
"@types/tinycolor2": "1.4.3",
|
||||||
|
"@types/uuid": "8.3.4",
|
||||||
"@vitejs/plugin-vue": "4.0.0",
|
"@vitejs/plugin-vue": "4.0.0",
|
||||||
"@vue/compiler-sfc": "3.2.45",
|
"@vue/compiler-sfc": "3.2.45",
|
||||||
"autobind-decorator": "2.4.0",
|
"autobind-decorator": "2.4.0",
|
||||||
@ -26,6 +40,8 @@
|
|||||||
"chartjs-plugin-zoom": "1.2.1",
|
"chartjs-plugin-zoom": "1.2.1",
|
||||||
"compare-versions": "5.0.3",
|
"compare-versions": "5.0.3",
|
||||||
"cropperjs": "2.0.0-beta.2",
|
"cropperjs": "2.0.0-beta.2",
|
||||||
|
"cross-env": "7.0.3",
|
||||||
|
"cypress": "10.11.0",
|
||||||
"date-fns": "2.29.3",
|
"date-fns": "2.29.3",
|
||||||
"escape-regexp": "0.0.1",
|
"escape-regexp": "0.0.1",
|
||||||
"eventemitter3": "4.0.7",
|
"eventemitter3": "4.0.7",
|
||||||
@ -40,9 +56,11 @@
|
|||||||
"punycode": "2.1.1",
|
"punycode": "2.1.1",
|
||||||
"querystring": "0.2.1",
|
"querystring": "0.2.1",
|
||||||
"rndstr": "1.0.0",
|
"rndstr": "1.0.0",
|
||||||
|
"rollup": "3.9.1",
|
||||||
"s-age": "1.1.2",
|
"s-age": "1.1.2",
|
||||||
"sass": "1.57.1",
|
"sass": "1.57.1",
|
||||||
"seedrandom": "3.0.5",
|
"seedrandom": "3.0.5",
|
||||||
|
"start-server-and-test": "1.15.2",
|
||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"swiper": "^8.4.5",
|
"swiper": "^8.4.5",
|
||||||
@ -57,28 +75,11 @@
|
|||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"uuid": "9.0.0",
|
"uuid": "9.0.0",
|
||||||
"vanilla-tilt": "1.8.0",
|
"vanilla-tilt": "1.8.0",
|
||||||
"vite": "^4.1.0-beta.1",
|
"vite": "^4.1.1",
|
||||||
"vue": "3.2.45",
|
"vue": "3.2.45",
|
||||||
"vue-isyourpasswordsafe": "^2.0.0",
|
"vue-isyourpasswordsafe": "^2.0.0",
|
||||||
"vue-plyr": "^7.0.0",
|
"vue-plyr": "^7.0.0",
|
||||||
"vue-prism-editor": "2.0.0-alpha.2",
|
"vue-prism-editor": "2.0.0-alpha.2",
|
||||||
"vuedraggable": "4.1.0"
|
"vuedraggable": "4.1.0"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/escape-regexp": "0.0.1",
|
|
||||||
"@types/glob": "8.0.0",
|
|
||||||
"@types/gulp": "4.0.10",
|
|
||||||
"@types/gulp-rename": "2.0.1",
|
|
||||||
"@types/katex": "0.14.0",
|
|
||||||
"@types/matter-js": "0.18.2",
|
|
||||||
"@types/punycode": "2.1.0",
|
|
||||||
"@types/seedrandom": "3.0.4",
|
|
||||||
"@types/throttle-debounce": "5.0.0",
|
|
||||||
"@types/tinycolor2": "1.4.3",
|
|
||||||
"@types/uuid": "8.3.4",
|
|
||||||
"cross-env": "7.0.3",
|
|
||||||
"cypress": "10.11.0",
|
|
||||||
"rollup": "3.9.1",
|
|
||||||
"start-server-and-test": "1.15.2"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="nrvgflfu _button" @click="toggle">
|
<button class="nrvgflfu _button" @click.stop.prevent="toggle">
|
||||||
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
|
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
|
||||||
<span v-if="!modelValue">{{ label }}</span>
|
<span v-if="!modelValue">{{ label }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="kpoogebi _button"
|
class="kpoogebi _button"
|
||||||
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
|
:class="{
|
||||||
|
wait,
|
||||||
|
active: isFollowing || hasPendingFollowRequestFromYou,
|
||||||
|
full,
|
||||||
|
large,
|
||||||
|
blocking: isBlocking
|
||||||
|
}"
|
||||||
:disabled="wait"
|
:disabled="wait"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<template v-if="!wait">
|
<template v-if="!wait">
|
||||||
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
|
<template v-if="isBlocking">
|
||||||
|
<span v-if="full">{{ i18n.ts.blocked }}</span><i class="ph-prohibit-bold ph-lg"></i>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="hasPendingFollowRequestFromYou && user.isLocked">
|
||||||
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium-bold ph-lg"></i>
|
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium-bold ph-lg"></i>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
|
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
|
||||||
@ -30,12 +39,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onBeforeUnmount, onMounted } from 'vue';
|
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||||
import type * as Misskey from 'calckey-js';
|
import type * as Misskey from 'calckey-js';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { stream } from '@/stream';
|
import { stream } from '@/stream';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh'])
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
user: Misskey.entities.UserDetailed,
|
user: Misskey.entities.UserDetailed,
|
||||||
full?: boolean,
|
full?: boolean,
|
||||||
@ -45,6 +55,8 @@ const props = withDefaults(defineProps<{
|
|||||||
large: false,
|
large: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isBlocking = computed(() => props.user.isBlocking);
|
||||||
|
|
||||||
let isFollowing = $ref(props.user.isFollowing);
|
let isFollowing = $ref(props.user.isFollowing);
|
||||||
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
|
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
|
||||||
let wait = $ref(false);
|
let wait = $ref(false);
|
||||||
@ -68,7 +80,24 @@ async function onClick() {
|
|||||||
wait = true;
|
wait = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isFollowing) {
|
if (isBlocking.value) {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: i18n.t('unblockConfirm'),
|
||||||
|
});
|
||||||
|
if (canceled) return
|
||||||
|
|
||||||
|
await os.api("blocking/delete", {
|
||||||
|
userId: props.user.id,
|
||||||
|
})
|
||||||
|
if (props.user.isMuted) {
|
||||||
|
await os.api("mute/delete", {
|
||||||
|
userId: props.user.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
emit('refresh')
|
||||||
|
}
|
||||||
|
else if (isFollowing) {
|
||||||
const { canceled } = await os.confirm({
|
const { canceled } = await os.confirm({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
|
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
|
||||||
@ -184,4 +213,9 @@ onBeforeUnmount(() => {
|
|||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blocking {
|
||||||
|
background-color: var(--bg) !important;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<div class="hoawjimk">
|
<div class="hoawjimk">
|
||||||
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
|
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
|
||||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }">
|
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }">
|
||||||
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length">
|
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop.prevent>
|
||||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||||
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
|
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
|
||||||
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
|
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
|
||||||
</a>
|
</a>
|
||||||
<button v-else-if="item.type === 'user'" :tabindex="0" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else-if="item.type === 'user' && !items.hidden" :tabindex="0" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
|
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
|
||||||
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
|
<span v-if="item.indicate" class="indicator"><i class="ph-circle-fill"></i></span>
|
||||||
</button>
|
</button>
|
||||||
@ -38,7 +38,7 @@
|
|||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span class="caret"><i class="ph-caret-right-bold ph-lg ph-fw ph-lg"></i></span>
|
<span class="caret"><i class="ph-caret-right-bold ph-lg ph-fw ph-lg"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else :tabindex="0" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else-if="!item.hidden" :tabindex="0" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ph-fw ph-lg" :class="item.icon"></i>
|
<i v-if="item.icon" class="ph-fw ph-lg" :class="item.icon"></i>
|
||||||
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
|
@ -62,10 +62,10 @@
|
|||||||
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
|
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
|
||||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
|
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
|
||||||
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
|
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
|
||||||
<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false">
|
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
|
||||||
<span>{{ i18n.ts.showMore }}</span>
|
<span>{{ i18n.ts.showMore }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true">
|
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
|
||||||
<span>{{ i18n.ts.showLess }}</span>
|
<span>{{ i18n.ts.showLess }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,10 +14,10 @@
|
|||||||
<summary>{{ i18n.ts.poll }}</summary>
|
<summary>{{ i18n.ts.poll }}</summary>
|
||||||
<XPoll :note="note"/>
|
<XPoll :note="note"/>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="isLong && collapsed" class="fade _button" @click="collapsed = false">
|
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
|
||||||
<span>{{ i18n.ts.showMore }}</span>
|
<span>{{ i18n.ts.showMore }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-if="isLong && !collapsed" class="showLess _button" @click="collapsed = true">
|
<button v-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
|
||||||
<span>{{ i18n.ts.showLess }}</span>
|
<span>{{ i18n.ts.showLess }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -67,6 +67,7 @@ const embedId = `embed${Math.random().toString().replace(/\D/,'')}`;
|
|||||||
let tweetHeight = $ref(150);
|
let tweetHeight = $ref(150);
|
||||||
|
|
||||||
const requestUrl = new URL(props.url);
|
const requestUrl = new URL(props.url);
|
||||||
|
if (!['http:', 'https:'].includes(requestUrl.protocol)) throw new Error('invalid url');
|
||||||
|
|
||||||
if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') {
|
if (requestUrl.hostname === 'twitter.com' || requestUrl.hostname === 'mobile.twitter.com') {
|
||||||
const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
|
const m = requestUrl.pathname.match(/^\/.+\/status(?:es)?\/(\d+)/);
|
||||||
|
@ -169,13 +169,12 @@ const props = withDefaults(defineProps<{
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep(.quote) {
|
::v-deep(blockquote) {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 8px;
|
margin: 8px 0;
|
||||||
padding: 6px 0 6px 12px;
|
padding-left: 12px;
|
||||||
color: var(--fg);
|
color: var(--fgTransparentWeak);
|
||||||
border-left: solid 3px var(--fg);
|
border-left: solid 4px var(--fgTransparent);
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::v-deep(pre) {
|
::v-deep(pre) {
|
||||||
|
@ -16,10 +16,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabs">
|
<div ref="tabsEl" v-if="hasTabs" class="tabs">
|
||||||
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
|
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
|
||||||
<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
|
<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
|
||||||
<span v-if="!tab.iconOnly && !narrow" class="title">{{ tab.title }}</span>
|
<span v-if="deviceKind !== 'desktop' || isTouchUsing || (!tab.iconOnly && !narrow)" class="title">{{ tab.title }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div ref="tabHighlightEl" class="highlight"></div>
|
<div ref="tabHighlightEl" class="highlight"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -39,6 +39,8 @@ import { popupMenu } from '@/os';
|
|||||||
import { scrollToTop } from '@/scripts/scroll';
|
import { scrollToTop } from '@/scripts/scroll';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { globalEvents } from '@/events';
|
import { globalEvents } from '@/events';
|
||||||
|
import { deviceKind } from '@/scripts/device-kind';
|
||||||
|
import { isTouchUsing } from '@/scripts/touch';
|
||||||
import { injectPageMetadata } from '@/scripts/page-metadata';
|
import { injectPageMetadata } from '@/scripts/page-metadata';
|
||||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
||||||
|
|
||||||
@ -74,6 +76,7 @@ const thin_ = props.thin || inject('shouldHeaderThin', false);
|
|||||||
const el = $ref<HTMLElement | null>(null);
|
const el = $ref<HTMLElement | null>(null);
|
||||||
const tabRefs = {};
|
const tabRefs = {};
|
||||||
const tabHighlightEl = $ref<HTMLElement | null>(null);
|
const tabHighlightEl = $ref<HTMLElement | null>(null);
|
||||||
|
const tabsEl = $ref<HTMLElement | null>(null);
|
||||||
const bg = ref(null);
|
const bg = ref(null);
|
||||||
let narrow = $ref(false);
|
let narrow = $ref(false);
|
||||||
const height = ref(0);
|
const height = ref(0);
|
||||||
@ -150,10 +153,12 @@ onMounted(() => {
|
|||||||
if (tabEl && tabHighlightEl) {
|
if (tabEl && tabHighlightEl) {
|
||||||
// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
|
// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
|
||||||
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
|
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
|
||||||
const parentRect = tabEl.parentElement.getBoundingClientRect();
|
const parentRect = tabsEl.getBoundingClientRect();
|
||||||
const rect = tabEl.getBoundingClientRect();
|
const rect = tabEl.getBoundingClientRect();
|
||||||
|
const left = (rect.left - parentRect.left + tabsEl?.scrollLeft);
|
||||||
tabHighlightEl.style.width = rect.width + 'px';
|
tabHighlightEl.style.width = rect.width + 'px';
|
||||||
tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px';
|
tabHighlightEl.style.left = left + 'px';
|
||||||
|
tabsEl.scrollTo({left: left - 80, behavior: "smooth"});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
@ -199,8 +204,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.slim {
|
&.slim {
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
> .titleContainer {
|
> .titleContainer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@ -213,13 +216,26 @@ onUnmounted(() => {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
> .tabs {
|
||||||
|
padding-inline: 12px;
|
||||||
|
mask: linear-gradient(to right, black 80%, transparent);
|
||||||
|
-webkit-mask: linear-gradient(to right, black 80%, transparent);
|
||||||
|
scrollbar-width: none;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
&::after { // Force right padding
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 20%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> .buttons {
|
> .buttons {
|
||||||
--margin: 8px;
|
--margin: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: var(--height);
|
|
||||||
height: var(--height);
|
height: var(--height);
|
||||||
margin: 0 var(--margin);
|
margin: 0 var(--margin);
|
||||||
|
|
||||||
@ -242,7 +258,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:empty {
|
&:empty {
|
||||||
width: var(--height);
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .button {
|
> .button {
|
||||||
@ -331,16 +347,18 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
overflow: auto;
|
overflow-x: auto;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
> .tab {
|
> .tab {
|
||||||
display: inline-block;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
transition: color .2s, opacity .2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -348,6 +366,8 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .icon + .title {
|
> .icon + .title {
|
||||||
|
@ -33,6 +33,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const self = props.url.startsWith(local);
|
const self = props.url.startsWith(local);
|
||||||
const url = new URL(props.url);
|
const url = new URL(props.url);
|
||||||
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('invalid url');
|
||||||
const el = ref();
|
const el = ref();
|
||||||
|
|
||||||
useTooltip(el, (showing) => {
|
useTooltip(el, (showing) => {
|
||||||
|
@ -377,15 +377,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
case "quote": {
|
case "quote": {
|
||||||
if (!this.nowrap) {
|
if (!this.nowrap) {
|
||||||
return [
|
return [h("blockquote", genEl(token.children))];
|
||||||
h(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
class: "quote",
|
|
||||||
},
|
|
||||||
genEl(token.children),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
h(
|
h(
|
||||||
|
@ -5,6 +5,10 @@ import * as os from "@/os";
|
|||||||
import { i18n } from "@/i18n";
|
import { i18n } from "@/i18n";
|
||||||
import { ui } from "@/config";
|
import { ui } from "@/config";
|
||||||
import { unisonReload } from "@/scripts/unison-reload";
|
import { unisonReload } from "@/scripts/unison-reload";
|
||||||
|
import { defaultStore } from "@/store";
|
||||||
|
import { instance } from "@/instance";
|
||||||
|
import { host } from "@/config";
|
||||||
|
import XTutorial from "@/components/MkTutorialDialog.vue";
|
||||||
|
|
||||||
export const navbarItemDef = reactive({
|
export const navbarItemDef = reactive({
|
||||||
notifications: {
|
notifications: {
|
||||||
@ -144,4 +148,73 @@ export const navbarItemDef = reactive({
|
|||||||
location.reload();
|
location.reload();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
help: {
|
||||||
|
title: "help",
|
||||||
|
icon: "ph-question-bold ph-lg",
|
||||||
|
action: (ev) => {
|
||||||
|
os.popupMenu(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: instance.name ?? host,
|
||||||
|
type: "label",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
text: i18n.ts.instanceInfo,
|
||||||
|
icon: "ph-info-bold ph-lg",
|
||||||
|
to: "/about",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
text: i18n.ts.aboutMisskey,
|
||||||
|
icon: "ph-lightbulb-bold ph-lg",
|
||||||
|
to: "/about-calckey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
text: i18n.ts._apps.apps,
|
||||||
|
icon: "ph-device-mobile-bold ph-lg",
|
||||||
|
to: "/apps",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "button",
|
||||||
|
action: async () => {
|
||||||
|
defaultStore.set("tutorial", 0);
|
||||||
|
os.popup(XTutorial, {}, {}, "closed");
|
||||||
|
},
|
||||||
|
text: i18n.ts.replayTutorial,
|
||||||
|
icon: "ph-circle-wavy-question-bold ph-lg",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
type: "parent",
|
||||||
|
text: i18n.ts.developer,
|
||||||
|
icon: "ph-code-bold ph-lg",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
to: "/api-console",
|
||||||
|
text: "API Console",
|
||||||
|
icon: "ph-terminal-window-bold ph-lg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: i18n.ts.document,
|
||||||
|
icon: "ph-file-doc-bold ph-lg",
|
||||||
|
action: () => {
|
||||||
|
window.open("/api-doc", "_blank");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "link",
|
||||||
|
to: "/scratchpad",
|
||||||
|
text: "AiScript Scratchpad",
|
||||||
|
icon: "ph-scribble-loop-bold ph-lg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ev.currentTarget ?? ev.target,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -39,9 +39,10 @@
|
|||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
|
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
|
||||||
<div class="_formLinks">
|
<div class="_formLinks">
|
||||||
<FormLink to="/@t1c@i.calckey.cloud"><Mfm :text="'$[sparkle @t1c@i.calckey.cloud] (Main developer)'"/></FormLink>
|
<FormLink to="/@kainoa@calckey.social"><Mfm :text="'$[sparkle @kainoa@calckey.social] (Main developer)'"/></FormLink>
|
||||||
<FormLink to="/@cleo@bz.pawdev.me"><Mfm :text="'@cleo@bz.pawdev.me (Maintainer)'"/></FormLink>
|
<FormLink to="/@cleo@bz.pawdev.me"><Mfm :text="'@cleo@bz.pawdev.me (Maintainer)'"/></FormLink>
|
||||||
<FormLink to="/@syuilo@misskey.io"><Mfm :text="'@syuilo@misskey.io (Original Misskey developer)'"/></FormLink>
|
<FormLink to="/@panos@i.calckey.cloud"><Mfm :text="'@panos@i.calckey.cloud (Management)'"/></FormLink>
|
||||||
|
<FormLink to="/@freeplay@bz.pawdev.me"><Mfm :text="'@freeplay@bz.pawdev.me (UI/UX Designer)'"/></FormLink>
|
||||||
<FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink>
|
<FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink>
|
||||||
</div>
|
</div>
|
||||||
<template #caption><MkLink url="https://codeberg.org/calckey/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
|
<template #caption><MkLink url="https://codeberg.org/calckey/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
|
||||||
|
99
packages/client/src/pages/apps.vue
Normal file
99
packages/client/src/pages/apps.vue
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
|
<div style="overflow: clip;">
|
||||||
|
<MkSpacer :content-max="600" :margin-min="20">
|
||||||
|
<div class="_formRoot">
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts._apps.crossPlatform }}</template>
|
||||||
|
<div class="_formBlock" style="text-align: center;">
|
||||||
|
<pwa-install/>
|
||||||
|
<MkButton primary rounded inline @click="installPwa">{{ i18n.ts._apps.pwa }}</MkButton>
|
||||||
|
</div>
|
||||||
|
<div class="_formLinks">
|
||||||
|
<FormLink to="https://kaiteki.app" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-android-logo-bold ph-xl"/>
|
||||||
|
<i class="ph-windows-logo-bold ph-xl"/>
|
||||||
|
<i class="ph-linux-logo-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.kaiteki }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.firstClass }}, {{ i18n.ts._apps.free }}</template>
|
||||||
|
</FormLink>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts._apps.mobile }}</template>
|
||||||
|
<div class="_formLinks">
|
||||||
|
<FormLink to="https://play.google.com/store/apps/details?id=jp.panta.misskeyandroidclient" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-android-logo-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.milktea }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.firstClass }}, {{ i18n.ts._apps.free }}</template>
|
||||||
|
</FormLink>
|
||||||
|
<FormLink to="https://play.google.com/store/apps/details?id=jp.juggler.subwaytooter&gl=US" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-android-logo-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.subwayTooter }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
|
||||||
|
</FormLink>
|
||||||
|
<!-- <FormLink to="https://apps.apple.com/app/kimis-a-client-for-misskey/id1667275125" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-apple-logo-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.kimis }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.paid }}</template>
|
||||||
|
</FormLink> -->
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
<FormSection>
|
||||||
|
<template #label>{{ i18n.ts.desktop }}</template>
|
||||||
|
<div class="_formLinks">
|
||||||
|
<FormLink to="https://thedesk.top/" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-apple-logo-bold ph-xl"/>
|
||||||
|
<i class="ph-windows-logo-bold ph-xl"/>
|
||||||
|
<i class="ph-linux-logo-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.theDesk }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
|
||||||
|
</FormLink>
|
||||||
|
<FormLink to="https://github.com/AsPulse/lesskey" external>
|
||||||
|
<template #icon>
|
||||||
|
<i class="ph-terminal-window-bold ph-xl"/>
|
||||||
|
</template>
|
||||||
|
{{ i18n.ts._apps.lesskey }}
|
||||||
|
<template #suffix>{{ i18n.ts._apps.secondClass }}, {{ i18n.ts._apps.free }}</template>
|
||||||
|
</FormLink>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
</div>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import FormLink from '@/components/form/link.vue';
|
||||||
|
import FormSection from '@/components/form/section.vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||||
|
import '@khmyznikov/pwa-install';
|
||||||
|
|
||||||
|
const headerActions = $computed(() => []);
|
||||||
|
|
||||||
|
const headerTabs = $computed(() => []);
|
||||||
|
|
||||||
|
function installPwa(ev: MouseEvent) {
|
||||||
|
const pwaInstall = document.getElementsByTagName('pwa-install')[0];
|
||||||
|
pwaInstall.showDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
definePageMetadata({
|
||||||
|
title: i18n.ts._apps.apps,
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
</script>
|
@ -78,8 +78,11 @@ export default defineComponent({
|
|||||||
methods: {
|
methods: {
|
||||||
accepted() {
|
accepted() {
|
||||||
this.state = 'accepted';
|
this.state = 'accepted';
|
||||||
|
const getUrlParams = () => window.location.search.substring(1).split('&').reduce((result, query) => { const [k, v] = query.split('='); result[k] = decodeURI(v); return result; }, {});
|
||||||
if (this.session.app.callbackUrl) {
|
if (this.session.app.callbackUrl) {
|
||||||
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}`;
|
const url = new URL(this.session.app.callbackUrl);
|
||||||
|
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(url.protocol)) throw new Error('invalid url');
|
||||||
|
location.href = `${this.session.app.callbackUrl}?token=${this.session.token}&code=${this.session.token}&state=${getUrlParams().state || ''}`;
|
||||||
}
|
}
|
||||||
}, onLogin(res) {
|
}, onLogin(res) {
|
||||||
login(res.i);
|
login(res.i);
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
<swiper-slide>
|
<swiper-slide>
|
||||||
<div class="_formRoot">
|
<div class="_formRoot">
|
||||||
<div class="fnfelxur">
|
<div class="fnfelxur">
|
||||||
<img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/>
|
<img :src="faviconUrl" alt="" class="icon"/>
|
||||||
<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
|
<span class="name">{{ instance.name || `(${i18n.ts.unknown})` }}</span>
|
||||||
</div>
|
</div>
|
||||||
<MkKeyValue :copy="host" oneline style="margin: 1em 0;">
|
<MkKeyValue :copy="host" oneline style="margin: 1em 0;">
|
||||||
@ -156,6 +156,7 @@ import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
|||||||
import MkPagination from '@/components/MkPagination.vue';
|
import MkPagination from '@/components/MkPagination.vue';
|
||||||
import 'swiper/scss';
|
import 'swiper/scss';
|
||||||
import 'swiper/scss/virtual';
|
import 'swiper/scss/virtual';
|
||||||
|
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
host: string;
|
host: string;
|
||||||
@ -171,6 +172,7 @@ let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null);
|
|||||||
let instance = $ref<misskey.entities.Instance | null>(null);
|
let instance = $ref<misskey.entities.Instance | null>(null);
|
||||||
let suspended = $ref(false);
|
let suspended = $ref(false);
|
||||||
let isBlocked = $ref(false);
|
let isBlocked = $ref(false);
|
||||||
|
let faviconUrl = $ref(null);
|
||||||
|
|
||||||
const usersPagination = {
|
const usersPagination = {
|
||||||
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
|
endpoint: iAmModerator ? 'admin/show-users' : 'users' as const,
|
||||||
@ -189,6 +191,7 @@ async function fetch() {
|
|||||||
});
|
});
|
||||||
suspended = instance.isSuspended;
|
suspended = instance.isSuspended;
|
||||||
isBlocked = instance.isBlocked;
|
isBlocked = instance.isBlocked;
|
||||||
|
faviconUrl = getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.iconUrl, 'preview');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleBlock(ev) {
|
async function toggleBlock(ev) {
|
||||||
|
@ -70,6 +70,8 @@ async function accept(): Promise<void> {
|
|||||||
|
|
||||||
state = 'accepted';
|
state = 'accepted';
|
||||||
if (props.callback) {
|
if (props.callback) {
|
||||||
|
const cbUrl = new URL(props.callback);
|
||||||
|
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url');
|
||||||
location.href = appendQuery(props.callback, query({
|
location.href = appendQuery(props.callback, query({
|
||||||
session: props.session,
|
session: props.session,
|
||||||
}));
|
}));
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="follow-container">
|
<div class="follow-container">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<MkFollowButton v-if="$i != null && $i.id != user.id" :user="user" :inline="true" :transparent="false" :full="!narrow" class="koudoku"/>
|
<MkFollowButton v-if="$i != null && $i.id != user.id" :user="user" @refresh="emit('refresh')" :inline="true" :transparent="false" :full="!narrow" class="koudoku"/>
|
||||||
<button class="menu _button" @click="menu"><i class="ph-dots-three-outline-bold ph-lg"></i></button>
|
<button class="menu _button" @click="menu"><i class="ph-dots-three-outline-bold ph-lg"></i></button>
|
||||||
<!-- <MkFollowButton v-else-if="$i == null" :user="user" :remote="true" :inline="true" :transparent="false" :full="true" class="koudoku"/> -->
|
<!-- <MkFollowButton v-else-if="$i == null" :user="user" :remote="true" :inline="true" :transparent="false" :full="true" class="koudoku"/> -->
|
||||||
</div>
|
</div>
|
||||||
@ -142,6 +142,7 @@ import { host } from '@/config';
|
|||||||
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
|
const XPhotos = defineAsyncComponent(() => import('./index.photos.vue'));
|
||||||
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
|
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh']);
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
user: misskey.entities.UserDetailed;
|
user: misskey.entities.UserDetailed;
|
||||||
}>(), {
|
}>(), {
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
<div v-if="user">
|
<div v-if="user">
|
||||||
<XHome v-if="tab === 'home'" :user="user"/>
|
<XHome v-if="tab === 'home'" :user="user" @refresh="fetchUser()"/>
|
||||||
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
<XReactions v-else-if="tab === 'reactions'" :user="user"/>
|
||||||
<XClips v-else-if="tab === 'clips'" :user="user"/>
|
<XClips v-else-if="tab === 'clips'" :user="user"/>
|
||||||
<XPages v-else-if="tab === 'pages'" :user="user"/>
|
<XPages v-else-if="tab === 'pages'" :user="user"/>
|
||||||
|
@ -282,6 +282,10 @@ export const routes = [
|
|||||||
path: "/about-calckey",
|
path: "/about-calckey",
|
||||||
component: page(() => import("./pages/about-calckey.vue")),
|
component: page(() => import("./pages/about-calckey.vue")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/apps",
|
||||||
|
component: page(() => import("./pages/apps.vue")),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/theme-editor",
|
path: "/theme-editor",
|
||||||
component: page(() => import("./pages/theme-editor.vue")),
|
component: page(() => import("./pages/theme-editor.vue")),
|
||||||
|
@ -24,7 +24,11 @@ export function createAiScriptEnv(opts) {
|
|||||||
return confirm.canceled ? values.FALSE : values.TRUE;
|
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||||
}),
|
}),
|
||||||
"Mk:api": values.FN_NATIVE(async ([ep, param, token]) => {
|
"Mk:api": values.FN_NATIVE(async ([ep, param, token]) => {
|
||||||
if (token) utils.assertString(token);
|
if (token) {
|
||||||
|
utils.assertString(token);
|
||||||
|
// バグがあればundefinedもあり得るため念のため
|
||||||
|
if (typeof token.value !== "string") throw new Error("invalid token");
|
||||||
|
}
|
||||||
apiRequests++;
|
apiRequests++;
|
||||||
if (apiRequests > 16) return values.NULL;
|
if (apiRequests > 16) return values.NULL;
|
||||||
const res = await os.api(
|
const res = await os.api(
|
||||||
|
@ -125,11 +125,23 @@ export function getUserMenu(user, router: Router = mainRouter) {
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
os.apiWithDialog(user.isBlocking ? "blocking/delete" : "blocking/create", {
|
await os.apiWithDialog(
|
||||||
|
user.isBlocking ? "blocking/delete" : "blocking/create",
|
||||||
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
}).then(() => {
|
},
|
||||||
|
);
|
||||||
user.isBlocking = !user.isBlocking;
|
user.isBlocking = !user.isBlocking;
|
||||||
|
await os.api(user.isBlocking ? "mute/create" : "mute/delete", {
|
||||||
|
userId: user.id,
|
||||||
});
|
});
|
||||||
|
user.isMuted = user.isBlocking;
|
||||||
|
if (user.isBlocking) {
|
||||||
|
await os.api("following/delete", {
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
user.isFollowing = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSilence() {
|
async function toggleSilence() {
|
||||||
@ -252,6 +264,7 @@ export function getUserMenu(user, router: Router = mainRouter) {
|
|||||||
{
|
{
|
||||||
icon: user.isMuted ? "ph-eye-bold ph-lg" : "ph-eye-slash-bold ph-lg",
|
icon: user.isMuted ? "ph-eye-bold ph-lg" : "ph-eye-slash-bold ph-lg",
|
||||||
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
|
text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute,
|
||||||
|
hidden: user.isBlocking === true,
|
||||||
action: toggleMute,
|
action: toggleMute,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -28,6 +28,7 @@ export type MenuUser = {
|
|||||||
user: Misskey.entities.User;
|
user: Misskey.entities.User;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
indicate?: boolean;
|
indicate?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
action: MenuAction;
|
action: MenuAction;
|
||||||
};
|
};
|
||||||
export type MenuSwitch = {
|
export type MenuSwitch = {
|
||||||
@ -43,6 +44,7 @@ export type MenuButton = {
|
|||||||
indicate?: boolean;
|
indicate?: boolean;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
avatar?: Misskey.entities.User;
|
avatar?: Misskey.entities.User;
|
||||||
action: MenuAction;
|
action: MenuAction;
|
||||||
};
|
};
|
||||||
|
@ -34,9 +34,6 @@
|
|||||||
<button class="item _button post" data-cy-open-post-form @click="os.post">
|
<button class="item _button post" data-cy-open-post-form @click="os.post">
|
||||||
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
|
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
|
|
||||||
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -44,12 +41,10 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
|
import { computed, defineAsyncComponent, defineComponent, ref, toRef, watch } from 'vue';
|
||||||
import { host } from '@/config';
|
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { navbarItemDef } from '@/navbar';
|
import { navbarItemDef } from '@/navbar';
|
||||||
import { openAccountMenu as openAccountMenu_ } from '@/account';
|
import { openAccountMenu as openAccountMenu_ } from '@/account';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { instance } from '@/instance';
|
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
const menu = toRef(defaultStore.state, 'menu');
|
const menu = toRef(defaultStore.state, 'menu');
|
||||||
@ -67,50 +62,6 @@ function openAccountMenu(ev: MouseEvent) {
|
|||||||
}, ev);
|
}, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInstanceMenu(ev: MouseEvent) {
|
|
||||||
os.popupMenu([{
|
|
||||||
text: instance.name ?? host,
|
|
||||||
type: 'label',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
text: i18n.ts.instanceInfo,
|
|
||||||
icon: 'ph-info-bold ph-lg',
|
|
||||||
to: '/about',
|
|
||||||
}, null, {
|
|
||||||
type: 'parent',
|
|
||||||
text: i18n.ts.help,
|
|
||||||
icon: 'ph-question-bold ph-lg',
|
|
||||||
children: [{
|
|
||||||
type: 'link',
|
|
||||||
to: '/mfm-cheat-sheet',
|
|
||||||
text: i18n.ts._mfm.cheatSheet,
|
|
||||||
icon: 'ph-code-bold ph-lg',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
to: '/scratchpad',
|
|
||||||
text: i18n.ts.scratchpad,
|
|
||||||
icon: 'ph-terminal-window-bold ph-lg',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
to: '/api-console',
|
|
||||||
text: 'API Console',
|
|
||||||
icon: 'ph-terminal-window-bold ph-lg',
|
|
||||||
}, null, {
|
|
||||||
text: i18n.ts.document,
|
|
||||||
icon: 'ph-question-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
window.open('/api-doc', '_blank');
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
text: i18n.ts.aboutMisskey,
|
|
||||||
to: '/about-calckey',
|
|
||||||
}], ev.currentTarget ?? ev.target, {
|
|
||||||
align: 'left',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function more() {
|
function more() {
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
|
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {}, {
|
||||||
}, 'closed');
|
}, 'closed');
|
||||||
|
@ -44,9 +44,14 @@
|
|||||||
<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
|
<button v-tooltip.noDelay.right="i18n.ts.note" class="item _button post" data-cy-open-post-form @click="os.post">
|
||||||
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
|
<i class="icon ph-pencil-bold ph-lg ph-fw ph-lg"></i><span class="text">{{ i18n.ts.note }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
|
<!-- <div class="help">
|
||||||
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
|
<button v-tooltip.noDelay.right="i18n.ts.help" class="item _button" @click="openHelpMenu">
|
||||||
|
<i class="icon ph-info-bold ph-xl ph-fw ph-lg"></i>
|
||||||
</button>
|
</button>
|
||||||
|
</div> -->
|
||||||
|
<!-- <button v-click-anime v-tooltip.noDelay.right="$instance.name ?? i18n.ts.instance" class="item _button instance" @click="openInstanceMenu">
|
||||||
|
<img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/>
|
||||||
|
</button> -->
|
||||||
<!-- <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
|
<!-- <button v-click-anime v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="item _button account" @click="openAccountMenu">
|
||||||
<MkAvatar :user="$i" class="account"/><MkAcct class="text" :user="$i"/>
|
<MkAvatar :user="$i" class="account"/><MkAcct class="text" :user="$i"/>
|
||||||
</button> -->
|
</button> -->
|
||||||
@ -57,14 +62,13 @@
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import XTutorial from '@/components/MkTutorialDialog.vue';
|
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { navbarItemDef } from '@/navbar';
|
import { navbarItemDef } from '@/navbar';
|
||||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
import { $i, openAccountMenu as openAccountMenu_ } from '@/account';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
import { instance } from '@/instance';
|
import { instance } from '@/instance';
|
||||||
import { host, version } from '@/config';
|
import { version } from '@/config';
|
||||||
|
|
||||||
const isEmpty = (x: string | null) => x == null || x === '';
|
const isEmpty = (x: string | null) => x == null || x === '';
|
||||||
|
|
||||||
@ -122,58 +126,6 @@ function openAccountMenu(ev: MouseEvent) {
|
|||||||
}, ev);
|
}, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInstanceMenu(ev: MouseEvent) {
|
|
||||||
os.popupMenu([{
|
|
||||||
text: instance.name ?? host,
|
|
||||||
type: 'label',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
text: i18n.ts.instanceInfo,
|
|
||||||
icon: 'ph-info-bold ph-lg',
|
|
||||||
to: '/about',
|
|
||||||
}, null, {
|
|
||||||
type: 'parent',
|
|
||||||
text: i18n.ts.help,
|
|
||||||
icon: 'ph-question-bold ph-lg',
|
|
||||||
children: [{
|
|
||||||
type: 'link',
|
|
||||||
to: '/mfm-cheat-sheet',
|
|
||||||
text: i18n.ts._mfm.cheatSheet,
|
|
||||||
icon: 'ph-code-bold ph-lg',
|
|
||||||
}, {
|
|
||||||
type: 'button',
|
|
||||||
action: async () => {
|
|
||||||
defaultStore.set('tutorial', 0);
|
|
||||||
os.popup(XTutorial, {}, {}, 'closed');
|
|
||||||
},
|
|
||||||
text: i18n.ts.replayTutorial,
|
|
||||||
icon: 'ph-circle-wavy-question-bold ph-lg',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
to: '/scratchpad',
|
|
||||||
text: i18n.ts.scratchpad,
|
|
||||||
icon: 'ph-terminal-window-bold ph-lg',
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
to: '/api-console',
|
|
||||||
text: 'API Console',
|
|
||||||
icon: 'ph-terminal-window-bold ph-lg',
|
|
||||||
}, null, {
|
|
||||||
text: i18n.ts.document,
|
|
||||||
icon: 'ph-question-bold ph-lg',
|
|
||||||
action: () => {
|
|
||||||
window.open('https://misskey-hub.net/help.html', '_blank');
|
|
||||||
},
|
|
||||||
}],
|
|
||||||
}, {
|
|
||||||
type: 'link',
|
|
||||||
text: i18n.ts.aboutMisskey,
|
|
||||||
to: '/about-calckey',
|
|
||||||
}], ev.currentTarget ?? ev.target, {
|
|
||||||
align: 'left',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function more(ev: MouseEvent) {
|
function more(ev: MouseEvent) {
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
|
os.popup(defineAsyncComponent(() => import('@/components/MkLaunchPad.vue')), {
|
||||||
src: ev.currentTarget ?? ev.target,
|
src: ev.currentTarget ?? ev.target,
|
||||||
@ -296,7 +248,6 @@ function more(ev: MouseEvent) {
|
|||||||
> .text {
|
> .text {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> .instance {
|
> .instance {
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<transition name="change" mode="default">
|
<transition name="change" mode="default">
|
||||||
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
|
<MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse">
|
||||||
<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
|
<span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }">
|
||||||
<img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/>
|
<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
|
||||||
<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
|
<MkA :to="`/instance-info/${instance.host}`" class="host _monospace">
|
||||||
{{ instance.host }}
|
{{ instance.host }}
|
||||||
</MkA>
|
</MkA>
|
||||||
@ -27,6 +27,7 @@ import * as os from '@/os';
|
|||||||
import { useInterval } from '@/scripts/use-interval';
|
import { useInterval } from '@/scripts/use-interval';
|
||||||
import { getNoteSummary } from '@/scripts/get-note-summary';
|
import { getNoteSummary } from '@/scripts/get-note-summary';
|
||||||
import { notePage } from '@/filters/note';
|
import { notePage } from '@/filters/note';
|
||||||
|
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
display?: 'marquee' | 'oneByOne';
|
display?: 'marquee' | 'oneByOne';
|
||||||
@ -56,6 +57,10 @@ useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), {
|
|||||||
immediate: true,
|
immediate: true,
|
||||||
afterMounted: true,
|
afterMounted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getInstanceIcon(instance): string {
|
||||||
|
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<MkLoading v-if="fetching"/>
|
<MkLoading v-if="fetching"/>
|
||||||
<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
|
<transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances">
|
||||||
<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
|
<div v-for="(instance, i) in instances" :key="instance.id" class="instance">
|
||||||
<img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/>
|
<img :src="getInstanceIcon(instance)" alt=""/>
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
|
<a class="a" :href="'https://' + instance.host" target="_blank" :title="instance.host">{{ instance.host }}</a>
|
||||||
<p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
|
<p>{{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</p>
|
||||||
@ -27,6 +27,7 @@ import MkMiniChart from '@/components/MkMiniChart.vue';
|
|||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { useInterval } from '@/scripts/use-interval';
|
import { useInterval } from '@/scripts/use-interval';
|
||||||
import { i18n } from '@/i18n';
|
import { i18n } from '@/i18n';
|
||||||
|
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||||
|
|
||||||
const name = 'federation';
|
const name = 'federation';
|
||||||
|
|
||||||
@ -71,6 +72,10 @@ useInterval(fetch, 1000 * 60, {
|
|||||||
afterMounted: true,
|
afterMounted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getInstanceIcon(instance): string {
|
||||||
|
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose<WidgetComponentExpose>({
|
defineExpose<WidgetComponentExpose>({
|
||||||
name,
|
name,
|
||||||
configure,
|
configure,
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
<MkTagCloud v-if="activeInstances">
|
<MkTagCloud v-if="activeInstances">
|
||||||
<li v-for="instance in activeInstances" :key="instance.id">
|
<li v-for="instance in activeInstances" :key="instance.id">
|
||||||
<a @click.prevent="onInstanceClick(instance)">
|
<a @click.prevent="onInstanceClick(instance)">
|
||||||
<img style="width: 32px;" :src="instance.iconUrl">
|
<img style="width: 32px;" :src="getInstanceIcon(instance)">
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</MkTagCloud>
|
</MkTagCloud>
|
||||||
@ -21,6 +21,7 @@ import MkContainer from '@/components/MkContainer.vue';
|
|||||||
import MkTagCloud from '@/components/MkTagCloud.vue';
|
import MkTagCloud from '@/components/MkTagCloud.vue';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { useInterval } from '@/scripts/use-interval';
|
import { useInterval } from '@/scripts/use-interval';
|
||||||
|
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||||
|
|
||||||
const name = 'instanceCloud';
|
const name = 'instanceCloud';
|
||||||
|
|
||||||
@ -65,6 +66,10 @@ useInterval(() => {
|
|||||||
afterMounted: true,
|
afterMounted: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getInstanceIcon(instance): string {
|
||||||
|
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose<WidgetComponentExpose>({
|
defineExpose<WidgetComponentExpose>({
|
||||||
name,
|
name,
|
||||||
configure,
|
configure,
|
||||||
|
@ -46,10 +46,10 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
|
|
||||||
build: {
|
build: {
|
||||||
target: [
|
target: [
|
||||||
'chrome108',
|
'chrome87',
|
||||||
'firefox109',
|
'firefox78',
|
||||||
'safari16',
|
'safari14',
|
||||||
'es2022',
|
'es2017',
|
||||||
],
|
],
|
||||||
manifest: 'manifest.json',
|
manifest: 'manifest.json',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
|
@ -7,15 +7,14 @@
|
|||||||
"lint": "pnpm rome check \"src/**/*.ts\""
|
"lint": "pnpm rome check \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/cli": "^0.1.59",
|
|
||||||
"@swc/core": "^1.3.26",
|
|
||||||
"calckey-js": "^0.0.20",
|
|
||||||
"idb-keyval": "^6.2.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@swc/core-android-arm64": "1.3.11"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@swc/cli": "^0.1.59",
|
||||||
|
"@swc/core": "^1.3.26",
|
||||||
|
"@swc/core-android-arm64": "1.3.11",
|
||||||
|
"calckey-js": "^0.0.20",
|
||||||
|
"idb-keyval": "^6.2.0",
|
||||||
"swc-loader": "^0.2.3",
|
"swc-loader": "^0.2.3",
|
||||||
"webpack": "^5.75.0"
|
"webpack": "^5.75.0"
|
||||||
}
|
}
|
||||||
|
1561
pnpm-lock.yaml
1561
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "13.1.0",
|
"version": "13.1.3-rc",
|
||||||
"notes": "This release includes many changes, including:\n\n• New post and thread layout\n• Automatic subdomain blocks\n• Customizable default reactions\n• Federation improvements\n• Many bug fixes and performance improvements",
|
"notes": "This release candidate has the following changes:\n• Better blocking/muting\n• Better user refreshing\n• New help menu with app list (More! > Help)\n• Bug fixes and performance improvements",
|
||||||
"screenshots": []
|
"screenshots": []
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user