Merge pull request 'develop' (#9178) from develop into account_migration

Reviewed-on: https://codeberg.org/thatonecalculator/calckey/pulls/9178
This commit is contained in:
Kainoa Kanter 2022-12-04 06:26:26 +00:00
commit 29c1b81f12
77 changed files with 21821 additions and 505 deletions

View file

@ -1,4 +1,4 @@
# db settings
POSTGRES_PASSWORD=example-misskey-pass
POSTGRES_USER=example-misskey-user
POSTGRES_DB=misskey
POSTGRES_PASSWORD=example-calckey-pass
POSTGRES_USER=example-calckey-user
POSTGRES_DB=calckey

View file

@ -9,7 +9,6 @@
- User "choices" (recommended users) like Mastodon and Soapbox
- Option to publicize instance blocks
- Fully revamp non-logged-in screen
- Remote follow button
- Personal notes for all accounts
- Non-nyaify cat mode
- Timeline filters
@ -21,8 +20,8 @@
## Work in progress
- Better Messaging UI
- Videos can be played in DMs
- Make your password hasn't been pwned
- Better API Documentation
- Remote follow button
- Admin custom CSS
- Add back time machine (jump to date)
- Improve accesibility score
@ -86,7 +85,12 @@
- Link hover effect
- Replace all `$ts` with i18n
- AVIF support
- Page drafts
- Patron list
- Animations respect reduced motion
- Obliteration of Ai-chan
- Undo renote button inside original note
- MissV: [fix Misskey Forkbomb](https://code.vtopia.live/Vtopia/MissV/commit/40b23c070bd4adbb3188c73546c6c625138fb3c1)
- [Make showing ads optional](https://github.com/misskey-dev/misskey/pull/8996)
- [Tapping avatar in mobile opens account modal](https://github.com/misskey-dev/misskey/pull/9056)
- [OAuth bearer token authentication](https://github.com/misskey-dev/misskey/pull/9021)

View file

@ -1,9 +1,9 @@
<div align="center">
<a href="https://stop.voring.me/">
<a href="https://i.calckey.cloud/">
<img src="./.github/title_float.svg" alt="Calckey logo" style="border-radius:50%" width="400"/>
</a>
**🌎 **[Calckey](https://stop.voring.me/)** is an open source, decentralized social media platform that's free forever! 🚀**
**🌎 **[Calckey](https://i.calckey.cloud/)** is an open source, decentralized social media platform that's free forever! 🚀**
</div>
@ -20,6 +20,7 @@
- Improved UI/UX (especially on mobile)
- Improved notifications
- Improved instance security
- Improved accessibility
- Recommended Instances timeline
- OCR image captioning
- New and improved Groups
@ -34,6 +35,9 @@
# 🥂 Links
- 💸 Liberapay: <https://liberapay.com/ThatOneCalculator>
- Donate publicly to get your name on the Patron list!
- 🚢 Flagship instance: <https://i.calckey.cloud>
- 📣 Official account: <https://i.calckey.cloud/@calckey>
- 💁 Matrix support room: <https://matrix.to/#/#calckey:matrix.fedibird.com>
- 📜 Instance list: <https://calckey.fediverse.observer/list>
- 📖 JoinFediverse Wiki: <https://joinfediverse.wiki/What_is_Calckey%3F>
@ -45,15 +49,27 @@ This guide will work for both **starting from scratch** and **migrating from Mis
## 📦 Dependencies
- At least 🐢 [NodeJS](https://nodejs.org/en/) v16.15.0 (v18.12.1 recommended)
> ⚠️ NodeJS v19 is not supported as of right now because of [this issue](https://github.com/nodejs/node-gyp/issues/2757).
- 🐢 At least [NodeJS](https://nodejs.org/en/) v18.12.1 (v19.1.0 recommended)
- Install with [nvm](https://github.com/nvm-sh/nvm)
- 🐘 At least [PostgreSQL](https://www.postgresql.org/) v12
- 🍱 At least [Redis](https://redis.io/) v6 (v7 recommended)
- 🛰️ (Optional, for non-Docker) [pm2](https://pm2.io/)
### 😗 Optional dependencies
- 📗 [FFmpeg](https://ffmpeg.org/) for video transcoding
- 🔍 [ElasticSearch](https://www.elastic.co/elasticsearch/) for full-text search
- OpenSearch/Sonic are not supported as of right now
- 🥡 Management (choose one of the following)
- 🛰️ [pm2](https://pm2.io/)
- 🐳 [Docker](https://docker.com)
- 📐 Service manager (systemd, openrc, etc)
### 🏗️ Build dependencies
- 🦬 C/C++ compiler & build tools
- `build-essential` on Debian/Ubuntu Linux
- `base-devel` on Arch Linux
- 🐍 [Python 3](https://www.python.org/)
## 👀 Get folder ready
@ -70,10 +86,19 @@ cd calckey/
corepack enable
```
## 🐘 Create database
Assuming you set up PostgreSQL correctly, all you have to run is:
```sh
psql postgres -c "create database calckey with encoding = 'UTF8';"
```
## 💅 Customize
- To add custom CSS for all users, edit `./custom/instance.css`.
- To add static assets (such as images for the splash screen), place them in the `./custom/` directory. They'll then be avaliable on `https://yourinstance.tld/static-assets/filename.ext`.
- To add custom CSS for all users, edit `./custom/assets/instance.css`.
- To add static assets (such as images for the splash screen), place them in the `./custom/assets/` directory. They'll then be avaliable on `https://yourinstance.tld/static-assets/filename.ext`.
- To add custom locales, place them in the `./custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: `en-FOO.yml`)
- To update custom assets without rebuilding, just run `yarn run gulp`.
## 🧑‍🔬 Configuring a new instance
@ -93,7 +118,7 @@ cp -r ../misskey/files . # if you don't use object storage
## 🍀 NGINX
- Run `sudo cp ./calckey.nginx.conf /etc/nginx/sites-avaliable/ && cd /etc/nginx/sites-avaliable/`
- Run `sudo cp ./calckey.nginx.conf /etc/nginx/sites-available/ && cd /etc/nginx/sites-available/`
- Edit `calckey.nginx.conf` to reflect your instance properly
- Run `sudo cp ./calckey.nginx.conf ../sites-enabled/`
- Run `sudo nginx -t` to validate that the config is valid, then restart the NGINX service.
@ -102,7 +127,7 @@ cp -r ../misskey/files . # if you don't use object storage
## 🚀 Build and launch!
### 🐢 NodeJS
### 🐢 NodeJS + pm2
#### `git pull` and run these steps to update Calckey in the future!
@ -123,15 +148,16 @@ docker up -d
### 🐳 Docker Compose
```sh
docker compose build
docker-compose build
docker-compose run --rm web yarn run init
docker compose up -d
docker-compose up -d
```
## 😉 Tips & Tricks
- When editing the config file, please don't fill out the settings at the bottom. They're designed *only* for managed hosting, not self hosting. Those settings are much better off being set in Calckey's control panel.
- Port 3000 (used in the default config) might be already used on your server for something else. To find an open port for Calckey, run `for p in $(seq 3000 4000); do ss -tlnH | tr -s ' ' | cut -d" " -sf4 | grep -q "${p}$" || echo "${p}"; done | head -n 1`
- I'd recommend you use a S3 Bucket/CDN for Object Storage, especially if you use Docker.
- I'd ***strongly*** recommend against using CloudFlare, but if you do, make sure to turn code minification off.
- For push notifications, run `npx web-push generate-vapid-keys`, the put the public and private keys into Control Panel > General > ServiceWorker.
- For translations, make a [DeepL](https://deepl.com) account and generate an API key, then put it into Control Panel > General > DeepL Translation.

0
custom/locales/.gitkeep Normal file
View file

View file

@ -16,7 +16,7 @@ gulp.task('copy:backend:views', () =>
);
gulp.task('copy:backend:custom', () =>
gulp.src('./custom/*').pipe(gulp.dest('./packages/backend/assets/'))
gulp.src('./custom/assets/*').pipe(gulp.dest('./packages/backend/assets/'))
);
gulp.task('copy:client:fonts', () =>
@ -24,7 +24,7 @@ gulp.task('copy:client:fonts', () =>
);
gulp.task('copy:client:phosphor', () =>
gulp.src('./node_modules/phosphor-icons/src/css/*').pipe(gulp.dest('./built/_client_dist_/phosphor/'))
gulp.src('./node_modules/phosphor-icons/src/fonts/*').pipe(gulp.dest('./built/_client_dist_/phosphor/'))
);
gulp.task('copy:client:locales', cb => {

View file

@ -32,12 +32,12 @@ uploading: "Uploading..."
save: "Save"
users: "Users"
addUser: "Add a user"
favorite: "Add to favorites"
favorites: "Favorites"
unfavorite: "Remove from favorites"
favorited: "Added to favorites."
alreadyFavorited: "Already added to favorites."
cantFavorite: "Couldn't add to favorites."
favorite: "Add to bookmarks"
favorites: "Bookmarks"
unfavorite: "Remove from bookmarks"
favorited: "Added to bookmarks."
alreadyFavorited: "Already added to bookmarks."
cantFavorite: "Couldn't add to bookmarks."
pin: "Pin to profile"
unpin: "Unpin from profile"
copyContent: "Copy contents"
@ -160,7 +160,7 @@ proxyAccount: "Proxy account"
proxyAccountDescription: "A proxy account is an account that acts as a remote follower for users under certain conditions. For example, when a user adds a remote user to the list, the remote user's activity will not be delivered to the instance if no local user is following that user, so the proxy account will follow instead."
host: "Host"
selectUser: "Select a user"
recipient: "Recipient"
recipient: "Recipient(s)"
annotation: "Comments"
federation: "Federation"
instances: "Instances"
@ -680,7 +680,7 @@ disableShowingAnimatedImages: "Don't play animated images"
verificationEmailSent: "A verification email has been sent. Please follow the included link to complete verification."
notSet: "Not set"
emailVerified: "Email has been verified"
noteFavoritesCount: "Number of favorite notes"
noteFavoritesCount: "Number of bookmarked notes"
pageLikesCount: "Number of liked Pages"
pageLikedCount: "Number of received Page likes"
contact: "Contact"
@ -771,8 +771,8 @@ noBotProtectionWarning: "Bot protection is not configured."
configure: "Configure"
postToGallery: "Create new gallery post"
gallery: "Gallery"
recentPosts: "Recent posts"
popularPosts: "Popular posts"
recentPosts: "Recent pages"
popularPosts: "Popular pages"
shareWithNote: "Share with note"
ads: "Advertisements"
expiration: "Deadline"
@ -1002,9 +1002,9 @@ _aboutMisskey:
allContributors: "All contributors"
source: "Source code"
translation: "Translate Misskey"
donate: "Donate to Misskey"
donate: "Donate to Calckey"
morePatrons: "We also appreciate the support of many other helpers not listed here. Thank you! 🥰"
patrons: "Misskey patrons"
patrons: "Calckey patrons"
_nsfw:
respect: "Hide NSFW media"
ignore: "Don't hide NSFW media"
@ -1095,7 +1095,7 @@ _channel:
usersCount: "{n} Participants"
notesCount: "{n} Notes"
_messaging:
dms: "DMs"
dms: "Private"
groups: "Groups"
_menuDisplay:
sideFull: "Side"
@ -1228,7 +1228,7 @@ _tutorial:
step5_3: "The Home {icon} timeline is where you can see posts from your followers."
step5_4: "The Local {icon} timeline is where you can see posts from everyone else on this instance."
step5_5: "The Recommended {icon} timeline is where you can see posts from instances the admins recommend."
step5_6: "The Social {icon} timeline is where you can see posts from friends of your followers."
step5_6: "The Social {icon} timeline is your home + local."
step5_7: "The Global {icon} timeline is where you can see posts from every other connected instance."
step6_1: "So, what is this place?"
step6_2: "Well, you didn't just join Calckey. You joined a portal to the Fediverse, an interconnected network of thousands of servers, called \"instances\"."
@ -1251,8 +1251,8 @@ _permissions:
"write:blocks": "Edit your list of blocked users"
"read:drive": "Access your Drive files and folders"
"write:drive": "Edit or delete your Drive files and folders"
"read:favorites": "View your list of favorites"
"write:favorites": "Edit your list of favorites"
"read:favorites": "View your list of bookmarks"
"write:favorites": "Edit your list of bookmarks"
"read:following": "View information on who you follow"
"write:following": "Follow or unfollow other accounts"
"read:messaging": "View your chats"
@ -1265,10 +1265,10 @@ _permissions:
"read:reactions": "View your reactions"
"write:reactions": "Edit your reactions"
"write:votes": "Vote on a poll"
"read:pages": "View your pages"
"write:pages": "Edit or delete your pages"
"read:page-likes": "View your likes on pages"
"write:page-likes": "Edit your likes on pages"
"read:pages": "View your page"
"write:pages": "Edit or delete your page"
"read:page-likes": "View your likes on page"
"write:page-likes": "Edit your likes on page"
"read:user-groups": "View your user groups"
"write:user-groups": "Edit or delete your user groups"
"read:channels": "View your channels"
@ -1442,7 +1442,7 @@ _pages:
liked: "Liked Pages"
featured: "Popular"
inspector: "Inspector"
contents: "Contents"
contents: "Content"
content: "Page block"
variables: "Variables"
title: "Title"

View file

@ -4,6 +4,8 @@
const fs = require('fs');
const yaml = require('js-yaml');
let languages = []
let languages_custom = []
const merge = (...args) => args.reduce((a, c) => ({
...a,
@ -13,33 +15,20 @@ const merge = (...args) => args.reduce((a, c) => ({
.reduce((a, [k, v]) => (a[k] = merge(v, c[k]), a), {})
}), {});
const languages = [
'ar-SA',
'cs-CZ',
'da-DK',
'de-DE',
'en-US',
'es-ES',
'fr-FR',
'id-ID',
'it-IT',
'ja-JP',
'ja-KS',
'kab-KAB',
'kn-IN',
'ko-KR',
'nl-NL',
'no-NO',
'pl-PL',
'pt-PT',
'ru-RU',
'sk-SK',
'ug-CN',
'uk-UA',
'vi-VN',
'zh-CN',
'zh-TW',
];
fs.readdirSync(__dirname).forEach((file) => {
if (file.includes('.yml')){
file = file.slice(0, file.indexOf('.'))
languages.push(file);
}
})
fs.readdirSync(__dirname + '/../custom/locales').forEach((file) => {
if (file.includes('.yml')){
file = file.slice(0, file.indexOf('.'))
languages_custom.push(file);
}
})
const primaries = {
'en': 'US',
@ -51,6 +40,8 @@ const primaries = {
const clean = (text) => text.replace(new RegExp(String.fromCodePoint(0x08), 'g'), '');
const locales = languages.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/${c}.yml`, 'utf-8'))) || {}, a), {});
const locales_custom = languages_custom.reduce((a, c) => (a[c] = yaml.load(clean(fs.readFileSync(`${__dirname}/../custom/locales/${c}.yml`, 'utf-8'))) || {}, a), {});
Object.assign(locales, locales_custom)
module.exports = Object.entries(locales)
.reduce((a, [k ,v]) => (a[k] = (() => {

View file

@ -1,12 +1,12 @@
{
"name": "calckey",
"version": "12.119.0-calc.14.6",
"version": "12.119.0-calc.18",
"codename": "aqua",
"repository": {
"type": "git",
"url": "https://codeberg.org/thatonecalculator/calckey.git"
},
"packageManager": "yarn@3.2.4",
"packageManager": "yarn@3.3.0",
"workspaces": [
"packages/client",
"packages/backend",
@ -42,7 +42,7 @@
"@bull-board/api": "^4.6.4",
"@bull-board/ui": "^4.6.4",
"@tensorflow/tfjs": "^3.21.0",
"eslint": "^8.27.0",
"eslint": "^8.28.0",
"execa": "5.1.1",
"gulp": "4.0.2",
"gulp-cssnano": "2.1.3",

View file

@ -0,0 +1,8 @@
export class Page1668828368510 {
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "page" ADD "isPublic" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "isPublic"`);
}
}

View file

@ -0,0 +1,11 @@
export class FixCalckeyAgain1668831378728 {
name = 'FixCalckeyAgain1668831378728'
async up(queryRunner) {
await queryRunner.query(`UPDATE "meta" SET "useStarForReactionFallback" = TRUE`);
}
async down(queryRunner) {
await queryRunner.query(`UPDATE "meta" SET "useStarForReactionFallback" = FALSE`);
}
}

View file

@ -4,8 +4,8 @@
"private": true,
"type": "module",
"scripts": {
"start": "node --experimental-json-modules ./built/index.js",
"start:test": "NODE_ENV=test node --experimental-json-modules ./built/index.js",
"start": "node ./built/index.js",
"start:test": "NODE_ENV=test node ./built/index.js",
"migrate": "typeorm migration:run -d ormconfig.js",
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
"watch": "node watch.mjs",
@ -36,11 +36,11 @@
"archiver": "5.3.1",
"autobind-decorator": "2.4.0",
"autwh": "0.1.0",
"aws-sdk": "2.1255.0",
"aws-sdk": "2.1258.0",
"bcryptjs": "2.4.3",
"blurhash": "1.1.5",
"bull": "4.10.1",
"cacheable-lookup": "6.1.0",
"cacheable-lookup": "7.0.0",
"cbor": "8.1.0",
"chalk": "5.1.2",
"chalk-template": "0.4.0",
@ -83,7 +83,7 @@
"node-fetch": "3.3.0",
"nodemailer": "6.8.0",
"nsfwjs": "2.4.2",
"oauth": "^0.9.15",
"oauth": "^0.10.0",
"os-utils": "0.0.14",
"parse5": "7.1.1",
"pg": "8.8.0",
@ -111,7 +111,7 @@
"stringz": "2.1.0",
"summaly": "2.7.0",
"syslog-pro": "1.0.0",
"systeminformation": "5.12.14",
"systeminformation": "5.13.5",
"tesseract.js": "^3.0.3",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
@ -130,7 +130,7 @@
"xev": "3.0.2"
},
"devDependencies": {
"@redocly/openapi-core": "1.0.0-beta.112",
"@redocly/openapi-core": "1.0.0-beta.114",
"@types/bcryptjs": "2.4.2",
"@types/bull": "3.15.9",
"@types/cbor": "6.0.0",
@ -165,7 +165,7 @@
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.6.2",
"@types/semver": "7.3.13",
"@types/sharp": "0.30.5",
"@types/sharp": "0.31.0",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/speakeasy": "2.0.7",
"@types/tinycolor2": "1.4.3",
@ -177,7 +177,7 @@
"@typescript-eslint/eslint-plugin": "5.43.0",
"@typescript-eslint/parser": "5.43.0",
"cross-env": "7.0.3",
"eslint": "8.27.0",
"eslint": "8.28.0",
"eslint-plugin-import": "2.26.0",
"execa": "6.1.0",
"typescript": "4.9.3"

View file

@ -0,0 +1,18 @@
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = deepClone(v);
}
return obj as T;
} else {
return x;
}
}

View file

@ -40,6 +40,9 @@ export class Page {
@Column('boolean')
public alignCenter: boolean;
@Column('boolean')
public isPublic: boolean;
@Column('boolean', {
default: false,
})

View file

@ -9,6 +9,8 @@ import { query, appendQuery } from '@/prelude/url.js';
import { Meta } from '@/models/entities/meta.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Users, DriveFolders } from '../index.js';
import { deepClone } from '@/misc/clone.js';
type PackOptions = {
detail?: boolean,
@ -29,9 +31,7 @@ export const DriveFileRepository = db.getRepository(DriveFile).extend({
getPublicProperties(file: DriveFile): DriveFile['properties'] {
if (file.properties.orientation != null) {
// TODO
//const properties = structuredClone(file.properties);
const properties = JSON.parse(JSON.stringify(file.properties));
const properties = deepClone(file.properties);
if (file.properties.orientation >= 5) {
[properties.width, properties.height] = [properties.height, properties.width];
}

View file

@ -1,14 +1,14 @@
import { In, Repository } from 'typeorm';
import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js';
import { Notification } from '@/models/entities/notification.js';
import { awaitAll } from '@/prelude/await-all.js';
import { Packed } from '@/misc/schema.js';
import { Note } from '@/models/entities/note.js';
import { NoteReaction } from '@/models/entities/note-reaction.js';
import { User } from '@/models/entities/user.js';
import type { Packed } from '@/misc/schema.js';
import type { Note } from '@/models/entities/note.js';
import type { NoteReaction } from '@/models/entities/note-reaction.js';
import type { User } from '@/models/entities/user.js';
import { aggregateNoteEmojis, prefetchEmojis } from '@/misc/populate-emojis.js';
import { notificationTypes } from '@/types.js';
import { db } from '@/db/postgre.js';
import { Users, Notes, UserGroupInvitations, AccessTokens, NoteReactions } from '../index.js';
export const NotificationRepository = db.getRepository(Notification).extend({
async pack(
@ -17,7 +17,7 @@ export const NotificationRepository = db.getRepository(Notification).extend({
_hintForEachNotes_?: {
myReactions: Map<Note['id'], NoteReaction | null>;
};
}
},
): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.findOneByOrFail({ id: src });
const token = notification.appAccessTokenId ? await AccessTokens.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
@ -86,7 +86,7 @@ export const NotificationRepository = db.getRepository(Notification).extend({
async packMany(
notifications: Notification[],
meId: User['id']
meId: User['id'],
) {
if (notifications.length === 0) return [];
@ -106,10 +106,15 @@ export const NotificationRepository = db.getRepository(Notification).extend({
await prefetchEmojis(aggregateNoteEmojis(notes));
return await Promise.all(notifications.map(x => this.pack(x, {
const results = await Promise.all(notifications
.map(x =>
this.pack(x, {
_hintForEachNotes_: {
myReactions: myReactionsMap,
},
})));
}).catch(e => null),
),
);
return results.filter(x => x != null);
},
});

View file

@ -65,6 +65,7 @@ export const PageRepository = db.getRepository(Page).extend({
content: page.content,
variables: page.variables,
title: page.title,
isPublic: page.isPublic,
name: page.name,
summary: page.summary,
hideTitleWhenPinned: page.hideTitleWhenPinned,

View file

@ -47,5 +47,9 @@ export const packedPageSchema = {
ref: 'UserLite',
optional: false, nullable: false,
},
isPublic: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View file

@ -26,7 +26,7 @@ export default async (actor: CacheableRemoteUser, activity: IUpdate): Promise<st
await updatePerson(actor.uri!, resolver, object);
return `ok: Person updated`;
} else if (getApType(object) === 'Question') {
await updateQuestion(object).catch(e => console.log(e));
await updateQuestion(object, resolver).catch(e => console.log(e));
return `ok: Question updated`;
} else {
return `skip: Unknown type: ${getApType(object)}`;

View file

@ -272,7 +272,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<Us
});
//#endregion
await updateFeatured(user!.id).catch(err => logger.error(err));
await updateFeatured(user!.id, resolver).catch(err => logger.error(err));
return user!;
}
@ -386,7 +386,7 @@ export async function updatePerson(uri: string, resolver?: Resolver | null, hint
followerSharedInbox: person.sharedInbox || (person.endpoints ? person.endpoints.sharedInbox : undefined),
});
await updateFeatured(exist.id).catch(err => logger.error(err));
await updateFeatured(exist.id, resolver).catch(err => logger.error(err));
}
/**
@ -464,14 +464,14 @@ export function analyzeAttachments(attachments: IObject | IObject[] | undefined)
return { fields, services };
}
export async function updateFeatured(userId: User['id']) {
export async function updateFeatured(userId: User['id'], resolver?: Resolver) {
const user = await Users.findOneByOrFail({ id: userId });
if (!Users.isRemoteUser(user)) return;
if (!user.featured) return;
logger.info(`Updating the featured: ${user.uri}`);
const resolver = new Resolver();
if (resolver == null) resolver = new Resolver();
// Resolve to (Ordered)Collection Object
const collection = await resolver.resolveCollection(user.featured);

View file

@ -40,7 +40,7 @@ export async function extractPollFromQuestion(source: string | IObject, resolver
* @param uri URI of AP Question object
* @returns true if updated
*/
export async function updateQuestion(value: any) {
export async function updateQuestion(value: any, resolver?: Resolver) {
const uri = typeof value === 'string' ? value : value.id;
// URIがこのサーバーを指しているならスキップ
@ -55,7 +55,7 @@ export async function updateQuestion(value: any) {
//#endregion
// resolve new Question object
const resolver = new Resolver();
if (resolver == null) resolver = new Resolver();
const question = await resolver.resolve(value) as IQuestion;
apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);

View file

@ -19,9 +19,11 @@ import renderFollow from '@/remote/activitypub/renderer/follow.js';
export default class Resolver {
private history: Set<string>;
private user?: ILocalUser;
private recursionLimit?: number;
constructor() {
constructor(recursionLimit = 100) {
this.history = new Set();
this.recursionLimit = recursionLimit;
}
public getHistory(): string[] {
@ -59,7 +61,9 @@ export default class Resolver {
if (this.history.has(value)) {
throw new Error('cannot resolve already resolved one');
}
if (this.recursionLimit && this.history.size > this.recursionLimit) {
throw new Error('hit recursion limit');
}
this.history.add(value);
const host = extractDbHost(value);

View file

@ -275,6 +275,7 @@ import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___customMOTD from './endpoints/custom-motd.js';
import * as ep___customSplashIcons from './endpoints/custom-splash-icons.js';
import * as ep___latestVersion from './endpoints/latest-version.js';
import * as ep___patrons from './endpoints/patrons.js';
import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js';
@ -599,6 +600,7 @@ const eps = [
['custom-motd', ep___customMOTD],
['custom-splash-icons', ep___customSplashIcons],
['latest-version', ep___latestVersion],
['patrons', ep___patrons],
['promo/read', ep___promo_read],
['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb],

View file

@ -53,6 +53,7 @@ export const paramDef = {
eyeCatchingImageId: { type: 'string', format: 'misskey:id', nullable: true },
font: { type: 'string', enum: ['serif', 'sans-serif'], default: 'sans-serif' },
alignCenter: { type: 'boolean', default: false },
isPublic: { type: 'boolean', default: true },
hideTitleWhenPinned: { type: 'boolean', default: false },
},
required: ['title', 'name', 'content', 'variables', 'script'],
@ -97,6 +98,7 @@ export default define(meta, paramDef, async (ps, user) => {
alignCenter: ps.alignCenter,
hideTitleWhenPinned: ps.hideTitleWhenPinned,
font: ps.font,
isPublic: ps.isPublic,
})).then(x => Pages.findOneByOrFail(x.identifiers[0]));
return await Pages.pack(page);

View file

@ -67,5 +67,9 @@ export default define(meta, paramDef, async (ps, user) => {
throw new ApiError(meta.errors.noSuchPage);
}
if (!page.isPublic && (user == null || (page.userId !== user.id))) {
throw new ApiError(meta.errors.noSuchPage);
}
return await Pages.pack(page, user);
});

View file

@ -60,6 +60,7 @@ export const paramDef = {
font: { type: 'string', enum: ['serif', 'sans-serif'] },
alignCenter: { type: 'boolean' },
hideTitleWhenPinned: { type: 'boolean' },
isPublic: { type: 'boolean' },
},
required: ['pageId', 'title', 'name', 'content', 'variables', 'script'],
} as const;
@ -104,6 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
content: ps.content,
variables: ps.variables,
script: ps.script,
isPublic: ps.isPublic,
alignCenter: ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter,
hideTitleWhenPinned: ps.hideTitleWhenPinned === undefined ? page.hideTitleWhenPinned : ps.hideTitleWhenPinned,
font: ps.font === undefined ? page.font : ps.font,

View file

@ -0,0 +1,27 @@
import define from '../define.js';
export const meta = {
tags: ['meta'],
description: 'Get list of Calckey patrons from Codeberg',
requireCredential: false,
requireCredentialPrivateMode: false,
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async () => {
let patrons;
await fetch('https://codeberg.org/thatonecalculator/calckey/raw/branch/develop/patrons.json')
.then((response) => response.json())
.then((data) => {
patrons = data['patrons'];
});
return patrons;
});

View file

@ -34,7 +34,8 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, user) => {
const query = makePaginationQuery(Pages.createQueryBuilder('page'), ps.sinceId, ps.untilId)
.andWhere('page.userId = :userId', { userId: ps.userId })
.andWhere('page.visibility = \'public\'');
.andWhere('page.visibility = \'public\'')
.andWhere('page.isPublic = true');
const pages = await query
.take(ps.limit)

View file

@ -9,7 +9,7 @@ export function genOpenapiSpec() {
info: {
version: 'v1',
title: 'Misskey API',
title: 'Calckey API',
'x-logo': { url: '/static-assets/api-doc.png' },
},

View file

@ -232,8 +232,43 @@ const getFeed = async (acct: string) => {
return user && await packFeed(user);
};
// As the /@user[.json|.rss|.atom]/sub endpoint is complicated, we will use a regex to switch between them.
const reUser = new RegExp(`^/@(?<user>[^/]+?)(?:\.(?<feed>json|rss|atom))?(?:/(?<sub>[^/]+))?$`);
router.get(reUser, async (ctx, next) => {
const groups = reUser.exec(ctx.originalUrl)?.groups;
if (!groups) {
await next();
return;
}
ctx.params = groups;
console.log(ctx, ctx.params)
if (groups.feed) {
if (groups.sub) {
await next();
return;
}
switch (groups.feed) {
case 'json':
await jsonFeed(ctx, next);
break;
case 'rss':
await rssFeed(ctx, next);
break;
case 'atom':
await atomFeed(ctx, next);
break;
}
return;
}
await userPage(ctx, next);
});
// Atom
router.get('/@:user.atom', async ctx => {
const atomFeed: Router.Middleware = async ctx => {
const feed = await getFeed(ctx.params.user);
if (feed) {
@ -242,10 +277,10 @@ router.get('/@:user.atom', async ctx => {
} else {
ctx.status = 404;
}
});
};
// RSS
router.get('/@:user.rss', async ctx => {
const rssFeed: Router.Middleware = async ctx => {
const feed = await getFeed(ctx.params.user);
if (feed) {
@ -254,10 +289,10 @@ router.get('/@:user.rss', async ctx => {
} else {
ctx.status = 404;
}
});
};
// JSON
router.get('/@:user.json', async ctx => {
const jsonFeed: Router.Middleware = async ctx => {
const feed = await getFeed(ctx.params.user);
if (feed) {
@ -266,19 +301,26 @@ router.get('/@:user.json', async ctx => {
} else {
ctx.status = 404;
}
});
};
//#region SSR (for crawlers)
// User
router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
const { username, host } = Acct.parse(ctx.params.user);
const userPage: Router.Middleware = async (ctx, next) => {
const userParam = ctx.params.user;
const subParam = ctx.params.sub;
const { username, host } = Acct.parse(userParam);
const user = await Users.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
isSuspended: false,
});
if (user != null) {
if (user === null) {
await next();
return;
}
const profile = await UserProfiles.findOneByOrFail({ userId: user.id });
const meta = await fetchMeta();
const me = profile.fields
@ -287,22 +329,19 @@ router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => {
.map(field => field.value)
: [];
await ctx.render('user', {
const userDetail = {
user, profile, me,
avatarUrl: await Users.getAvatarUrl(user),
sub: ctx.params.sub,
sub: subParam,
instanceName: meta.name || 'Calckey',
icon: meta.iconUrl,
themeColor: meta.themeColor,
privateMode: meta.privateMode,
});
};
await ctx.render('user', userDetail);
ctx.set('Cache-Control', 'public, max-age=15');
} else {
// リモートユーザーなので
// モデレータがAPI経由で参照可能にするために404にはしない
await next();
}
});
};
router.get('/users/:user', async ctx => {
const user = await Users.findOneBy({

View file

@ -42,7 +42,7 @@ html {
width: 28px;
height: 28px;
transform: translateY(110px);
display: none !important;
display: none;
color: var(--accent);
}
#splashSpinner > .spinner {
@ -101,6 +101,16 @@ html {
}
}
@media(prefers-reduced-motion) {
#splashSpinner {
display: block;
}
#splashIcon {
animation: none;
}
}
#splashText {
position: absolute;
top: 0;

View file

@ -19,8 +19,8 @@
"blurhash": "1.1.5",
"broadcast-channel": "4.18.1",
"browser-image-resizer": "https://github.com/misskey-dev/browser-image-resizer.git#commit=0380d12c8e736788ea7f4e6e985175521ea7b23c",
"chart.js": "3.9.1",
"chartjs-adapter-date-fns": "2.0.0",
"chart.js": "4.0.1",
"chartjs-adapter-date-fns": "2.0.1",
"chartjs-plugin-gradient": "0.5.1",
"chartjs-plugin-zoom": "1.2.1",
"compare-versions": "5.0.1",
@ -31,7 +31,7 @@
"idb-keyval": "6.2.0",
"insert-text-at-cursor": "0.3.0",
"json5": "2.2.1",
"katex": "0.15.6",
"katex": "0.16.3",
"matter-js": "0.18.0",
"mfm-js": "0.23.0",
"misskey-js": "0.0.14",
@ -48,7 +48,7 @@
"swiper": "^8.4.4",
"syuilo-password-strength": "0.0.1",
"textarea-caret": "3.1.0",
"three": "0.144.0",
"three": "0.146.0",
"throttle-debounce": "5.0.0",
"tinycolor2": "1.4.2",
"tsc-alias": "1.7.1",
@ -80,7 +80,7 @@
"@typescript-eslint/parser": "5.43.0",
"cross-env": "7.0.3",
"cypress": "10.11.0",
"eslint": "8.27.0",
"eslint": "8.28.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-vue": "9.7.0",
"rollup": "2.79.1",

View file

@ -81,9 +81,12 @@ const bannerStyle = computed(() => {
top: 16px;
left: 16px;
padding: 12px 16px;
background: rgba(0, 0, 0, 0.7);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
background: rgba(0, 0, 0, 0.2);
color: #fff;
font-size: 1.2em;
border-radius: 999px;
}
> .status {
@ -93,7 +96,9 @@ const bannerStyle = computed(() => {
right: 16px;
padding: 8px 12px;
font-size: 80%;
background: rgba(0, 0, 0, 0.7);
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
color: #fff;
}

View file

@ -178,6 +178,7 @@ export default defineComponent({
> ::v-deep(i) {
margin-right: 6px;
transform: translateY(0.1em);
}
&:empty {

View file

@ -1,5 +1,6 @@
<template>
<button class="kpoogebi _button"
<button
class="kpoogebi _button"
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
:disabled="wait"
@click="onClick"
@ -8,7 +9,8 @@
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ph-hourglass-medium-bold ph-lg"></i>
</template>
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked"> <!-- つまりリモートフォローの場合 -->
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
<!-- つまりリモートフォローの場合 -->
<span v-if="full">{{ i18n.ts.processing }}</span><i class="ph-circle-notch-bold ph-lg fa-pulse"></i>
</template>
<template v-else-if="isFollowing">
@ -29,7 +31,7 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import type * as Misskey from 'misskey-js';
import * as os from '@/os';
import { stream } from '@/stream';
import { i18n } from '@/i18n';
@ -50,7 +52,7 @@ const connection = stream.useChannel('main');
if (props.user.isFollowing == null) {
os.api('users/show', {
userId: props.user.id
userId: props.user.id,
})
.then(onFollowChange);
}
@ -75,17 +77,17 @@ async function onClick() {
if (canceled) return;
await os.api('following/delete', {
userId: props.user.id
userId: props.user.id,
});
} else {
if (hasPendingFollowRequestFromYou) {
await os.api('following/requests/cancel', {
userId: props.user.id
userId: props.user.id,
});
hasPendingFollowRequestFromYou = false;
} else {
await os.api('following/create', {
userId: props.user.id
userId: props.user.id,
});
hasPendingFollowRequestFromYou = true;
}

View file

@ -64,6 +64,7 @@ const bg = {
font-size: 0.9em;
vertical-align: top;
font-weight: bold;
text-overflow: clip;
}
}
</style>

View file

@ -2,7 +2,7 @@
<MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')">
<div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }">
<div class="header" @contextmenu="onContextmenu">
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ph--left-bold ph-lg"></i></button>
<button v-if="history.length > 0" v-tooltip="i18n.ts.goBack" class="_button" @click="back()"><i class="ph-caret-left-bold ph-lg"></i></button>
<span v-else style="display: inline-block; width: 20px"></span>
<span v-if="pageMetadata?.value" class="title">
<i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i>

View file

@ -71,21 +71,21 @@
</div>
<footer class="footer">
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button class="button _button" @click="reply()">
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<template v-if="appearNote.reply"><i class="ph-arrow-u-up-left-bold ph-lg"></i></template>
<template v-else><i class="ph-arrow-bend-up-left-bold ph-lg"></i></template>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
</button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
<i class="ph-smiley-bold ph-lg"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ph-minus-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote"/>
<button ref="menuButton" class="button _button" @click="menu()">
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
<i class="ph-dots-three-outline-bold ph-lg"></i>
</button>
</footer>
@ -135,6 +135,7 @@ import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { notePage } from '@/filters/note';
import { deepClone } from '@/scripts/clone';
const router = useRouter();
@ -145,12 +146,12 @@ const props = defineProps<{
const inChannel = inject('inChannel', null);
let note = $ref(JSON.parse(JSON.stringify(props.note)));
let note = $ref(deepClone(props.note));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = JSON.parse(JSON.stringify(note));
let result = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
@ -425,13 +426,18 @@ function readPromo() {
> .article {
display: flex;
padding: 28px 32px 18px;
cursor: pointer;
@media (pointer: coarse) {
cursor: default;
}
> .avatar {
flex-shrink: 0;
display: block;
margin: 0 14px 8px 0;
width: 58px;
height: 58px;
width: 52px;
height: 52px;
position: sticky;
/* For some reason this breaks avatar
positions on notes, commenting it for now */
@ -612,7 +618,7 @@ function readPromo() {
margin: 0 10px 8px 0;
width: 46px;
height: 46px;
top: calc(14px + var(--stickyTop, 0px));
// top: calc(14px + var(--stickyTop, 0px));
}
}
}

View file

@ -81,21 +81,21 @@
</MkA>
</div>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button class="button _button" @click="reply()">
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<template v-if="appearNote.reply"><i class="ph-arrow-u-up-left-bold ph-lg"></i></template>
<template v-else><i class="ph-arrow-bend-up-left-bold ph-lg"></i></template>
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
</button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
<button v-if="appearNote.myReaction == null" ref="reactButton" class="button _button" @click="react()">
<button v-if="appearNote.myReaction == null" ref="reactButton" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
<i class="ph-smiley-bold ph-lg"></i>
</button>
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
<i class="ph-minus-bold ph-lg"></i>
</button>
<XQuoteButton class="button" :note="appearNote"/>
<button ref="menuButton" class="button _button" @click="menu()">
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
<i class="ph-dots-three-outline-bold ph-lg"></i>
</button>
</footer>
@ -117,7 +117,7 @@
<script lang="ts" setup>
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
import * as mfm from 'mfm-js';
import * as misskey from 'misskey-js';
import type * as misskey from 'misskey-js';
import MkNoteSub from '@/components/MkNoteSub.vue';
import XNoteSimple from '@/components/MkNoteSimple.vue';
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
@ -143,6 +143,7 @@ import { $i } from '@/account';
import { i18n } from '@/i18n';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { useNoteCapture } from '@/scripts/use-note-capture';
import { deepClone } from '@/scripts/clone';
const router = useRouter();
@ -153,12 +154,12 @@ const props = defineProps<{
const inChannel = inject('inChannel', null);
let note = $ref(JSON.parse(JSON.stringify(props.note)));
let note = $ref(deepClone(props.note));
// plugin
if (noteViewInterruptors.length > 0) {
onMounted(async () => {
let result = JSON.parse(JSON.stringify(note));
let result = deepClone(note);
for (const interruptor of noteViewInterruptors) {
result = await interruptor.handler(result);
}
@ -345,6 +346,11 @@ if (appearNote.replyId) {
> .reply-to-more {
opacity: 0.7;
cursor: pointer;
@media (pointer: coarse) {
cursor: default;
}
}
> .renote {
@ -410,8 +416,8 @@ if (appearNote.replyId) {
> .avatar {
display: block;
flex-shrink: 0;
width: 58px;
height: 58px;
width: 52px;
height: 52px;
}
> .body {
@ -542,6 +548,11 @@ if (appearNote.replyId) {
> .reply {
border-top: solid 0.5px var(--divider);
cursor: pointer;
@media (pointer: coarse) {
cursor: default;
}
}
> .reply, .reply-to, .reply-to-more {

View file

@ -65,6 +65,7 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
&.children {
padding: 10px 0 0 16px;
font-size: 1em;
cursor: auto;
&.max-width_450px {
padding: 10px 0 0 8px;
@ -86,9 +87,15 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
> .body {
flex: 1;
min-width: 0;
cursor: pointer;
@media (pointer: coarse) {
cursor: default;
}
> .header {
margin-bottom: 2px;
cursor: auto;
}
> .body {

View file

@ -5,7 +5,7 @@
<MkAvatar v-else-if="notification.user" class="icon" :user="notification.user"/>
<img v-else-if="notification.icon" class="icon" :src="notification.icon" alt=""/>
<div class="sub-icon" :class="notification.type">
<i v-if="notification.type === 'follow'" class="ph-plus-bold"></i>
<i v-if="notification.type === 'follow'" class="ph-hand-waving-bold"></i>
<i v-else-if="notification.type === 'receiveFollowRequest'" class="ph-clock-bold"></i>
<i v-else-if="notification.type === 'followRequestAccepted'" class="ph-check-bold"></i>
<i v-else-if="notification.type === 'groupInvited'" class="ph-identification-card-bold"></i>

View file

@ -57,7 +57,7 @@ const buttonsLeft = $computed(() => {
if (history.length > 1) {
buttons.push({
icon: 'ph--left-bold ph-lg',
icon: 'ph-caret-left-bold ph-lg',
onClick: back,
});
}

View file

@ -36,7 +36,7 @@
<MkAcct :user="u"/>
<button class="_button" @click="removeVisibleUser(u)"><i class="ph-x-bold ph-lg"></i></button>
</span>
<button class="_buttonPrimary" @click="addVisibleUser"><i class="ph-plus-bold ph-lg ph-fw ph-lg"></i></button>
<button class="_button" @click="addVisibleUser"><i class="ph-plus-bold ph-md ph-fw ph-lg"></i></button>
</div>
</div>
<MkInfo v-if="hasNotSpecifiedMentions" warn class="hasNotSpecifiedMentions">{{ i18n.ts.notSpecifiedMentionWarning }} - <button class="_textButton" @click="addMissingMention()">{{ i18n.ts.add }}</button></MkInfo>
@ -89,6 +89,7 @@ import { i18n } from '@/i18n';
import { instance } from '@/instance';
import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
import { uploadFile } from '@/scripts/upload';
import { deepClone } from '@/scripts/clone';
const modal = inject('modal');
@ -575,7 +576,7 @@ async function post() {
// plugin
if (notePostInterruptors.length > 0) {
for (const interruptor of notePostInterruptors) {
postData = await interruptor.handler(JSON.parse(JSON.stringify(postData)));
postData = await interruptor.handler(deepClone(postData));
}
}
@ -832,7 +833,7 @@ onMounted(() => {
padding: 6px 24px;
margin-bottom: 8px;
overflow: auto;
white-space: nowrap;
line-height: 2rem;
> .visibleUsers {
display: inline;
@ -840,15 +841,19 @@ onMounted(() => {
font-size: 14px;
> button {
padding: 4px;
padding: 2px;
border-radius: 8px;
> i {
transform: translateX(2px);
}
}
> span {
margin-right: 14px;
padding: 8px 0 8px 8px;
border-radius: 8px;
background: var(--X4);
margin: 0.3rem;
padding: 4px 0 4px 4px;
border-radius: 999px;
background: var(--X3);
> button {
padding: 4px 8px;

View file

@ -1,6 +1,7 @@
<template>
<button
v-if="canRenote && $store.state.seperateRenoteQuote"
v-tooltip.noDelay.bottom="i18n.ts.quote"
class="eddddedb _button"
@click="quote()"
>
@ -14,6 +15,7 @@ import type { Note } from 'misskey-js/built/entities';
import { pleaseLogin } from '@/scripts/please-login';
import * as os from '@/os';
import { $i } from '@/account';
import { i18n } from '@/i18n';
const props = defineProps<{
note: Note;

View file

@ -2,6 +2,7 @@
<button
v-if="canRenote"
ref="buttonRef"
v-tooltip.noDelay.bottom="i18n.ts.renote"
class="eddddedb _button canRenote"
@click="renote(false, $event)"
>
@ -15,7 +16,7 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import * as misskey from 'misskey-js';
import type * as misskey from 'misskey-js';
import Ripple from '@/components/MkRipple.vue';
import XDetails from '@/components/MkUsersTooltip.vue';
import { pleaseLogin } from '@/scripts/please-login';
@ -23,7 +24,7 @@ import * as os from '@/os';
import { $i } from '@/account';
import { useTooltip } from '@/scripts/use-tooltip';
import { i18n } from '@/i18n';
import { defaultStore } from "@/store";
import { defaultStore } from '@/store';
const props = defineProps<{
note: misskey.entities.Note;
@ -52,9 +53,22 @@ useTooltip(buttonRef, async (showing) => {
}, {}, 'closed');
});
const renote = (viaKeyboard = false, ev?: MouseEvent) => {
const renote = async (viaKeyboard = false, ev?: MouseEvent) => {
pleaseLogin();
if (defaultStore.state.seperateRenoteQuote) {
const renotes = await os.api('notes/renotes', {
noteId: props.note.id,
limit: 11,
});
const users = renotes.map(x => x.user.id);
const hasRenotedBefore = users.includes($i.id);
let buttonActions = [{
text: i18n.ts.renote,
icon: 'ph-repeat-bold ph-lg',
danger: false,
action: () => {
os.api('notes/create', {
renoteId: props.note.id,
visibility: props.note.visibility,
@ -66,28 +80,35 @@ const renote = (viaKeyboard = false, ev?: MouseEvent) => {
const y = rect.top + (el.offsetHeight / 2);
os.popup(Ripple, { x, y }, {}, 'end');
}
} else {
os.popupMenu([{
text: i18n.ts.renote,
icon: 'ph-repeat-bold ph-lg',
action: () => {
os.api('notes/create', {
renoteId: props.note.id,
visibility: props.note.visibility,
});
},
}, {
}];
if (!defaultStore.state.seperateRenoteQuote) {
buttonActions.push({
text: i18n.ts.quote,
icon: 'ph-quotes-bold ph-lg',
danger: false,
action: () => {
os.post({
renote: props.note,
});
},
}], buttonRef.value, {
viaKeyboard,
});
}
if (hasRenotedBefore) {
buttonActions.push({
text: i18n.ts.unrenote,
icon: 'ph-trash-bold ph-lg',
danger: true,
action: () => {
os.api('notes/unrenote', {
noteId: props.note.id,
});
},
});
}
os.popupMenu(buttonActions, buttonRef.value, { viaKeyboard });
};
</script>

View file

@ -65,6 +65,7 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { reducedMotion } from '@/scripts/reduced-motion';
const particles = ref([]);
const el = ref<HTMLElement>();
@ -75,6 +76,7 @@ let stop = false;
let ro: ResizeObserver | undefined;
onMounted(() => {
if (!reducedMotion()) {
ro = new ResizeObserver((entries, observer) => {
width.value = el.value?.offsetWidth + 64;
height.value = el.value?.offsetHeight + 64;
@ -103,6 +105,7 @@ onMounted(() => {
}, 500 + (Math.random() * 500));
};
add();
}
});
onUnmounted(() => {

View file

@ -1,5 +1,5 @@
<template>
<button class="skdfgljsdkf _button" @click="star($event)">
<button v-tooltip.noDelay.bottom="i18n.ts._gallery.like" class="skdfgljsdkf _button" @click="star($event)">
<i class="ph-star-bold ph-lg"></i>
</button>
</template>
@ -9,6 +9,7 @@ import type { Note } from 'misskey-js/built/entities';
import Ripple from '@/components/MkRipple.vue';
import { pleaseLogin } from '@/scripts/please-login';
import * as os from '@/os';
import { i18n } from '@/i18n';
const props = defineProps<{
note: Note;

View file

@ -1,5 +1,6 @@
import { VNode, defineComponent, h } from 'vue';
import { defineComponent, h } from 'vue';
import * as mfm from 'mfm-js';
import type { VNode } from 'vue';
import MkUrl from '@/components/global/MkUrl.vue';
import MkLink from '@/components/MkLink.vue';
import MkMention from '@/components/MkMention.vue';
@ -12,6 +13,7 @@ import MkSparkle from '@/components/MkSparkle.vue';
import MkA from '@/components/global/MkA.vue';
import { host } from '@/config';
import { MFM_TAGS } from '@/scripts/mfm-tags';
import { reducedMotion } from '@/scripts/reduced-motion';
export default defineComponent({
props: {
@ -97,17 +99,17 @@ export default defineComponent({
}
case 'jelly': {
const speed = validTime(token.props.args.speed) || '1s';
style = (this.$store.state.animatedMfm ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
style = (this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-rubberBand ${speed} linear infinite both;` : '');
break;
}
case 'twitch': {
const speed = validTime(token.props.args.speed) || '0.5s';
style = this.$store.state.animatedMfm ? `animation: mfm-twitch ${speed} ease infinite;` : '';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-twitch ${speed} ease infinite;` : '';
break;
}
case 'shake': {
const speed = validTime(token.props.args.speed) || '0.5s';
style = this.$store.state.animatedMfm ? `animation: mfm-shake ${speed} ease infinite;` : '';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-shake ${speed} ease infinite;` : '';
break;
}
case 'spin': {
@ -120,19 +122,30 @@ export default defineComponent({
token.props.args.y ? 'mfm-spinY' :
'mfm-spin';
const speed = validTime(token.props.args.speed) || '1.5s';
style = this.$store.state.animatedMfm ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: ${anime} ${speed} linear infinite; animation-direction: ${direction};` : '';
break;
}
case 'jump': {
const speed = validTime(token.props.args.speed) || '0.75s';
style = this.$store.state.animatedMfm ? `animation: mfm-jump ${speed} linear infinite;` : '';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-jump ${speed} linear infinite;` : '';
break;
}
case 'bounce': {
const speed = validTime(token.props.args.speed) || '0.75s';
style = this.$store.state.animatedMfm ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-bounce ${speed} linear infinite; transform-origin: center bottom;` : '';
break;
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) || '1s';
style = this.$store.state.animatedMfm && !reducedMotion() ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
break;
}
case 'sparkle': {
if (!this.$store.state.animatedMfm && !reducedMotion()) {
return genEl(token.children);
}
return h(MkSparkle, {}, genEl(token.children));
}
case 'flip': {
const transform =
(token.props.args.h && token.props.args.v) ? 'scale(-1, -1)' :
@ -173,17 +186,6 @@ export default defineComponent({
class: '_mfm_blur_',
}, genEl(token.children));
}
case 'rainbow': {
const speed = validTime(token.props.args.speed) || '1s';
style = this.$store.state.animatedMfm ? `animation: mfm-rainbow ${speed} linear infinite;` : '';
break;
}
case 'sparkle': {
if (!this.$store.state.animatedMfm) {
return genEl(token.children);
}
return h(MkSparkle, {}, genEl(token.children));
}
case 'rotate': {
const rotate =
token.props.args.x ? 'perspective(128px) rotateX' :

21027
packages/client/src/icons.scss Normal file

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
*/
import '@/style.scss';
import '@/icons.scss';
//#region account indexedDB migration
import { set } from '@/scripts/idb-proxy';
@ -295,7 +296,7 @@ import { getAccountFromId } from '@/scripts/get-account-from-id';
}, { immediate: true });
watch(defaultStore.reactiveState.useBlurEffect, v => {
if (v) {
if (v && deviceKind !== 'smartphone') {
document.documentElement.style.removeProperty('--blur');
} else {
document.documentElement.style.setProperty('--blur', 'none');

View file

@ -72,7 +72,7 @@ export const navbarItemDef = reactive({
},
favorites: {
title: 'favorites',
icon: 'ph-star-bold ph-lg',
icon: 'ph-bookmark-simple-bold ph-lg',
show: computed(() => $i != null),
to: '/my/favorites',
},

View file

@ -24,20 +24,29 @@
{{ i18n.ts._aboutMisskey.source }}
<template #suffix>Codeberg</template>
</FormLink>
<FormLink to="https://liberapay.com/ThatOneCalculator" external>
<template #icon><i class="ph-money-bold ph-lg"></i></template>
{{ i18n.ts._aboutMisskey.donate }}
<template #suffix>Donate</template>
</FormLink>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._aboutMisskey.contributors }}</template>
<div class="_formLinks">
<FormLink to="https://codeberg.org/thatonecalculator" external>ThatOneCalculator (fork developer)</FormLink>
<FormLink to="https://github.com/syuilo" external>Syuilo (Misskey developer)</FormLink>
<FormLink to="/@t1c@i.calckey.cloud"><Mfm :text="'$[sparkle @t1c@i.calckey.cloud] (Main fork developer)'"/></FormLink>
<FormLink to="/@syuilo@misskey.io"><Mfm :text="'@syuilo@misskey.io (Misskey developer)'"/></FormLink>
<FormLink to="https://www.youtube.com/c/Henkiwashere" external>Henki (error images artist)</FormLink>
</div>
<template #caption><MkLink url="https://codeberg.org/thatonecalculator/calckey/activity">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template>
</FormSection>
<FormSection>
<template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template>
<div v-for="patron in patrons" :key="patron">{{ patron }}</div>
<MkSparkle>
<div v-for="patron in patrons" :key="patron" style="margin-bottom: 0.5rem">
<Mfm :text="`${patron}`"/>
</div>
</MkSparkle>
<template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template>
</FormSection>
</div>
@ -53,92 +62,14 @@ import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import MkButton from '@/components/MkButton.vue';
import MkLink from '@/components/MkLink.vue';
import MkSparkle from '@/components/MkSparkle.vue';
import { physics } from '@/scripts/physics';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
import * as os from '@/os';
import { definePageMetadata } from '@/scripts/page-metadata';
const patrons = [
'まっちゃとーにゅ',
'mametsuko',
'noellabo',
'AureoleArk',
'Gargron',
'Nokotaro Takeda',
'Suji Yan',
'oi_yekssim',
'regtan',
'Hekovic',
'nenohi',
'Gitmo Life Services',
'naga_rus',
'Efertone',
'Melilot',
'motcha',
'nanami kan',
'sevvie Rose',
'Hayato Ishikawa',
'Puniko',
'skehmatics',
'Quinton Macejkovic',
'YUKIMOCHI',
'dansup',
'mewl hayabusa',
'Emilis',
'Fristi',
'makokunsan',
'chidori ninokura',
'Peter G.',
'見当かなみ',
'natalie',
'Maronu',
'Steffen K9',
'takimura',
'sikyosyounin',
'Nesakko',
'YuzuRyo61',
'blackskye',
'sheeta.s',
'osapon',
'public_yusuke',
'CG',
'吴浥',
't_w',
'Jerry',
'nafuchoco',
'Takumi Sugita',
'GLaTAN',
'mkatze',
'kabo2468y',
'mydarkstar',
'Roujo',
'DignifiedSilence',
'uroco @99',
'totokoro',
'うし',
'kiritan',
'weepjp',
'Liaizon Wakest',
'Duponin',
'Blue',
'Naoki Hirayama',
'wara',
'Wataru Manji (manji0)',
'みなしま',
'kanoy',
'xianon',
'Denshi',
'Osushimaru',
'にょんへら',
'おのだい',
'Leni',
'oss',
'Weeble',
'蝉暮せせせ',
'ThatOneCalculator',
'pixeldesu',
];
const patrons = await os.api('patrons');
let easterEggReady = false;
let easterEggEmojis = $ref([]);

View file

@ -5,13 +5,23 @@
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img src="/static-assets/badges/info.png" class="_ghost" alt="Info"/>
<img
src="/static-assets/badges/info.png"
class="_ghost"
alt="Info"
/>
<div>{{ i18n.ts.noNotes }}</div>
</div>
</template>
<template #default="{ items }">
<XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false">
<XList
v-slot="{ item }"
:items="items"
:direction="'down'"
:no-gap="false"
:ad="false"
>
<XNote :key="item.id" :note="item.note" :class="$style.note" />
</XList>
</template>
@ -21,15 +31,15 @@
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import XNote from '@/components/MkNote.vue';
import XList from '@/components/MkDateSeparatedList.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { ref } from "vue";
import MkPagination from "@/components/MkPagination.vue";
import XNote from "@/components/MkNote.vue";
import XList from "@/components/MkDateSeparatedList.vue";
import { i18n } from "@/i18n";
import { definePageMetadata } from "@/scripts/page-metadata";
const pagination = {
endpoint: 'i/favorites' as const,
endpoint: "i/favorites" as const,
limit: 10,
};
@ -37,7 +47,7 @@ const pagingComponent = ref<InstanceType<typeof MkPagination>>();
definePageMetadata({
title: i18n.ts.favorites,
icon: 'ph-star-bold ph-lg',
icon: "ph-bookmark-simple-bold ph-lg",
});
</script>

View file

@ -24,7 +24,7 @@
<div class="other">
<button v-if="$i && $i.id === post.user.id" v-tooltip="i18n.ts.edit" v-click-anime class="_button" @click="edit"><i class="ph-pencil-bold ph-lg ph-fw ph-lg"></i></button>
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-repeat-bold ph-lg ph-fw ph-lg"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network-bold ph-lg ph-fw ph-lg"></i></button>
<button v-if="shareAvailable()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network-bold ph-lg ph-fw ph-lg"></i></button>
</div>
</div>
<div class="user">
@ -67,6 +67,7 @@ import { url } from '@/config';
import { useRouter } from '@/router';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { shareAvailable } from '@/scripts/share-available';
const router = useRouter();

View file

@ -1,6 +1,6 @@
<template>
<div v-size="{ max: [400, 500] }" class="thvuemwp" :class="{ isMe }">
<MkAvatar class="avatar" :user="message.user" :show-indicator="true"/>
<MkAvatar v-if="!isMe" class="avatar" :user="message.user" :show-indicator="true"/>
<div class="content">
<div class="balloon" :class="{ noText: message.text == null }">
<button v-if="isMe" class="delete-button" :title="i18n.ts.delete" @click="del">
@ -38,7 +38,6 @@
<script lang="ts" setup>
import { } from 'vue';
import * as mfm from 'mfm-js';
import VuePlyr from 'vue-plyr';
import type * as Misskey from 'misskey-js';
import XMediaList from '@/components/MkMediaList.vue';
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
@ -73,10 +72,10 @@ function del(): void {
> .avatar {
position: sticky;
top: calc(var(--stickyTop, 0px) + 16px);
top: calc(var(--stickyTop, 0px) + 20px);
display: block;
width: 54px;
height: 54px;
width: 45px;
height: 45px;
transition: all 0.1s ease;
}
@ -92,14 +91,6 @@ function del(): void {
border-radius: 16px;
max-width: 100%;
&:before {
content: "";
pointer-events: none;
display: block;
position: absolute;
top: 12px;
}
& + * {
clear: both;
}
@ -222,7 +213,7 @@ function del(): void {
padding-right: 32px;
> .balloon {
$color: var(--messageBg);
$color: var(--X4);
background: $color;
&.noText {

View file

@ -24,6 +24,7 @@
<template #label>{{ i18n.ts._pages.url }}</template>
</MkInput>
<MkSwitch v-model="isPublic" class="_formBlock">{{ i18n.ts.public }}</MkSwitch>
<MkSwitch v-model="alignCenter" class="_formBlock">{{ i18n.ts._pages.alignCenter }}</MkSwitch>
<MkSelect v-model="font" class="_formBlock">
@ -47,7 +48,6 @@
<div v-else-if="tab === 'contents'">
<div>
<XBlocks v-model="content" class="content" :hpml="hpml"/>
<MkButton v-if="!readonly" @click="add()"><i class="ph-plus-bold ph-lg"></i></MkButton>
</div>
</div>
@ -130,6 +130,7 @@ let eyeCatchingImageId = $ref(null);
let font = $ref('sans-serif');
let content = $ref([]);
let alignCenter = $ref(false);
let isPublic = $ref(true);
let hideTitleWhenPinned = $ref(false);
let variables = $ref([]);
let hpml = $ref(null);
@ -158,6 +159,7 @@ function getSaveOptions() {
script: script,
hideTitleWhenPinned: hideTitleWhenPinned,
alignCenter: alignCenter,
isPublic: isPublic,
content: content,
variables: variables,
eyeCatchingImageId: eyeCatchingImageId,
@ -393,6 +395,7 @@ async function init() {
script = page.script;
hideTitleWhenPinned = page.hideTitleWhenPinned;
alignCenter = page.alignCenter;
isPublic = page.isPublic;
content = page.content;
variables = page.variables;
eyeCatchingImageId = page.eyeCatchingImageId;
@ -401,7 +404,7 @@ async function init() {
content = [{
id,
type: 'text',
text: 'Hello World!',
text: '',
}];
}
}
@ -447,7 +450,7 @@ definePageMetadata(computed(() => {
.jqqmcavi {
> .button {
& + .button {
margin-left: 8px;
margin: 4px;
}
}
}

View file

@ -4,14 +4,25 @@
<MkSpacer :content-max="800">
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh">
<div class="footer">
<div><i class="ph-alarm-bold"/> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
<div v-if="page.createdAt != page.updatedAt"><i class="ph-alarm-bold"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div>
<div class="_block main">
<!--
<div class="banner">
<div class="banner-image">
<div class="header">
<h1>{{ page.title }}</h1>
</div>
-->
<div class="banner">
<img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/>
<div class="menu-actions">
<MkA v-tooltip="i18n.ts._pages.viewSource" :to="`/@${username}/pages/${pageName}/view-source`" class="menu _button"><i class="ph-code-bold ph-lg"/></MkA>
<template v-if="$i && $i.id === page.userId">
<MkA v-tooltip="i18n.ts._pages.editPage" class="menu _button" :to="`/pages/edit/${page.id}`"><i class="ph-pencil-bold ph-lg"/></MkA>
<button v-if="$i.pinnedPageId === page.id" v-tooltip="i18n.ts.unpin" class="menu _button" @click="pin(false)"><i class="ph-push-pin-slash-bold ph-lg"/></button>
<button v-else v-tooltip="i18n.ts.pin" class="menu _button" @click="pin(true)"><i class="ph-push-pin-bold ph-lg"/></button>
</template>
</div>
</div>
</div>
<div class="content">
<XPage :page="page"/>
@ -23,8 +34,7 @@
</div>
<div class="other">
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ph-repeat-bold ph-lg ph-fw ph-lg"></i></button>
<button v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network-bold ph-lg ph-fw ph-lg"></i></button>
</div>
<button v-if="shareAvailable()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ph-share-network-bold ph-lg ph-fw ph-lg"></i></button>
</div>
<div class="user">
<MkAvatar :user="page.user" class="avatar"/>
@ -32,20 +42,17 @@
<MkUserName :user="page.user" style="display: block;"/>
<MkAcct :user="page.user"/>
</div>
<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/>
<MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
</div>
<div class="links">
</div>
<!-- <div class="links">
<MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA>
<template v-if="$i && $i.id === page.userId">
<MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA>
<button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button>
<button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button>
</template>
</div>
</div>
<div class="footer">
<div><i class="ph-alarm-bold"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div>
<div v-if="page.createdAt != page.updatedAt"><i class="ph-alarm-bold"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div>
</div> -->
</div>
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
<MkContainer :max-height="300" :foldable="true" class="other">
@ -74,6 +81,7 @@ import MkPagination from '@/components/MkPagination.vue';
import MkPagePreview from '@/components/MkPagePreview.vue';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { shareAvailable } from '@/scripts/share-available';
const props = defineProps<{
pageName: string;
@ -81,6 +89,7 @@ const props = defineProps<{
}>();
let page = $ref(null);
let bgImg = $ref(null);
let error = $ref(null);
const otherPostsPagination = {
endpoint: 'users/pages' as const,
@ -98,11 +107,21 @@ function fetchPage() {
username: props.username,
}).then(_page => {
page = _page;
bgImg = getBgImg();
}).catch(err => {
error = err;
});
}
function getBgImg(): string {
if (page.eyeCatchingImage != null) {
return `url(${page.eyeCatchingImage.url})`;
}
else {
return 'linear-gradient(to bottom right, #31748f, #9ccfd8)'
}
}
function share() {
navigator.share({
title: page.title ?? page.name,
@ -118,7 +137,7 @@ function shareWithNote() {
}
function like() {
os.apiWithDialog('pages/like', {
os.api('pages/like', {
pageId: page.id,
}).then(() => {
page.isLiked = true;
@ -180,35 +199,65 @@ definePageMetadata(computed(() => page ? {
margin: 1rem;
}
> .banner {
margin: 0rem !important;
> .banner-image {
// TODO:
display: block;
width: 100%;
height: 150px;
background-position: center;
background-size: cover;
background-image: v-bind('bgImg');
> .header {
padding: 16px;
> h1 {
margin: 0;
color: white;
text-shadow: 0 0 8px #000;
}
}
> .banner {
margin: 0rem !important;
> .menu-actions {
-webkit-backdrop-filter: var(--blur, blur(8px));
backdrop-filter: var(--blur, blur(8px));
background: rgba(0, 0, 0, 0.2);
padding: 8px;
border-radius: 24px;
width: fit-content;
position: relative;
top: -10px;
left: 1rem;
> img {
// TODO:
display: block;
width: 100%;
height: 150px;
object-fit: cover;
> .menu {
vertical-align: bottom;
height: 31px;
width: 31px;
color: #fff;
text-shadow: 0 0 8px #000;
font-size: 16px;
}
> .koudoku {
margin-left: 4px;
vertical-align: bottom;
}
}
}
}
> .content {
padding: 16px 0 0 0;
padding: 16px 0;
}
> .actions {
display: flex;
align-items: center;
margin-top: 16px;
padding: 16px 0 0 0;
padding: 16px 0;
border-top: solid 0.5px var(--divider);
> .like {
@ -226,10 +275,8 @@ definePageMetadata(computed(() => page ? {
}
> .other {
margin-left: auto;
> button {
padding: 8px;
padding: 2px;
margin: 0 8px;
&:hover {
@ -237,18 +284,15 @@ definePageMetadata(computed(() => page ? {
}
}
}
}
> .user {
margin-top: 16px;
padding: 16px 0 0 0;
border-top: solid 0.5px var(--divider);
margin-left: auto;
display: flex;
align-items: center;
> .avatar {
width: 52px;
height: 52px;
width: 40px;
height: 40px;
}
> .name {
@ -258,6 +302,8 @@ definePageMetadata(computed(() => page ? {
> .koudoku {
margin-left: auto;
margin: 1rem;
}
}
}

View file

@ -66,8 +66,9 @@ import * as os from '@/os';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { deepClone } from '@/scripts/clone';
let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions)));
let reactions = $ref(deepClone(defaultStore.state.reactions));
const reactionPickerSize = $computed(defaultStore.makeGetterSetter('reactionPickerSize'));
const reactionPickerWidth = $computed(defaultStore.makeGetterSetter('reactionPickerWidth'));
@ -101,7 +102,7 @@ async function setDefault() {
});
if (canceled) return;
reactions = JSON.parse(JSON.stringify(defaultStore.def.reactions.default));
reactions = deepClone(defaultStore.def.reactions.default);
}
function chooseEmoji(ev: MouseEvent) {

View file

@ -91,13 +91,14 @@ import FormRange from '@/components/form/range.vue';
import * as os from '@/os';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
import { deepClone } from '@/scripts/clone';
const props = defineProps<{
_id: string;
userLists: any[] | null;
}>();
const statusbar = reactive(JSON.parse(JSON.stringify(defaultStore.state.statusbars.find(x => x.id === props._id))));
const statusbar = reactive(deepClone(defaultStore.state.statusbars.find(x => x.id === props._id)));
watch(() => statusbar.type, () => {
if (statusbar.type === 'rss') {
@ -128,8 +129,8 @@ watch(statusbar, save);
async function save() {
const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id);
const statusbars = JSON.parse(JSON.stringify(defaultStore.state.statusbars));
statusbars[i] = JSON.parse(JSON.stringify(statusbar));
const statusbars = deepClone(defaultStore.state.statusbars);
statusbars[i] = deepClone(statusbar);
defaultStore.set('statusbars', statusbars);
}

View file

@ -25,9 +25,10 @@
</div>
</div>
<span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span>
<div v-if="$i" class="actions">
<div class="actions">
<button class="menu _button" @click="menu"><i class="ph-dots-three-outline-bold ph-lg"></i></button>
<MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/>
<MkFollowButton v-if="$i != null && $i.id != user.id" :user="user" :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>
<MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/>

View file

@ -0,0 +1,18 @@
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
type Cloneable = string | number | boolean | null | { [key: string]: Cloneable } | Cloneable[];
export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = deepClone(v);
}
return obj as T;
} else {
return x;
}
}

View file

@ -8,6 +8,7 @@ import * as os from '@/os';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { url } from '@/config';
import { noteActions } from '@/store';
import { shareAvailable } from '@/scripts/share-available';
export function getNoteMenu(props: {
note: misskey.entities.Note;
@ -220,23 +221,23 @@ export function getNoteMenu(props: {
window.open(appearNote.url || appearNote.uri, '_blank');
},
} : undefined,
{
shareAvailable() ? {
icon: 'ph-share-network-bold ph-lg',
text: i18n.ts.share,
action: share,
},
} : undefined,
instance.translatorAvailable ? {
icon: 'ph-translate-bold ph-lg',
text: i18n.ts.translate,
action: translate,
} : undefined,
null,
statePromise.then(state => state.isFavorited ? {
icon: 'ph-star-bold ph-lg',
statePromise.then(state => state?.isFavorited ? {
icon: 'ph-bookmark-simple-bold ph-lg',
text: i18n.ts.unfavorite,
action: () => toggleFavorite(false),
} : {
icon: 'ph-star-bold ph-lg',
icon: 'ph-bookmark-simple-bold ph-lg',
text: i18n.ts.favorite,
action: () => toggleFavorite(true),
}),

View file

@ -0,0 +1,3 @@
export function reducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

View file

@ -0,0 +1,6 @@
export function shareAvailable(): boolean {
if (navigator.share) {
return true;
}
return false;
}

View file

@ -13,6 +13,7 @@ export type Theme = {
import lightTheme from '@/themes/_light.json5';
import darkTheme from '@/themes/_dark.json5';
import { deepClone } from './clone';
export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X'));
@ -63,7 +64,7 @@ export function applyTheme(theme: Theme, persist = true) {
const colorSchema = theme.base === 'dark' ? 'dark' : 'light';
// Deep copy
const _theme = JSON.parse(JSON.stringify(theme));
const _theme = deepClone(theme);
if (_theme.base) {
const base = [lightTheme, darkTheme].find(x => x.id === _theme.base);

View file

@ -98,9 +98,9 @@ a {
-webkit-tap-highlight-color: transparent;
}
i {
transform: translateY(0.1em);
}
// i {
// transform: translateY(0.1em);
// }
textarea, input {
tap-highlight-color: transparent;
@ -568,6 +568,22 @@ hr {
}
}
@media(prefers-reduced-motion) {
@keyframes tada {
from {
transform: scale3d(1, 1, 1);
}
50% {
transform: scale3d(1.1, 1.1, 1.1);
}
to {
transform: scale3d(1, 1, 1);
}
}
}
._anime_bounce {
will-change: transform;
animation: bounce ease 0.7s;

View file

@ -11,7 +11,7 @@
<XStreamIndicator/>
<div v-if="pendingApiRequestsCount > 0" id="wait"></div>
<!-- <div v-if="pendingApiRequestsCount > 0" id="wait"></div> -->
<div v-if="dev" id="devTicker"><span>DEV BUILD</span></div>
</template>
@ -99,8 +99,8 @@ if ($i) {
top: 0;
left: 0;
z-index: 2147483647;
color: #ff0;
background: rgba(0, 0, 0, 0.5);
color: #f6c177;
background: #6e6a86;
padding: 4px 5px;
font-size: 14px;
pointer-events: none;

View file

@ -38,7 +38,7 @@
<button v-tooltip.noDelay.left="i18n.ts._deck.deleteProfile" class="_button button" @click="deleteProfile"><i class="ph-trash-bold ph-lg"></i></button>
</div>
<div class="middle">
<button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="ph-plus-bold ph-lg"></i></button>
<button v-tooltip.noDelay.left="i18n.ts._deck.addColumn" class="_button button new" @click="addColumn"><i class="ph-plus-bold ph-lg"></i></button>
</div>
<div class="bottom">
<button v-tooltip.noDelay.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="ph-gear-six-bold ph-lg"></i></button>
@ -322,7 +322,7 @@ async function deleteProfile() {
display: flex;
flex-direction: column;
justify-content: center;
width: 32px;
width: 44px;
> .top, > .middle, > .bottom {
> .button {
@ -339,6 +339,11 @@ async function deleteProfile() {
> .middle {
margin-top: auto;
margin-bottom: auto;
> .new {
font-size: 20px;
background-color: var(--accentedBg);
}
}
> .bottom {

View file

@ -133,25 +133,25 @@ function getMenu() {
text: i18n.ts.move + '...',
icon: 'ph-arrows-out-cardinal-bold ph-lg',
children: [{
icon: 'ph--left-bold ph-lg',
icon: 'ph-caret-left-bold ph-lg',
text: i18n.ts._deck.swapLeft,
action: () => {
swapLeftColumn(props.column.id);
},
}, {
icon: 'ph--right-bold ph-lg',
icon: 'ph-caret-right-bold ph-lg',
text: i18n.ts._deck.swapRight,
action: () => {
swapRightColumn(props.column.id);
},
}, props.isStacked ? {
icon: 'ph--up-bold ph-lg',
icon: 'ph-caret-up-bold ph-lg',
text: i18n.ts._deck.swapUp,
action: () => {
swapUpColumn(props.column.id);
},
} : undefined, props.isStacked ? {
icon: 'ph--down-bold ph-lg',
icon: 'ph-caret-down-bold ph-lg',
text: i18n.ts._deck.swapDown,
action: () => {
swapDownColumn(props.column.id);

View file

@ -4,6 +4,7 @@ import { notificationTypes } from 'misskey-js';
import { Storage } from '../../pizzax';
import { i18n } from '@/i18n';
import { api } from '@/os';
import { deepClone } from '@/scripts/clone';
type ColumnWidget = {
name: string;
@ -25,10 +26,6 @@ export type Column = {
tl?: 'home' | 'local' | 'social' | 'global';
};
function copy<T>(x: T): T {
return JSON.parse(JSON.stringify(x));
}
export const deckStore = markRaw(new Storage('deck', {
profile: {
where: 'deviceAccount',
@ -128,7 +125,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) {
const aY = deckStore.state.layout[aX].findIndex(id => id === a);
const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1);
const bY = deckStore.state.layout[bX].findIndex(id => id === b);
const layout = copy(deckStore.state.layout);
const layout = deepClone(deckStore.state.layout);
layout[aX][aY] = b;
layout[bX][bY] = a;
deckStore.set('layout', layout);
@ -136,7 +133,7 @@ export function swapColumn(a: Column['id'], b: Column['id']) {
}
export function swapLeftColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const layout = deepClone(deckStore.state.layout);
deckStore.state.layout.some((ids, i) => {
if (ids.includes(id)) {
const left = deckStore.state.layout[i - 1];
@ -152,7 +149,7 @@ export function swapLeftColumn(id: Column['id']) {
}
export function swapRightColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const layout = deepClone(deckStore.state.layout);
deckStore.state.layout.some((ids, i) => {
if (ids.includes(id)) {
const right = deckStore.state.layout[i + 1];
@ -168,9 +165,9 @@ export function swapRightColumn(id: Column['id']) {
}
export function swapUpColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const layout = deepClone(deckStore.state.layout);
const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
const ids = copy(deckStore.state.layout[idsIndex]);
const ids = deepClone(deckStore.state.layout[idsIndex]);
ids.some((x, i) => {
if (x === id) {
const up = ids[i - 1];
@ -188,9 +185,9 @@ export function swapUpColumn(id: Column['id']) {
}
export function swapDownColumn(id: Column['id']) {
const layout = copy(deckStore.state.layout);
const layout = deepClone(deckStore.state.layout);
const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id));
const ids = copy(deckStore.state.layout[idsIndex]);
const ids = deepClone(deckStore.state.layout[idsIndex]);
ids.some((x, i) => {
if (x === id) {
const down = ids[i + 1];
@ -208,7 +205,7 @@ export function swapDownColumn(id: Column['id']) {
}
export function stackLeftColumn(id: Column['id']) {
let layout = copy(deckStore.state.layout);
let layout = deepClone(deckStore.state.layout);
const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
layout = layout.map(ids => ids.filter(_id => _id !== id));
layout[i - 1].push(id);
@ -218,7 +215,7 @@ export function stackLeftColumn(id: Column['id']) {
}
export function popRightColumn(id: Column['id']) {
let layout = copy(deckStore.state.layout);
let layout = deepClone(deckStore.state.layout);
const i = deckStore.state.layout.findIndex(ids => ids.includes(id));
const affected = layout[i];
layout = layout.map(ids => ids.filter(_id => _id !== id));
@ -226,7 +223,7 @@ export function popRightColumn(id: Column['id']) {
layout = layout.filter(ids => ids.length > 0);
deckStore.set('layout', layout);
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
for (const column of columns) {
if (affected.includes(column.id)) {
column.active = true;
@ -238,9 +235,9 @@ export function popRightColumn(id: Column['id']) {
}
export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
const column = deepClone(deckStore.state.columns[columnIndex]);
if (column == null) return;
if (column.widgets == null) column.widgets = [];
column.widgets.unshift(widget);
@ -250,9 +247,9 @@ export function addColumnWidget(id: Column['id'], widget: ColumnWidget) {
}
export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
const column = deepClone(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = column.widgets.filter(w => w.id !== widget.id);
columns[columnIndex] = column;
@ -261,9 +258,9 @@ export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) {
}
export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
const column = deepClone(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = widgets;
columns[columnIndex] = column;
@ -272,9 +269,9 @@ export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) {
}
export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) {
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const column = copy(deckStore.state.columns[columnIndex]);
const column = deepClone(deckStore.state.columns[columnIndex]);
if (column == null) return;
column.widgets = column.widgets.map(w => w.id === widgetId ? {
...w,
@ -286,9 +283,9 @@ export function updateColumnWidget(id: Column['id'], widgetId: string, widgetDat
}
export function updateColumn(id: Column['id'], column: Partial<Column>) {
const columns = copy(deckStore.state.columns);
const columns = deepClone(deckStore.state.columns);
const columnIndex = deckStore.state.columns.findIndex(c => c.id === id);
const currentColumn = copy(deckStore.state.columns[columnIndex]);
const currentColumn = deepClone(deckStore.state.columns[columnIndex]);
if (currentColumn == null) return;
for (const [k, v] of Object.entries(column)) {
currentColumn[k] = v;

View file

@ -377,6 +377,10 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
> .button-wrapper {
> i {
transform: translateY(0.05em);
}
&.on {
background-color: var(--accentedBg);
width: 100%;

View file

@ -47,12 +47,13 @@
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { GetFormResultType } from '@/scripts/form';
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
import { GetFormResultType } from '@/scripts/form';
import { stream } from '@/stream';
import number from '@/filters/number';
import * as sound from '@/scripts/sound';
import * as os from '@/os';
import { deepClone } from '@/scripts/clone';
const name = 'jobQueue';
@ -100,12 +101,12 @@ const prev = reactive({} as typeof current);
const jammedSound = sound.setVolume(sound.getAudio('syuilo/queue-jammed'), 1);
for (const domain of ['inbox', 'deliver']) {
prev[domain] = JSON.parse(JSON.stringify(current[domain]));
prev[domain] = deepClone(current[domain]);
}
const onStats = (stats) => {
for (const domain of ['inbox', 'deliver']) {
prev[domain] = JSON.parse(JSON.stringify(current[domain]));
prev[domain] = deepClone(current[domain]);
current[domain].activeSincePrevTick = stats[domain].activeSincePrevTick;
current[domain].active = stats[domain].active;
current[domain].waiting = stats[domain].waiting;

View file

@ -2,6 +2,7 @@ import { reactive, watch } from 'vue';
import { throttle } from 'throttle-debounce';
import { Form, GetFormResultType } from '@/scripts/form';
import * as os from '@/os';
import { deepClone } from '@/scripts/clone';
export type Widget<P extends Record<string, unknown>> = {
id: string;
@ -32,7 +33,7 @@ export const useWidgetPropsManager = <F extends Form & Record<string, { default:
save: () => void;
configure: () => void;
} => {
const widgetProps = reactive(props.widget ? JSON.parse(JSON.stringify(props.widget.data)) : {});
const widgetProps = reactive(props.widget ? deepClone(props.widget.data) : {});
const mergeProps = () => {
for (const prop of Object.keys(propsDef)) {
@ -43,14 +44,14 @@ export const useWidgetPropsManager = <F extends Form & Record<string, { default:
};
watch(widgetProps, () => {
mergeProps();
}, { deep: true, immediate: true, });
}, { deep: true, immediate: true });
const save = throttle(3000, () => {
emit('updateProps', widgetProps);
});
const configure = async () => {
const form = JSON.parse(JSON.stringify(propsDef));
const form = deepClone(propsDef);
for (const item of Object.keys(form)) {
form[item].default = widgetProps[item];
}

View file

@ -7,11 +7,11 @@
"lint": "eslint --quiet src/**/*.{ts}"
},
"dependencies": {
"esbuild": "^0.14.54",
"esbuild": "^0.15.14",
"idb-keyval": "^6.2.0",
"misskey-js": "0.0.14"
},
"devDependencies": {
"eslint": "^8.27.0"
"eslint": "^8.28.0"
}
}

11
patrons.json Normal file
View file

@ -0,0 +1,11 @@
{
"patrons": [
"@atomicpoet@vancity.social",
"@shoq@newsroom.social",
"@pikadude@erisly.social",
"@sage@stop.voring.me",
"@sky@therian.club",
"@panos@electricrequiem.com",
"@redhunt07@www.foxyhole.io"
]
}

10
push-docker.sh Executable file
View file

@ -0,0 +1,10 @@
sudo systemctl start docker.service
sudo docker rmi $(docker images -q)
sudo docker compose build
sudo docker tag thatonecalculator/calckey:latest thatonecalculator/calckey:$(git describe --tags --exact-match)
sudo docker images
echo "\nPress any key to continue\n"
read
sudo docker push thatonecalculator/calckey:$(git describe --tags --exact-match)
sudo docker push thatonecalculator/calckey:latest
sudo systemctl stop docker.service