mirror of
https://iceshrimp.dev/limepotato/jormungandr-bite.git
synced 2024-11-25 19:37:34 -07:00
Merge branch 'develop' into beta
This commit is contained in:
commit
a57530160a
88 changed files with 1864 additions and 238 deletions
|
@ -72,6 +72,16 @@ redis:
|
|||
# user:
|
||||
# pass:
|
||||
|
||||
# ┌─────────────────────┐
|
||||
#───┘ Sonic configuration └─────────────────────────────────────
|
||||
|
||||
#sonic:
|
||||
# host: localhost
|
||||
# port: 1491
|
||||
# auth: SecretPassword
|
||||
# collection: notes
|
||||
# bucket: default
|
||||
|
||||
# ┌───────────────┐
|
||||
#───┘ ID generation └───────────────────────────────────────────
|
||||
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -44,6 +44,7 @@ ormconfig.json
|
|||
packages/backend/assets/instance.css
|
||||
packages/backend/assets/sounds/None.mp3
|
||||
|
||||
!packages/backend/src/db
|
||||
|
||||
# blender backups
|
||||
*.blend1
|
||||
|
|
13
CALCKEY.md
13
CALCKEY.md
|
@ -3,21 +3,17 @@
|
|||
## Planned
|
||||
|
||||
- Stucture
|
||||
- [Sonic](https://crates.io/crates/sonic-server) support as an ElasticSearch alternative
|
||||
- [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative
|
||||
- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes
|
||||
- Rewrite backend in Rust and [Axum](https://github.com/tokio-rs/axum)
|
||||
- Function
|
||||
- Federate with note edits
|
||||
- Admin customizable max note length (100-8000)
|
||||
- User "choices" (recommended users) like Mastodon and Soapbox
|
||||
- Join Reason system like Mastodon/Pleroma
|
||||
- Option to publicize instance blocks
|
||||
- Backfill remote users
|
||||
- Build flag to remove NSFW/AI stuff
|
||||
- Timeline filters
|
||||
- Filter notifications by user
|
||||
- Non-nyaify cat mode
|
||||
- Exclude self from antenna
|
||||
- Form
|
||||
- MFM button
|
||||
|
@ -37,6 +33,7 @@
|
|||
- Admin custom CSS
|
||||
- Add back time machine (jump to date)
|
||||
- Improve accesibility
|
||||
- Non-nyaify cat mode
|
||||
|
||||
## Implemented
|
||||
|
||||
|
@ -108,6 +105,14 @@
|
|||
- Allows custom emoji
|
||||
- Fix lint errors
|
||||
- Use Rome instead of ESLint
|
||||
- Mastodon API support
|
||||
- More antenna options
|
||||
- New dashboard
|
||||
- Backfill follower counts
|
||||
- Improved emoji licensing
|
||||
- Compile time compression
|
||||
- Sonic search
|
||||
- Popular color schemes, including Nord, Gruvbox, and Catppuccin
|
||||
- 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)
|
||||
|
|
22
README.md
22
README.md
|
@ -34,6 +34,9 @@
|
|||
- OCR image captioning
|
||||
- New and improved Groups
|
||||
- Better intro tutorial
|
||||
- Compatibility with Mastodon clients/apps
|
||||
- Backfill user information
|
||||
- Sonic search
|
||||
- Many more user and admin settings
|
||||
- [So much more!](./CALCKEY.md)
|
||||
|
||||
|
@ -78,8 +81,9 @@ If you have access to a server that supports one of the sources below, I recomme
|
|||
### 😗 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
|
||||
- Full text search (choost one of the following)
|
||||
- 🦔 [Sonic](https://crates.io/crates/sonic-server) (highly recommended!)
|
||||
- [ElasticSearch](https://www.elastic.co/elasticsearch/)
|
||||
- Management (choose one of the following)
|
||||
- 🛰️ [pm2](https://pm2.io/)
|
||||
- 🐳 [Docker](https://docker.com)
|
||||
|
@ -119,6 +123,17 @@ Assuming you set up PostgreSQL correctly, all you have to run is:
|
|||
psql postgres -c "create database calckey with encoding = 'UTF8';"
|
||||
```
|
||||
|
||||
In Calckey's directory, fill out the `db` section of `.config/default.yml` with the correct information, where the `db` key is `calckey`.
|
||||
|
||||
## 🦔 Set up search
|
||||
|
||||
Follow sonic's [installation guide](https://github.com/valeriansaliou/sonic#installation)
|
||||
|
||||
If you use IPv4: in Sonic's directory, edit the `config.cfg` file to change `inet` to `"0.0.0.0:1491"`.
|
||||
|
||||
In Calckey's directory, fill out the `sonic` section of `.config/default.yml` with the correct information.
|
||||
|
||||
|
||||
## 💅 Customize
|
||||
|
||||
- To add custom CSS for all users, edit `./custom/assets/instance.css`.
|
||||
|
@ -155,7 +170,8 @@ For migrating from Misskey v13, Misskey v12, and Foundkey, read [this document](
|
|||
|
||||
```sh
|
||||
# git pull
|
||||
NODE_ENV=production pnpm install && pnpm run build && pnpm run migrate
|
||||
pnpm install
|
||||
NODE_ENV=production pnpm run build && pnpm run migrate
|
||||
pm2 start "NODE_ENV=production pnpm run start" --name Calckey
|
||||
```
|
||||
|
||||
|
|
|
@ -835,7 +835,7 @@ muteThread: "Mute thread"
|
|||
unmuteThread: "Unmute thread"
|
||||
ffVisibility: "Follows/Followers Visibility"
|
||||
ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you."
|
||||
continueThread: "View thread continuation"
|
||||
continueThread: "Continue thread"
|
||||
deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
|
||||
incorrectPassword: "Incorrect password."
|
||||
voteConfirm: "Confirm your vote for \"{choice}\"?"
|
||||
|
@ -935,6 +935,7 @@ moveFromLabel: "Account you're moving from:"
|
|||
moveFromDescription: "This will set an alias of your old account so that you can move from that account to this current one. Do this BEFORE moving from your older account. Please enter the tag of the account formatted like @person@instance.com"
|
||||
migrationConfirm: "Are you absolutely sure you want to migrate your acccount to {account}? Once you do this, you won't be able to reverse it, and you won't be able to use your account normally again.\nAlso, please ensure that you've set this current account as the account you're moving from."
|
||||
defaultReaction: "Default emoji reaction for outgoing and incoming posts"
|
||||
license: "License"
|
||||
|
||||
_sensitiveMediaDetection:
|
||||
description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server."
|
||||
|
@ -1400,7 +1401,7 @@ _profile:
|
|||
metadataContent: "Content"
|
||||
changeAvatar: "Change avatar"
|
||||
changeBanner: "Change banner"
|
||||
locationDescription: "If entered properly, this will display your local time to other users."
|
||||
locationDescription: "If you enter your city, it will display your local time to other users."
|
||||
_exportOrImport:
|
||||
allNotes: "All posts"
|
||||
followingList: "Followed users"
|
||||
|
|
|
@ -935,6 +935,7 @@ moveFromLabel: "引っ越し元のアカウント:"
|
|||
moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com"
|
||||
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
|
||||
defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション"
|
||||
license: "ライセンス"
|
||||
|
||||
_sensitiveMediaDetection:
|
||||
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "calckey",
|
||||
"version": "13.2.0-beta",
|
||||
"version": "13.2.0-beta2",
|
||||
"codename": "aqua",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/calckey/calckey.git"
|
||||
},
|
||||
"packageManager": "pnpm@7.27.1",
|
||||
"packageManager": "pnpm@7.29.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export class addPropsForCustomEmoji1678945242650 {
|
||||
name = 'addPropsForCustomEmoji1678945242650'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "emoji" ADD "license" character varying(1024)`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`);
|
||||
}
|
||||
}
|
13
packages/backend/migration/1679269929000-fix-repo.js
Normal file
13
packages/backend/migration/1679269929000-fix-repo.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
export class FixRepo1679269929000 {
|
||||
name = 'FixRepo1679269929000'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/calckey/calckey'`);
|
||||
await queryRunner.query(`UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/calckey/calckey/issues'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/calckey/calckey'`);
|
||||
await queryRunner.query(`UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/calckey/calckey/issues'`);
|
||||
}
|
||||
}
|
|
@ -81,7 +81,7 @@
|
|||
"koa-send": "5.0.1",
|
||||
"koa-slow": "2.1.0",
|
||||
"koa-views": "7.0.2",
|
||||
"@calckey/megalodon": "5.1.2",
|
||||
"@calckey/megalodon": "5.1.21",
|
||||
"mfm-js": "0.23.2",
|
||||
"mime-types": "2.1.35",
|
||||
"multer": "1.4.4-lts.1",
|
||||
|
@ -112,6 +112,7 @@
|
|||
"seedrandom": "^3.0.5",
|
||||
"semver": "7.3.8",
|
||||
"sharp": "0.31.3",
|
||||
"sonic-channel": "^1.3.1",
|
||||
"speakeasy": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"summaly": "2.7.0",
|
||||
|
|
|
@ -32,6 +32,13 @@ export type Source = {
|
|||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
sonic: {
|
||||
host: string;
|
||||
port: number;
|
||||
auth?: string;
|
||||
collection?: string;
|
||||
bucket?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
|
|
51
packages/backend/src/db/sonic.ts
Normal file
51
packages/backend/src/db/sonic.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import * as SonicChannel from "sonic-channel";
|
||||
import { dbLogger } from "./logger.js";
|
||||
|
||||
import config from "@/config/index.js";
|
||||
|
||||
const logger = dbLogger.createSubLogger("sonic", "gray", false);
|
||||
|
||||
logger.info("Connecting to Sonic");
|
||||
|
||||
const handlers = (type: string): SonicChannel.Handlers => (
|
||||
{
|
||||
connected: () => {
|
||||
logger.succ(`Connected to Sonic ${type}`);
|
||||
},
|
||||
disconnected: (error) => {
|
||||
logger.warn(`Disconnected from Sonic ${type}, error: ${error}`);
|
||||
},
|
||||
error: (error) => {
|
||||
logger.warn(`Sonic ${type} error: ${error}`);
|
||||
},
|
||||
retrying: () => {
|
||||
logger.info(`Sonic ${type} retrying`);
|
||||
},
|
||||
timeout: () => {
|
||||
logger.warn(`Sonic ${type} timeout`);
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const hasConfig =
|
||||
config.sonic
|
||||
&& ( config.sonic.host
|
||||
|| config.sonic.port
|
||||
|| config.sonic.auth
|
||||
)
|
||||
|
||||
const host = hasConfig ? config.sonic.host ?? "localhost" : "";
|
||||
const port = hasConfig ? config.sonic.port ?? 1491 : 0;
|
||||
const auth = hasConfig ? config.sonic.auth ?? "SecretPassword" : "";
|
||||
const collection = hasConfig ? config.sonic.collection ?? "main" : "";
|
||||
const bucket = hasConfig ? config.sonic.bucket ?? "default" : "";
|
||||
|
||||
export default hasConfig
|
||||
? {
|
||||
search: new SonicChannel.Search({host, port, auth}).connect(handlers("search")),
|
||||
ingest: new SonicChannel.Ingest({host, port, auth}).connect(handlers("ingest")),
|
||||
|
||||
collection,
|
||||
bucket,
|
||||
}
|
||||
: null;
|
|
@ -55,4 +55,9 @@ export class Emoji {
|
|||
array: true, length: 128, default: '{}',
|
||||
})
|
||||
public aliases: string[];
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public license: string | null;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ export const EmojiRepository = db.getRepository(Emoji).extend({
|
|||
host: emoji.host,
|
||||
// || emoji.originalUrl してるのは後方互換性のため
|
||||
url: emoji.publicUrl || emoji.originalUrl,
|
||||
license: emoji.license,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
|
@ -12,7 +12,6 @@ import {
|
|||
Channels,
|
||||
} from "../index.js";
|
||||
import type { Packed } from "@/misc/schema.js";
|
||||
import { nyaize } from "@/misc/nyaize.js";
|
||||
import { awaitAll } from "@/prelude/await-all.js";
|
||||
import {
|
||||
convertLegacyReaction,
|
||||
|
@ -263,7 +262,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
: {}),
|
||||
});
|
||||
|
||||
if (packed.user.isCat && packed.text) {
|
||||
/* if (packed.user.isCat && packed.text) {
|
||||
const tokens = packed.text ? mfm.parse(packed.text) : [];
|
||||
mfm.inspect(tokens, (node) => {
|
||||
if (node.type === "text") {
|
||||
|
@ -272,7 +271,7 @@ export const NoteRepository = db.getRepository(Note).extend({
|
|||
}
|
||||
});
|
||||
packed.text = mfm.toString(tokens);
|
||||
}
|
||||
} */
|
||||
|
||||
return packed;
|
||||
},
|
||||
|
|
|
@ -40,5 +40,10 @@ export const packedEmojiSchema = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
license: {
|
||||
type: "string",
|
||||
optional: false,
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
@ -13,6 +13,7 @@ import processDb from "./processors/db/index.js";
|
|||
import processObjectStorage from "./processors/object-storage/index.js";
|
||||
import processSystemQueue from "./processors/system/index.js";
|
||||
import processWebhookDeliver from "./processors/webhook-deliver.js";
|
||||
import processBackground from "./processors/background/index.js";
|
||||
import { endedPollNotification } from "./processors/ended-poll-notification.js";
|
||||
import { queueLogger } from "./logger.js";
|
||||
import { getJobInfo } from "./get-job-info.js";
|
||||
|
@ -24,6 +25,7 @@ import {
|
|||
objectStorageQueue,
|
||||
endedPollNotificationQueue,
|
||||
webhookDeliverQueue,
|
||||
backgroundQueue,
|
||||
} from "./queues.js";
|
||||
import type { ThinUser } from "./types.js";
|
||||
|
||||
|
@ -418,6 +420,17 @@ export function createCleanRemoteFilesJob() {
|
|||
);
|
||||
}
|
||||
|
||||
export function createIndexAllNotesJob(data = {}) {
|
||||
return backgroundQueue.add(
|
||||
"indexAllNotes",
|
||||
data,
|
||||
{
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function webhookDeliver(
|
||||
webhook: Webhook,
|
||||
type: typeof webhookEventTypes[number],
|
||||
|
@ -454,6 +467,7 @@ export default function () {
|
|||
webhookDeliverQueue.process(64, processWebhookDeliver);
|
||||
processDb(dbQueue);
|
||||
processObjectStorage(objectStorageQueue);
|
||||
processBackground(backgroundQueue);
|
||||
|
||||
systemQueue.add(
|
||||
"tickCharts",
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
import type Bull from "bull";
|
||||
|
||||
import { queueLogger } from "../../logger.js";
|
||||
import { Notes } from "@/models/index.js";
|
||||
import { MoreThan } from "typeorm";
|
||||
import { index } from "@/services/note/create.js"
|
||||
import { Note } from "@/models/entities/note.js";
|
||||
|
||||
const logger = queueLogger.createSubLogger("index-all-notes");
|
||||
|
||||
export default async function indexAllNotes(
|
||||
job: Bull.Job<Record<string, unknown>>,
|
||||
done: ()=>void,
|
||||
): Promise<void> {
|
||||
logger.info("Indexing all notes...");
|
||||
|
||||
let cursor: string|null = job.data.cursor as string ?? null;
|
||||
let indexedCount: number = job.data.indexedCount as number ?? 0;
|
||||
let total: number = job.data.total as number ?? 0;
|
||||
|
||||
let running = true;
|
||||
const take = 50000;
|
||||
const batch = 100;
|
||||
while (running) {
|
||||
logger.info(`Querying for ${take} notes ${indexedCount}/${total ? total : '?'} at ${cursor}`);
|
||||
|
||||
let notes: Note[] = [];
|
||||
try {
|
||||
notes = await Notes.find({
|
||||
where: {
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: take,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error(`Failed to query notes ${e}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
job.progress(100);
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const count = await Notes.count();
|
||||
total = count;
|
||||
job.update({ indexedCount, cursor, total })
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
for (let i = 0; i < notes.length; i += batch) {
|
||||
const chunk = notes.slice(i, i + batch);
|
||||
await Promise.all(chunk.map(note => index(note)));
|
||||
|
||||
indexedCount += chunk.length;
|
||||
const pct = (indexedCount / total)*100;
|
||||
job.update({ indexedCount, cursor, total })
|
||||
job.progress(+(pct.toFixed(1)));
|
||||
logger.info(`Indexed notes ${indexedCount}/${total ? total : '?'}`);
|
||||
}
|
||||
cursor = notes[notes.length - 1].id;
|
||||
job.update({ indexedCount, cursor, total })
|
||||
|
||||
if (notes.length < take) {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
done();
|
||||
logger.info("All notes have been indexed.");
|
||||
}
|
15
packages/backend/src/queue/processors/background/index.ts
Normal file
15
packages/backend/src/queue/processors/background/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import type Bull from "bull";
|
||||
import indexAllNotes from "./index-all-notes.js";
|
||||
|
||||
const jobs = {
|
||||
indexAllNotes,
|
||||
} as Record<
|
||||
string,
|
||||
Bull.ProcessCallbackFunction<Record<string, unknown>>
|
||||
>;
|
||||
|
||||
export default function (q: Bull.Queue) {
|
||||
for (const [k, v] of Object.entries(jobs)) {
|
||||
q.process(k, 16, v);
|
||||
}
|
||||
}
|
|
@ -75,6 +75,7 @@ export async function importCustomEmojis(
|
|||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
license: emojiInfo.license,
|
||||
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>(
|
|||
"webhookDeliver",
|
||||
64,
|
||||
);
|
||||
export const backgroundQueue = initializeQueue<Record<string, unknown>>("bg");
|
||||
|
||||
export const queues = [
|
||||
systemQueue,
|
||||
|
@ -36,4 +37,5 @@ export const queues = [
|
|||
dbQueue,
|
||||
objectStorageQueue,
|
||||
webhookDeliverQueue,
|
||||
backgroundQueue,
|
||||
];
|
||||
|
|
|
@ -198,9 +198,36 @@ export async function createPerson(
|
|||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !url.startsWith("https://")) {
|
||||
throw new Error(`unexpected shcema of person url: ${url}`);
|
||||
throw new Error(`unexpected schema of person url: ${url}`);
|
||||
}
|
||||
|
||||
let followersCount: number | undefined;
|
||||
|
||||
if (typeof person.followers === "string") {
|
||||
try {
|
||||
let data = await fetch(person.followers, { headers: { "Accept": "application/json" } });
|
||||
let json_data = JSON.parse(await data.text());
|
||||
|
||||
followersCount = json_data.totalItems;
|
||||
} catch {
|
||||
followersCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let followingCount: number | undefined;
|
||||
|
||||
if (typeof person.following === "string") {
|
||||
try {
|
||||
let data = await fetch(person.following, { headers: { "Accept": "application/json" } });
|
||||
let json_data = JSON.parse(await data.text());
|
||||
|
||||
followingCount = json_data.totalItems;
|
||||
} catch (e) {
|
||||
followingCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create user
|
||||
let user: IRemoteUser;
|
||||
try {
|
||||
|
@ -228,6 +255,16 @@ export async function createPerson(
|
|||
followersUri: person.followers
|
||||
? getApId(person.followers)
|
||||
: undefined,
|
||||
followersCount: followersCount !== undefined
|
||||
? followersCount
|
||||
: person.followers && typeof person.followers !== "string" && isCollectionOrOrderedCollection(person.followers)
|
||||
? person.followers.totalItems
|
||||
: undefined,
|
||||
followingCount: followingCount !== undefined
|
||||
? followingCount
|
||||
: person.following && typeof person.following !== "string" && isCollectionOrOrderedCollection(person.following)
|
||||
? person.following.totalItems
|
||||
: undefined,
|
||||
featured: person.featured ? getApId(person.featured) : undefined,
|
||||
uri: person.id,
|
||||
tags,
|
||||
|
@ -396,7 +433,34 @@ export async function updatePerson(
|
|||
const url = getOneApHrefNullable(person.url);
|
||||
|
||||
if (url && !url.startsWith("https://")) {
|
||||
throw new Error(`unexpected shcema of person url: ${url}`);
|
||||
throw new Error(`unexpected schema of person url: ${url}`);
|
||||
}
|
||||
|
||||
let followersCount: number | undefined;
|
||||
|
||||
if (typeof person.followers === "string") {
|
||||
try {
|
||||
let data = await fetch(person.followers, { headers: { "Accept": "application/json" } } );
|
||||
let json_data = JSON.parse(await data.text());
|
||||
|
||||
followersCount = json_data.totalItems;
|
||||
} catch {
|
||||
followersCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
let followingCount: number | undefined;
|
||||
|
||||
if (typeof person.following === "string") {
|
||||
try {
|
||||
let data = await fetch(person.following, { headers: { "Accept": "application/json" } } );
|
||||
let json_data = JSON.parse(await data.text());
|
||||
|
||||
followingCount = json_data.totalItems;
|
||||
} catch {
|
||||
followingCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {
|
||||
|
@ -406,6 +470,16 @@ export async function updatePerson(
|
|||
person.sharedInbox ||
|
||||
(person.endpoints ? person.endpoints.sharedInbox : undefined),
|
||||
followersUri: person.followers ? getApId(person.followers) : undefined,
|
||||
followersCount: followersCount !== undefined
|
||||
? followersCount
|
||||
: person.followers && typeof person.followers !== "string" && isCollectionOrOrderedCollection(person.followers)
|
||||
? person.followers.totalItems
|
||||
: undefined,
|
||||
followingCount: followingCount !== undefined
|
||||
? followingCount
|
||||
: person.following && typeof person.following !== "string" && isCollectionOrOrderedCollection(person.following)
|
||||
? person.following.totalItems
|
||||
: undefined,
|
||||
featured: person.featured,
|
||||
emojis: emojiNames,
|
||||
name: truncate(person.name, nameLength),
|
||||
|
|
|
@ -29,6 +29,7 @@ import * as ep___admin_emoji_list from "./endpoints/admin/emoji/list.js";
|
|||
import * as ep___admin_emoji_removeAliasesBulk from "./endpoints/admin/emoji/remove-aliases-bulk.js";
|
||||
import * as ep___admin_emoji_setAliasesBulk from "./endpoints/admin/emoji/set-aliases-bulk.js";
|
||||
import * as ep___admin_emoji_setCategoryBulk from "./endpoints/admin/emoji/set-category-bulk.js";
|
||||
import * as ep___admin_emoji_setLicenseBulk from "./endpoints/admin/emoji/set-license-bulk.js";
|
||||
import * as ep___admin_emoji_update from "./endpoints/admin/emoji/update.js";
|
||||
import * as ep___admin_federation_deleteAllFiles from "./endpoints/admin/federation/delete-all-files.js";
|
||||
import * as ep___admin_federation_refreshRemoteInstanceMetadata from "./endpoints/admin/federation/refresh-remote-instance-metadata.js";
|
||||
|
@ -50,6 +51,7 @@ import * as ep___admin_relays_list from "./endpoints/admin/relays/list.js";
|
|||
import * as ep___admin_relays_remove from "./endpoints/admin/relays/remove.js";
|
||||
import * as ep___admin_resetPassword from "./endpoints/admin/reset-password.js";
|
||||
import * as ep___admin_resolveAbuseUserReport from "./endpoints/admin/resolve-abuse-user-report.js";
|
||||
import * as ep___admin_search_indexAll from "./endpoints/admin/search/index-all.js";
|
||||
import * as ep___admin_sendEmail from "./endpoints/admin/send-email.js";
|
||||
import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js";
|
||||
import * as ep___admin_showModerationLogs from "./endpoints/admin/show-moderation-logs.js";
|
||||
|
@ -131,6 +133,7 @@ import * as ep___drive_folders_show from "./endpoints/drive/folders/show.js";
|
|||
import * as ep___drive_folders_update from "./endpoints/drive/folders/update.js";
|
||||
import * as ep___drive_stream from "./endpoints/drive/stream.js";
|
||||
import * as ep___emailAddress_available from "./endpoints/email-address/available.js";
|
||||
import * as ep___emoji from "./endpoints/emoji.js";
|
||||
import * as ep___endpoint from "./endpoints/endpoint.js";
|
||||
import * as ep___endpoints from "./endpoints/endpoints.js";
|
||||
import * as ep___exportCustomEmojis from "./endpoints/export-custom-emojis.js";
|
||||
|
@ -363,6 +366,7 @@ const eps = [
|
|||
["admin/emoji/remove-aliases-bulk", ep___admin_emoji_removeAliasesBulk],
|
||||
["admin/emoji/set-aliases-bulk", ep___admin_emoji_setAliasesBulk],
|
||||
["admin/emoji/set-category-bulk", ep___admin_emoji_setCategoryBulk],
|
||||
["admin/emoji/set-license-bulk", ep___admin_emoji_setLicenseBulk],
|
||||
["admin/emoji/update", ep___admin_emoji_update],
|
||||
["admin/federation/delete-all-files", ep___admin_federation_deleteAllFiles],
|
||||
[
|
||||
|
@ -390,6 +394,7 @@ const eps = [
|
|||
["admin/relays/remove", ep___admin_relays_remove],
|
||||
["admin/reset-password", ep___admin_resetPassword],
|
||||
["admin/resolve-abuse-user-report", ep___admin_resolveAbuseUserReport],
|
||||
["admin/search/index-all", ep___admin_search_indexAll],
|
||||
["admin/send-email", ep___admin_sendEmail],
|
||||
["admin/server-info", ep___admin_serverInfo],
|
||||
["admin/show-moderation-logs", ep___admin_showModerationLogs],
|
||||
|
@ -471,6 +476,7 @@ const eps = [
|
|||
["drive/folders/update", ep___drive_folders_update],
|
||||
["drive/stream", ep___drive_stream],
|
||||
["email-address/available", ep___emailAddress_available],
|
||||
["emoji", ep___emoji],
|
||||
["endpoint", ep___endpoint],
|
||||
["endpoints", ep___endpoints],
|
||||
["export-custom-emojis", ep___exportCustomEmojis],
|
||||
|
|
|
@ -49,6 +49,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
originalUrl: file.url,
|
||||
publicUrl: file.webpublicUrl ?? file.url,
|
||||
type: file.webpublicType ?? file.type,
|
||||
license: null,
|
||||
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
|
|
|
@ -73,6 +73,7 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
originalUrl: driveFile.url,
|
||||
publicUrl: driveFile.webpublicUrl ?? driveFile.url,
|
||||
type: driveFile.webpublicType ?? driveFile.type,
|
||||
license: emoji.license,
|
||||
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
|
|
|
@ -55,6 +55,11 @@ export const meta = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
license: {
|
||||
type: "string",
|
||||
optional: false,
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -55,6 +55,11 @@ export const meta = {
|
|||
optional: false,
|
||||
nullable: false,
|
||||
},
|
||||
license: {
|
||||
type: "string",
|
||||
optional: false,
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import define from "../../../define.js";
|
||||
import { Emojis } from "@/models/index.js";
|
||||
import { In } from "typeorm";
|
||||
import { ApiError } from "../../../error.js";
|
||||
import { db } from "@/db/postgre.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ["admin"],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
ids: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "string",
|
||||
format: "misskey:id",
|
||||
},
|
||||
},
|
||||
license: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "Use `null` to reset the license.",
|
||||
},
|
||||
},
|
||||
required: ["ids"],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps) => {
|
||||
await Emojis.update(
|
||||
{
|
||||
id: In(ps.ids),
|
||||
},
|
||||
{
|
||||
updatedAt: new Date(),
|
||||
license: ps.license,
|
||||
},
|
||||
);
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
});
|
|
@ -34,6 +34,10 @@ export const paramDef = {
|
|||
type: "string",
|
||||
},
|
||||
},
|
||||
license: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
},
|
||||
},
|
||||
required: ["id", "name", "aliases"],
|
||||
} as const;
|
||||
|
@ -48,6 +52,7 @@ export default define(meta, paramDef, async (ps) => {
|
|||
name: ps.name,
|
||||
category: ps.category,
|
||||
aliases: ps.aliases,
|
||||
license: ps.license,
|
||||
});
|
||||
|
||||
await db.queryResultCache!.remove(["meta_emojis"]);
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
inboxQueue,
|
||||
dbQueue,
|
||||
objectStorageQueue,
|
||||
backgroundQueue,
|
||||
} from "@/queue/queues.js";
|
||||
import define from "../../../define.js";
|
||||
|
||||
|
@ -37,6 +38,11 @@ export const meta = {
|
|||
nullable: false,
|
||||
ref: "QueueCount",
|
||||
},
|
||||
backgroundQueue: {
|
||||
optional: false,
|
||||
nullable: false,
|
||||
ref: "QueueCount",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
@ -52,11 +58,13 @@ export default define(meta, paramDef, async (ps) => {
|
|||
const inboxJobCounts = await inboxQueue.getJobCounts();
|
||||
const dbJobCounts = await dbQueue.getJobCounts();
|
||||
const objectStorageJobCounts = await objectStorageQueue.getJobCounts();
|
||||
const backgroundJobCounts = await backgroundQueue.getJobCounts();
|
||||
|
||||
return {
|
||||
deliver: deliverJobCounts,
|
||||
inbox: inboxJobCounts,
|
||||
db: dbJobCounts,
|
||||
objectStorage: objectStorageJobCounts,
|
||||
backgroundQueue: backgroundJobCounts,
|
||||
};
|
||||
});
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import define from "../../../define.js";
|
||||
import { createIndexAllNotesJob } from "@/queue/index.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ["admin"],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
cursor: {
|
||||
type: "string",
|
||||
format: "misskey:id",
|
||||
nullable: true,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, _me) => {
|
||||
createIndexAllNotesJob({
|
||||
cursor: ps.cursor ?? undefined,
|
||||
});
|
||||
});
|
|
@ -54,7 +54,7 @@ export const paramDef = {
|
|||
folderId: { type: "string", format: "misskey:id", nullable: true },
|
||||
name: { type: "string" },
|
||||
isSensitive: { type: "boolean" },
|
||||
comment: { type: "string", nullable: true, maxLength: 512 },
|
||||
comment: { type: "string", nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH },
|
||||
},
|
||||
required: ["fileId"],
|
||||
} as const;
|
||||
|
|
38
packages/backend/src/server/api/endpoints/emoji.ts
Normal file
38
packages/backend/src/server/api/endpoints/emoji.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { IsNull } from "typeorm";
|
||||
import { Emojis } from "@/models/index.js";
|
||||
import define from "../define.js";
|
||||
|
||||
export const meta = {
|
||||
tags: ["meta"],
|
||||
|
||||
requireCredential: false,
|
||||
allowGet: true,
|
||||
cacheSec: 3600,
|
||||
|
||||
res: {
|
||||
type: "object",
|
||||
optional: false, nullable: false,
|
||||
ref: "Emoji",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, me) => {
|
||||
const emoji = await Emojis.findOneOrFail({
|
||||
where: {
|
||||
name: ps.name,
|
||||
host: IsNull(),
|
||||
},
|
||||
});
|
||||
|
||||
return Emojis.pack(emoji);
|
||||
});
|
|
@ -151,7 +151,7 @@ export default define(meta, paramDef, async (ps, user) => {
|
|||
}
|
||||
|
||||
// テキストが無いかつ添付ファイルも無かったらエラー
|
||||
if (ps.text == null && file == null) {
|
||||
if ((ps.text == null || ps.text.trim() === '') && file == null) {
|
||||
throw new ApiError(meta.errors.contentRequired);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { In } from "typeorm";
|
||||
import { Notes } from "@/models/index.js";
|
||||
import { Note } from "@/models/entities/note.js";
|
||||
import config from "@/config/index.js";
|
||||
import es from "../../../../db/elasticsearch.js";
|
||||
import sonic from "../../../../db/sonic.js";
|
||||
import define from "../../define.js";
|
||||
import { makePaginationQuery } from "../../common/make-pagination-query.js";
|
||||
import { generateVisibilityQuery } from "../../common/generate-visibility-query.js";
|
||||
|
@ -59,7 +61,7 @@ export const paramDef = {
|
|||
} as const;
|
||||
|
||||
export default define(meta, paramDef, async (ps, me) => {
|
||||
if (es == null) {
|
||||
if (es == null && sonic == null) {
|
||||
const query = makePaginationQuery(
|
||||
Notes.createQueryBuilder("note"),
|
||||
ps.sinceId,
|
||||
|
@ -92,9 +94,82 @@ export default define(meta, paramDef, async (ps, me) => {
|
|||
if (me) generateMutedUserQuery(query, me);
|
||||
if (me) generateBlockedUserQuery(query, me);
|
||||
|
||||
const notes = await query.take(ps.limit).getMany();
|
||||
const notes: Note[] = await query.take(ps.limit).getMany();
|
||||
|
||||
return await Notes.packMany(notes, me);
|
||||
} else if (sonic) {
|
||||
let start = 0;
|
||||
const chunkSize = 100;
|
||||
|
||||
// Use sonic to fetch and step through all search results that could match the requirements
|
||||
const ids = [];
|
||||
while (true) {
|
||||
const results = await sonic.search.query(
|
||||
sonic.collection,
|
||||
sonic.bucket,
|
||||
ps.query,
|
||||
{
|
||||
limit: chunkSize,
|
||||
offset: start,
|
||||
},
|
||||
);
|
||||
|
||||
start += chunkSize;
|
||||
|
||||
if (results.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
const res = results
|
||||
.map((k) => JSON.parse(k))
|
||||
.filter((key) => {
|
||||
if (ps.userId && key.userId !== ps.userId) {
|
||||
return false;
|
||||
}
|
||||
if (ps.channelId && key.channelId !== ps.channelId) {
|
||||
return false;
|
||||
}
|
||||
if (ps.sinceId && key.id <= ps.sinceId) {
|
||||
return false;
|
||||
}
|
||||
if (ps.untilId && key.id >= ps.untilId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((key) => key.id);
|
||||
|
||||
ids.push(...res);
|
||||
}
|
||||
|
||||
// Sort all the results by note id DESC (newest first)
|
||||
ids.sort((a, b) => b - a);
|
||||
|
||||
// Fetch the notes from the database until we have enough to satisfy the limit
|
||||
start = 0;
|
||||
const found = [];
|
||||
while (found.length < ps.limit && start < ids.length) {
|
||||
const chunk = ids.slice(start, start + chunkSize);
|
||||
const notes: Note[] = await Notes.find({
|
||||
where: {
|
||||
id: In(chunk),
|
||||
},
|
||||
order: {
|
||||
id: "DESC",
|
||||
},
|
||||
});
|
||||
|
||||
// The notes are checked for visibility and muted/blocked users when packed
|
||||
found.push(...await Notes.packMany(notes, me));
|
||||
start += chunkSize;
|
||||
}
|
||||
|
||||
// If we have more results than the limit, trim them
|
||||
if (found.length > ps.limit) {
|
||||
found.length = ps.limit;
|
||||
}
|
||||
|
||||
return found;
|
||||
} else {
|
||||
const userQuery =
|
||||
ps.userId != null
|
||||
|
|
|
@ -33,10 +33,10 @@ export function apiAccountMastodon(router: Router): void {
|
|||
let acct = data.data;
|
||||
acct.id = convertId(acct.id, IdType.MastodonId);
|
||||
acct.url = `${BASE_URL}/@${acct.url}`;
|
||||
acct.note = "";
|
||||
acct.note = acct.note || "";
|
||||
acct.avatar_static = acct.avatar;
|
||||
acct.header = acct.header || "";
|
||||
acct.header_static = acct.header || "";
|
||||
acct.header = acct.header || "https://http.cat/404";
|
||||
acct.header_static = acct.header || "https://http.cat/404";
|
||||
acct.source = {
|
||||
note: acct.note,
|
||||
fields: acct.fields,
|
||||
|
@ -339,7 +339,12 @@ export function apiAccountMastodon(router: Router): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const data = await client.getRelationships(ids);
|
||||
let reqIds = [];
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
reqIds.push(convertId(ids[i], IdType.CalckeyId));
|
||||
}
|
||||
|
||||
const data = await client.getRelationships(reqIds);
|
||||
let resp = data.data;
|
||||
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
|
||||
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
|
||||
|
@ -359,7 +364,7 @@ export function apiAccountMastodon(router: Router): void {
|
|||
const accessTokens = ctx.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = (await client.getBookmarks(ctx.query as any)) as any;
|
||||
const data = (await client.getBookmarks(limitToInt(ctx.query as any))) as any;
|
||||
let resp = data.data;
|
||||
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
|
||||
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
|
||||
|
@ -383,7 +388,7 @@ export function apiAccountMastodon(router: Router): void {
|
|||
const accessTokens = ctx.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.getFavourites(ctx.query as any);
|
||||
const data = await client.getFavourites(limitToInt(ctx.query as any));
|
||||
let resp = data.data;
|
||||
for (let statIdx = 0; statIdx < resp.length; statIdx++) {
|
||||
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId);
|
||||
|
@ -407,7 +412,7 @@ export function apiAccountMastodon(router: Router): void {
|
|||
const accessTokens = ctx.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.getMutes(ctx.query as any);
|
||||
const data = await client.getMutes(limitToInt(ctx.query as any));
|
||||
let resp = data.data;
|
||||
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
|
||||
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
|
||||
|
@ -425,7 +430,7 @@ export function apiAccountMastodon(router: Router): void {
|
|||
const accessTokens = ctx.headers.authorization;
|
||||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const data = await client.getBlocks(ctx.query as any);
|
||||
const data = await client.getBlocks(limitToInt(ctx.query as any));
|
||||
let resp = data.data;
|
||||
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
|
||||
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId);
|
||||
|
|
|
@ -4,6 +4,8 @@ import { emojiRegexAtStartToEnd } from "@/misc/emoji-regex.js";
|
|||
import axios from "axios";
|
||||
import querystring from 'node:querystring'
|
||||
import qs from 'qs'
|
||||
import { limitToInt } from "./timeline.js";
|
||||
|
||||
function normalizeQuery(data: any) {
|
||||
const str = querystring.stringify(data);
|
||||
return qs.parse(str);
|
||||
|
@ -101,9 +103,14 @@ export function apiStatusMastodon(router: Router): void {
|
|||
const client = getClient(BASE_URL, accessTokens);
|
||||
try {
|
||||
const id = ctx.params.id;
|
||||
const data = await client.getStatusContext(id, ctx.query as any);
|
||||
const data = await client.getStatusContext(id, limitToInt(ctx.query as any));
|
||||
const status = await client.getStatus(id);
|
||||
const reactionsAxios = await axios.get(
|
||||
let reqInstance = axios.create({
|
||||
headers: {
|
||||
Authorization : ctx.headers.authorization
|
||||
}
|
||||
});
|
||||
const reactionsAxios = await reqInstance.get(
|
||||
`${BASE_URL}/api/notes/reactions?noteId=${id}`,
|
||||
);
|
||||
const reactions: IReaction[] = reactionsAxios.data;
|
||||
|
|
|
@ -15,13 +15,16 @@ export function limitToInt(q: ParsedUrlQuery) {
|
|||
}
|
||||
|
||||
export function argsToBools(q: ParsedUrlQuery) {
|
||||
// Values taken from https://docs.joinmastodon.org/client/intro/#boolean
|
||||
const toBoolean = (value: string) => !['0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].includes(value);
|
||||
|
||||
let object: any = q;
|
||||
if (q.only_media)
|
||||
if (typeof q.only_media === "string")
|
||||
object.only_media = q.only_media.toLowerCase() === "true";
|
||||
object.only_media = toBoolean(q.only_media);
|
||||
if (q.exclude_replies)
|
||||
if (typeof q.exclude_replies === "string")
|
||||
object.exclude_replies = q.exclude_replies.toLowerCase() === "true";
|
||||
object.exclude_replies = toBoolean(q.exclude_replies);
|
||||
return q;
|
||||
}
|
||||
|
||||
|
@ -92,8 +95,8 @@ export function apiTimelineMastodon(router: Router): void {
|
|||
try {
|
||||
const query: any = ctx.query;
|
||||
const data = query.local
|
||||
? await client.getLocalTimeline(limitToInt(query))
|
||||
: await client.getPublicTimeline(limitToInt(query));
|
||||
? await client.getLocalTimeline(argsToBools(limitToInt(query)))
|
||||
: await client.getPublicTimeline(argsToBools(limitToInt(query)));
|
||||
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
|
@ -111,7 +114,7 @@ export function apiTimelineMastodon(router: Router): void {
|
|||
try {
|
||||
const data = await client.getTagTimeline(
|
||||
ctx.params.hashtag,
|
||||
limitToInt(ctx.query),
|
||||
argsToBools(limitToInt(ctx.query)),
|
||||
);
|
||||
ctx.body = toTextWithReaction(data.data, ctx.hostname);
|
||||
} catch (e: any) {
|
||||
|
|
|
@ -6,27 +6,38 @@ main {
|
|||
border-radius: 10px;
|
||||
}
|
||||
#tl > div {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #908caa;
|
||||
border: 1px solid #908caa;
|
||||
border-radius: 10px;
|
||||
margin: 10px;
|
||||
padding: 10px;
|
||||
width: fit-content;
|
||||
}
|
||||
#tl > div > header {
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
img {
|
||||
border-radius: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
#form {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#calckey_app {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
background-color: #191724;
|
||||
color: #e0def4;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
button {
|
||||
border-radius:999px;
|
||||
|
|
|
@ -45,12 +45,27 @@ window.onload = async () => {
|
|||
const tl = document.getElementById("tl");
|
||||
for (const note of notes) {
|
||||
const el = document.createElement("div");
|
||||
const name = document.createElement("header");
|
||||
const header = document.createElement("header");
|
||||
const name = document.createElement("p");
|
||||
const avatar = document.createElement("img")
|
||||
name.textContent = `${note.user.name} @${note.user.username}`;
|
||||
avatar.src = note.user.avatarUrl;
|
||||
avatar.style = 'height: 40px'
|
||||
const text = document.createElement("div");
|
||||
text.textContent = `${note.text}`;
|
||||
el.appendChild(name);
|
||||
el.appendChild(header);
|
||||
header.appendChild(avatar);
|
||||
header.appendChild(name);
|
||||
if (note.text) {
|
||||
el.appendChild(text);
|
||||
}
|
||||
if (note.files) {
|
||||
for (const file of note.files) {
|
||||
const img = document.createElement("img");
|
||||
img.src = file.properties.thumbnailUrl;
|
||||
el.appendChild(img)
|
||||
}
|
||||
}
|
||||
tl.appendChild(el);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import * as mfm from "mfm-js";
|
||||
import es from "../../db/elasticsearch.js";
|
||||
import sonic from "../../db/sonic.js";
|
||||
import {
|
||||
publishMainStream,
|
||||
publishNotesStream,
|
||||
|
@ -588,7 +589,7 @@ export default async (
|
|||
}
|
||||
|
||||
// Register to search database
|
||||
index(note);
|
||||
await index(note);
|
||||
});
|
||||
|
||||
async function renderNoteOrRenoteActivity(data: Option, note: Note) {
|
||||
|
@ -728,10 +729,11 @@ async function insertNote(
|
|||
}
|
||||
}
|
||||
|
||||
function index(note: Note) {
|
||||
if (note.text == null || config.elasticsearch == null) return;
|
||||
export async function index(note: Note): Promise<void> {
|
||||
if (!note.text) return;
|
||||
|
||||
es!.index({
|
||||
if (config.elasticsearch && es) {
|
||||
es.index({
|
||||
index: config.elasticsearch.index || "misskey_note",
|
||||
id: note.id.toString(),
|
||||
body: {
|
||||
|
@ -742,6 +744,21 @@ function index(note: Note) {
|
|||
});
|
||||
}
|
||||
|
||||
if (sonic) {
|
||||
await sonic.ingest.push(
|
||||
sonic.collection,
|
||||
sonic.bucket,
|
||||
JSON.stringify({
|
||||
id: note.id,
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
channelId: note.channelId,
|
||||
}),
|
||||
note.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyToWatchersOfRenotee(
|
||||
renote: Note,
|
||||
user: { id: User["id"] },
|
||||
|
|
|
@ -78,6 +78,7 @@
|
|||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vite": "^4.1.1",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vue": "3.2.45",
|
||||
"vue-isyourpasswordsafe": "^2.0.0",
|
||||
"vue-plyr": "^7.0.0",
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<button class="nrvgflfu _button" @click.stop.prevent="toggle">
|
||||
<button class="nrvgflfu _button" @click.stop="toggle">
|
||||
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
|
||||
<span v-if="!modelValue">{{ label }}</span>
|
||||
</button>
|
||||
|
@ -36,6 +36,8 @@ const toggle = () => {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.nrvgflfu {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
|
@ -44,6 +46,7 @@ const toggle = () => {
|
|||
padding: 6px 10px;
|
||||
width: 90%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--divider);
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
transition: background-color 0.25s ease-in-out;
|
||||
|
|
|
@ -14,9 +14,11 @@
|
|||
</div>
|
||||
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
|
||||
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div>
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
|
||||
<MkInput v-if="input && input.type !== 'paragraph'" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ph-password ph-bold ph-lg"></i></template>
|
||||
</MkInput>
|
||||
<MkTextarea v-if="input && input.type === 'paragraph'" v-model="inputValue" autofocus :type="paragraph" :placeholder="input.placeholder || undefined">
|
||||
</MkTextarea>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
|
@ -49,6 +51,7 @@ import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
|||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import MkSelect from '@/components/form/select.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
|
|
|
@ -144,13 +144,12 @@ onBeforeUnmount(() => {
|
|||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
background: var(--accentedBg);
|
||||
|
||||
&.full {
|
||||
padding: 0 8px 0 12px;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
|
||||
:title="url"
|
||||
:title="url" @click.stop
|
||||
>
|
||||
<slot></slot>
|
||||
<i v-if="target === '_blank'" class="ph-arrow-square-out ph-bold ph-lg icon"></i>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div class="hoawjimk">
|
||||
<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 ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop.prevent>
|
||||
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @click.stop>
|
||||
<template v-for="media in mediaList.filter(media => previewable(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"/>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }">
|
||||
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }" @click.stop>
|
||||
<img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
|
||||
<span class="main">
|
||||
<span class="username">@{{ username }}</span>
|
||||
<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
|
||||
</span>
|
||||
</MkA>
|
||||
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
|
||||
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }" @click.stop>
|
||||
<span class="main">
|
||||
<span class="username">@{{ username }}</span>
|
||||
<span class="host">@{{ toUnicode(host) }}</span>
|
||||
|
@ -42,8 +42,13 @@ const bgCss = bg.toRgbString();
|
|||
<style lang="scss" scoped>
|
||||
.akbvjaqn {
|
||||
display: inline-block;
|
||||
padding: 4px 8px 4px 4px;
|
||||
padding: 2px 8px 2px 2px;
|
||||
margin-block: 2px;
|
||||
border-radius: 999px;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: clip;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--mention);
|
||||
|
||||
&.isMe {
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
</template>
|
||||
</I18n>
|
||||
<div class="info">
|
||||
<button ref="renoteTime" class="_button time" @click="showRenoteMenu()">
|
||||
<button ref="renoteTime" class="_button time" @click.stop="showRenoteMenu()">
|
||||
<i v-if="isMyRenote" class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"></i>
|
||||
<MkTime :time="note.createdAt"/>
|
||||
</button>
|
||||
|
@ -33,19 +33,20 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article" @contextmenu.stop="onContextmenu" @click.self="router.push(notePage(appearNote))">
|
||||
<div class="main" @click.self="router.push(notePage(appearNote))">
|
||||
<article class="article" @contextmenu.stop="onContextmenu" @click="noteClick">
|
||||
<div class="main">
|
||||
<div class="header-container">
|
||||
<MkAvatar class="avatar" :user="appearNote.user"/>
|
||||
<XNoteHeader class="header" :note="appearNote" :mini="true"/>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p v-if="appearNote.cw != null" class="cw">
|
||||
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :custom-emojis="appearNote.emojis" :i="$i"/>
|
||||
<br/>
|
||||
<XCwButton v-model="showContent" :note="appearNote"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }">
|
||||
<div class="text" @click.self="router.push(notePage(appearNote))">
|
||||
<div class="text">
|
||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||
<!-- <a v-if="appearNote.renote != null" class="rp">RN:</a> -->
|
||||
<div v-if="translating || translation" class="translation">
|
||||
|
@ -61,22 +62,23 @@
|
|||
</div>
|
||||
<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"/>
|
||||
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div>
|
||||
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
|
||||
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div>
|
||||
<button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false">
|
||||
<span>{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
|
||||
<button v-else-if="isLong && !collapsed" class="showLess _button" @click.stop="collapsed = true">
|
||||
<span>{{ i18n.ts.showLess }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`" @click.stop><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<footer ref="el" class="footer" @click.stop>
|
||||
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
||||
<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 ph-bold ph-lg"></i></template>
|
||||
<template v-else><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i></template>
|
||||
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p>
|
||||
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
||||
<template v-if="appearNote.repliesCount > 0">
|
||||
<p class="count">{{ appearNote.repliesCount }}</p>
|
||||
</template>
|
||||
</button>
|
||||
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
|
||||
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/>
|
||||
|
@ -91,6 +93,7 @@
|
|||
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</footer>
|
||||
<!-- <MkNoteFooter :note="appearNote"></MkNoteFooter> -->
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
@ -113,15 +116,14 @@ import type * as misskey from 'calckey-js';
|
|||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import XNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import XNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import XMediaList from '@/components/MkMediaList.vue';
|
||||
import XCwButton from '@/components/MkCwButton.vue';
|
||||
import XPoll from '@/components/MkPoll.vue';
|
||||
import XStarButton from '@/components/MkStarButton.vue';
|
||||
import XRenoteButton from '@/components/MkRenoteButton.vue';
|
||||
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import XStarButton from '@/components/MkStarButton.vue';
|
||||
import XQuoteButton from '@/components/MkQuoteButton.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import MkVisibility from '@/components/MkVisibility.vue';
|
||||
import { pleaseLogin } from '@/scripts/please-login';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus';
|
||||
|
@ -187,7 +189,6 @@ const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
|
|||
const translation = ref(null);
|
||||
const translating = ref(false);
|
||||
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null;
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
|
@ -296,6 +297,14 @@ function focusAfter() {
|
|||
focusNext(el.value);
|
||||
}
|
||||
|
||||
function noteClick(e) {
|
||||
if (document.getSelection().type === 'Range') {
|
||||
e.stopPropagation();
|
||||
} else {
|
||||
router.push(notePage(appearNote))
|
||||
}
|
||||
}
|
||||
|
||||
function readPromo() {
|
||||
os.api('promo/read', {
|
||||
noteId: appearNote.id,
|
||||
|
@ -342,9 +351,13 @@ function readPromo() {
|
|||
}
|
||||
}
|
||||
|
||||
&:hover > .article > .main > .footer > .button {
|
||||
& > .article > .main {
|
||||
&:hover, &:focus-within {
|
||||
:deep(.footer .button) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .reply-to {
|
||||
& + .note-context {
|
||||
|
@ -352,9 +365,9 @@ function readPromo() {
|
|||
content: "";
|
||||
display: block;
|
||||
margin-bottom: -10px;
|
||||
width: 2px;
|
||||
background-color: var(--divider);
|
||||
margin-inline: auto;
|
||||
margin-top: 16px;
|
||||
border-left: 2px solid var(--divider);
|
||||
margin-left: calc((var(--avatarSize) / 2) - 1px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -477,7 +490,6 @@ function readPromo() {
|
|||
|
||||
> .body {
|
||||
margin-top: .7em;
|
||||
overflow: hidden;
|
||||
|
||||
> .cw {
|
||||
cursor: default;
|
||||
|
@ -585,6 +597,10 @@ function readPromo() {
|
|||
padding: 16px;
|
||||
border: solid 1px var(--renote);
|
||||
border-radius: 8px;
|
||||
transition: background .2s;
|
||||
&:hover, &:focus-within {
|
||||
background-color: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -594,10 +610,13 @@ function readPromo() {
|
|||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
> .footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
|
||||
|
||||
> .button {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
|
@ -606,6 +625,8 @@ function readPromo() {
|
|||
max-width: 3.5em;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
pointer-events: all;
|
||||
transition: opacity .2s;
|
||||
&:first-of-type {
|
||||
margin-left: -.5em;
|
||||
}
|
||||
|
@ -627,6 +648,7 @@ function readPromo() {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
> .reply {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
:tabindex="!isDeleted ? '-1' : null"
|
||||
:class="{ renote: isRenote }"
|
||||
>
|
||||
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note" @click.self="router.push(notePage(note))"/>
|
||||
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to" @click.self="router.push(notePage(appearNote))"/>
|
||||
<MkNoteSub v-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
|
||||
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
|
||||
<div v-if="isRenote" class="renote">
|
||||
<MkAvatar class="avatar" :user="note.user"/>
|
||||
<i class="ph-repeat ph-bold ph-lg"></i>
|
||||
|
@ -29,7 +29,7 @@
|
|||
<MkVisibility :note="note"/>
|
||||
</div>
|
||||
</div>
|
||||
<article class="article" @contextmenu.stop="onContextmenu">
|
||||
<article ref="noteEl" class="article" @contextmenu.stop="onContextmenu" tabindex="-1">
|
||||
<header class="header">
|
||||
<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
|
||||
<div class="body">
|
||||
|
@ -48,12 +48,13 @@
|
|||
</header>
|
||||
<div class="main">
|
||||
<div class="body">
|
||||
<p v-if="appearNote.cw != null" class="cw">
|
||||
<div v-if="appearNote.cw != null" class="cw">
|
||||
<Mfm v-if="appearNote.cw != ''" class="text" :text="appearNote.cw" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||
<br/>
|
||||
<XCwButton v-model="showContent" :note="appearNote"/>
|
||||
</p>
|
||||
</div>
|
||||
<div v-show="appearNote.cw == null || showContent" class="content">
|
||||
<div class="text" @click.self="router.push(notePage(appearNote))">
|
||||
<div class="text">
|
||||
<Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||
<div v-if="translating || translation" class="translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
|
@ -68,7 +69,7 @@
|
|||
</div>
|
||||
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" 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" @click.stop="router.push(notePage(appearNote.renote))"/></div>
|
||||
</div>
|
||||
<MkA v-if="appearNote.channel && !inChannel" class="channel" :to="`/channels/${appearNote.channel.id}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
|
||||
</div>
|
||||
|
@ -99,7 +100,7 @@
|
|||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
<MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies" @click.self="router.push(notePage(note))"/>
|
||||
<MkNoteSub v-for="note in directReplies" :key="note.id" :note="note" class="reply" :conversation="replies"/>
|
||||
</div>
|
||||
<div v-else class="_panel muted" @click="muted = false">
|
||||
<I18n :src="i18n.ts.userSaysSomething" tag="small">
|
||||
|
@ -113,7 +114,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { computed, inject, onMounted, onUnmounted, onUpdated, reactive, ref } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import type * as misskey from 'calckey-js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
|
@ -175,6 +176,7 @@ const isRenote = (
|
|||
);
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const noteEl = $ref();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
const starButton = ref<InstanceType<typeof XStarButton>>();
|
||||
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||
|
@ -192,6 +194,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
|
|||
const conversation = ref<misskey.entities.Note[]>([]);
|
||||
const replies = ref<misskey.entities.Note[]>([]);
|
||||
const directReplies = ref<misskey.entities.Note[]>([]);
|
||||
let isScrolling;
|
||||
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
|
@ -281,20 +285,20 @@ function showRenoteMenu(viaKeyboard = false): void {
|
|||
}
|
||||
|
||||
function focus() {
|
||||
el.value.focus();
|
||||
noteEl.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
el.value.blur();
|
||||
noteEl.blur();
|
||||
}
|
||||
|
||||
os.api('notes/children', {
|
||||
noteId: appearNote.id,
|
||||
limit: 30,
|
||||
depth: 6,
|
||||
depth: 12,
|
||||
}).then(res => {
|
||||
replies.value = res;
|
||||
directReplies.value = res.filter(note => note.replyId === appearNote.id || note.renoteId === appearNote.id);
|
||||
directReplies.value = res.filter(note => note.replyId === appearNote.id || note.renoteId === appearNote.id).reverse();
|
||||
});
|
||||
|
||||
if (appearNote.replyId) {
|
||||
|
@ -302,6 +306,7 @@ if (appearNote.replyId) {
|
|||
noteId: appearNote.replyId,
|
||||
}).then(res => {
|
||||
conversation.value = res.reverse();
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -322,20 +327,32 @@ function onNoteReplied(noteData: NoteUpdatedEvent): void {
|
|||
|
||||
}
|
||||
|
||||
document.addEventListener("wheel", () => {
|
||||
isScrolling = true;
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
stream.on("noteUpdated", onNoteReplied);
|
||||
isScrolling = false;
|
||||
noteEl.scrollIntoView();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
if (!isScrolling) {
|
||||
noteEl.scrollIntoView()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stream.off("noteUpdated", onNoteReplied);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lxwezrsl {
|
||||
position: relative;
|
||||
transition: box-shadow 0.1s ease;
|
||||
overflow: hidden;
|
||||
contain: content;
|
||||
|
||||
&:focus-visible {
|
||||
|
@ -429,8 +446,14 @@ onUnmounted(() => {
|
|||
|
||||
> .article {
|
||||
padding: 32px;
|
||||
padding-bottom: 6px;
|
||||
&:last-child {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
font-size: 1.2em;
|
||||
|
||||
overflow: clip;
|
||||
outline: none;
|
||||
scroll-margin-top: calc(var(--stickyTop) + 20vh);
|
||||
> .header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
@ -530,6 +553,10 @@ onUnmounted(() => {
|
|||
padding: 16px;
|
||||
border: solid 1px var(--renote);
|
||||
border-radius: 8px;
|
||||
transition: background .2s;
|
||||
&:hover, &:focus-within {
|
||||
background-color: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -577,19 +604,65 @@ onUnmounted(() => {
|
|||
> .reply {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
cursor: pointer;
|
||||
|
||||
padding-top: 24px;
|
||||
padding-bottom: 10px;
|
||||
@media (pointer: coarse) {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
> .reply, .reply-to, .reply-to-more {
|
||||
transition: background-color 0.25s ease-in-out;
|
||||
// Hover
|
||||
.reply :deep(.main), .reply-to, .reply-to-more, :deep(.more) {
|
||||
position: relative;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12px -24px;
|
||||
bottom: -0px;
|
||||
background: var(--panelHighlight);
|
||||
border-radius: var(--radius);
|
||||
opacity: 0;
|
||||
transition: opacity .2s;
|
||||
z-index: -1;
|
||||
}
|
||||
&.reply-to, &.reply-to-more {
|
||||
&::before {
|
||||
inset: 0px 8px;
|
||||
}
|
||||
&:first-of-type::before {
|
||||
top: 12px;
|
||||
}
|
||||
}
|
||||
// &::after {
|
||||
// content: "";
|
||||
// position: absolute;
|
||||
// inset: -9999px;
|
||||
// background: var(--modalBg);
|
||||
// opacity: 0;
|
||||
// z-index: -2;
|
||||
// pointer-events: none;
|
||||
// transition: opacity .2s;
|
||||
// }
|
||||
&.more::before {
|
||||
inset: 0 !important;
|
||||
}
|
||||
&:hover, &:focus-within {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
// @media (pointer: coarse) {
|
||||
// &:has(.button:focus-within) {
|
||||
// z-index: 2;
|
||||
// --X13: transparent;
|
||||
// &::after {
|
||||
// opacity: 1;
|
||||
// backdrop-filter: var(--modalBgFilter);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
font-size: 0.9em;
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<header class="kkwtjztg">
|
||||
<div class="user-info">
|
||||
<div>
|
||||
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)">
|
||||
<MkA v-user-preview="note.user.id" class="name" :to="userPage(note.user)" @click.stop>
|
||||
<MkUserName :user="note.user" class="mkusername">
|
||||
<span v-if="note.user.isBot" class="is-bot">bot</span>
|
||||
</MkUserName>
|
||||
|
@ -47,6 +47,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.kkwtjztg {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<div class="body">
|
||||
<p v-if="note.cw != null" class="cw">
|
||||
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
|
||||
<br/>
|
||||
<XCwButton v-model="showContent" :note="note"/>
|
||||
</p>
|
||||
<div v-show="note.cw == null || showContent" class="content">
|
||||
|
|
|
@ -1,28 +1,73 @@
|
|||
<template>
|
||||
<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }">
|
||||
<div class="main" @click="router.push(notePage(note))">
|
||||
<div ref="el"
|
||||
v-size="{ max: [450, 500] }"
|
||||
class="wrpstxzv"
|
||||
:class="{ children: depth > 1, singleStart: replies.length == 1, firstColumn: depth == 1 && conversation }"
|
||||
>
|
||||
<div v-if="conversation && depth > 1" class="line"></div>
|
||||
<div class="main" @click="noteClick">
|
||||
<div class="avatar-container">
|
||||
<MkAvatar class="avatar" :user="note.user"/>
|
||||
<div class="line"></div>
|
||||
<div v-if="(!conversation) || replies.length > 0" class="line"></div>
|
||||
</div>
|
||||
<div class="body">
|
||||
<XNoteHeader class="header" :note="note" :mini="true"/>
|
||||
<div class="body">
|
||||
<p v-if="note.cw != null" class="cw">
|
||||
<MkA v-if="note.replyId" :to="`/notes/${note.replyId}`" class="reply-icon" @click.stop>
|
||||
<i class="ph-arrow-bend-left-up ph-bold ph-lg"></i>
|
||||
</MkA>
|
||||
<MkA v-if="conversation && note.renoteId && note.renoteId != parentId" :to="`/notes/${note.renoteId}`" class="reply-icon" @click.stop>
|
||||
<i class="ph-quotes ph-bold ph-lg"></i>
|
||||
</MkA>
|
||||
<Mfm v-if="note.cw != ''" class="text" :text="note.cw" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
|
||||
<br/>
|
||||
<XCwButton v-model="showContent" :note="note"/>
|
||||
</p>
|
||||
<div v-show="note.cw == null || showContent" class="content" @click="router.push(notePage(note))">
|
||||
<MkSubNoteContent class="text" :note="note"/>
|
||||
<div v-show="note.cw == null || showContent" class="content">
|
||||
<MkSubNoteContent class="text" :note="note" :detailed="true" :parentId="note.parentId" :conversation="conversation"/>
|
||||
</div>
|
||||
<div v-if="translating || translation" class="translation">
|
||||
<MkLoading v-if="translating" mini/>
|
||||
<div v-else class="translated">
|
||||
<b>{{ i18n.t('translatedFrom', { x: translation.sourceLang }) }}: </b>
|
||||
<Mfm :text="translation.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer class="footer" @click.stop>
|
||||
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
|
||||
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
|
||||
<i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
|
||||
<template v-if="appearNote.repliesCount > 0">
|
||||
<p class="count">{{ appearNote.repliesCount }}</p>
|
||||
</template>
|
||||
</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" v-tooltip.noDelay.bottom="i18n.ts.reaction" class="button _button" @click="react()">
|
||||
<i class="ph-smiley ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<button v-if="appearNote.myReaction != null" ref="reactButton" class="button _button reacted" @click="undoReact(appearNote)">
|
||||
<i class="ph-minus ph-bold ph-lg"></i>
|
||||
</button>
|
||||
<XQuoteButton class="button" :note="appearNote"/>
|
||||
<button ref="menuButton" v-tooltip.noDelay.bottom="i18n.ts.more" class="button _button" @click="menu()">
|
||||
<i class="ph-dots-three-outline ph-bold ph-lg"></i>
|
||||
</button>
|
||||
</footer>
|
||||
<!-- <MkNoteFooter :note="note" :directReplies="replies.length"></MkNoteFooter> -->
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="conversation">
|
||||
<template v-if="depth < 5">
|
||||
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :conversation="conversation" :depth="depth + 1"/>
|
||||
<template v-if="replies.length == 1">
|
||||
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply single" :conversation="conversation" :depth="depth" :parentId="note.replyId"/>
|
||||
</template>
|
||||
<template v-else-if="depth < 5">
|
||||
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :conversation="conversation" :depth="depth + 1" :parentId="note.replyId"/>
|
||||
</template>
|
||||
<div v-else-if="replies.length > 0" class="more">
|
||||
<div class="line"></div>
|
||||
<MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -30,21 +75,33 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { inject, ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as misskey from 'calckey-js';
|
||||
import XNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkSubNoteContent from '@/components/MkSubNoteContent.vue';
|
||||
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
import XStarButton from '@/components/MkStarButton.vue';
|
||||
import XRenoteButton from '@/components/MkRenoteButton.vue';
|
||||
import XQuoteButton from '@/components/MkQuoteButton.vue';
|
||||
import XCwButton from '@/components/MkCwButton.vue';
|
||||
import { pleaseLogin } from '@/scripts/please-login';
|
||||
import { getNoteMenu } from '@/scripts/get-note-menu';
|
||||
import { notePage } from '@/filters/note';
|
||||
import { useRouter } from '@/router';
|
||||
import * as os from '@/os';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker';
|
||||
import { i18n } from '@/i18n';
|
||||
import { deepClone } from '@/scripts/clone';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture';
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
conversation?: misskey.entities.Note[];
|
||||
parentId?;
|
||||
|
||||
// how many notes are in between this one and the note being viewed in detail
|
||||
depth?: number;
|
||||
|
@ -52,17 +109,96 @@ const props = withDefaults(defineProps<{
|
|||
depth: 1,
|
||||
});
|
||||
|
||||
let note = $ref(deepClone(props.note));
|
||||
|
||||
const isRenote = (
|
||||
note.renote != null &&
|
||||
note.text == null &&
|
||||
note.fileIds.length === 0 &&
|
||||
note.poll == null
|
||||
);
|
||||
|
||||
const el = ref<HTMLElement>();
|
||||
const menuButton = ref<HTMLElement>();
|
||||
const starButton = ref<InstanceType<typeof XStarButton>>();
|
||||
const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
|
||||
const reactButton = ref<HTMLElement>();
|
||||
let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note : note);
|
||||
const isDeleted = ref(false);
|
||||
const translation = ref(null);
|
||||
const translating = ref(false);
|
||||
let showContent = $ref(false);
|
||||
const replies: misskey.entities.Note[] = props.conversation?.filter(item => item.replyId === props.note.id || item.renoteId === props.note.id) ?? [];
|
||||
const replies: misskey.entities.Note[] = props.conversation?.filter(item => item.replyId === props.note.id || item.renoteId === props.note.id).reverse() ?? [];
|
||||
|
||||
useNoteCapture({
|
||||
rootEl: el,
|
||||
note: $$(appearNote)
|
||||
});
|
||||
|
||||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
os.post({
|
||||
reply: appearNote,
|
||||
animation: !viaKeyboard,
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
blur();
|
||||
reactionPicker.show(reactButton.value, reaction => {
|
||||
os.api('notes/reactions/create', {
|
||||
noteId: appearNote.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
}, () => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function undoReact(note): void {
|
||||
const oldReaction = note.myReaction;
|
||||
if (!oldReaction) return;
|
||||
os.api('notes/reactions/delete', {
|
||||
noteId: note.id,
|
||||
});
|
||||
}
|
||||
|
||||
const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null);
|
||||
|
||||
|
||||
function menu(viaKeyboard = false): void {
|
||||
os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, {
|
||||
viaKeyboard,
|
||||
}).then(focus);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
el.value.focus();
|
||||
}
|
||||
|
||||
function blur() {
|
||||
el.value.blur();
|
||||
}
|
||||
|
||||
function noteClick(e) {
|
||||
if (document.getSelection().type === 'Range') {
|
||||
e.stopPropagation();
|
||||
} else {
|
||||
router.push(notePage(props.note))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wrpstxzv {
|
||||
padding: 16px 32px;
|
||||
|
||||
|
||||
&.children {
|
||||
padding: 10px 0 0 16px;
|
||||
padding: 10px 0 0 var(--indent);
|
||||
padding-left: var(--indent) !important;
|
||||
font-size: 1em;
|
||||
cursor: auto;
|
||||
|
||||
|
@ -71,6 +207,7 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
> .main {
|
||||
display: flex;
|
||||
|
||||
|
@ -89,6 +226,9 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
margin: 0 -200px;
|
||||
padding: 0 200px;
|
||||
overflow: clip;
|
||||
@media (pointer: coarse) {
|
||||
cursor: default;
|
||||
}
|
||||
|
@ -99,6 +239,17 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
}
|
||||
|
||||
> .body {
|
||||
.reply-icon {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: .2em .2em;
|
||||
margin-right: .2em;
|
||||
color: var(--accent);
|
||||
transition: background .2s;
|
||||
&:hover, &:focus {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
}
|
||||
> .cw {
|
||||
cursor: default;
|
||||
display: block;
|
||||
|
@ -110,24 +261,113 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
> .text {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
> .translation {
|
||||
border: solid 0.5px var(--divider);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
> .footer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
|
||||
|
||||
> .button {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
opacity: 0.7;
|
||||
flex-grow: 1;
|
||||
max-width: 3.5em;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
pointer-events: all;
|
||||
transition: opacity .2s;
|
||||
&:first-of-type {
|
||||
margin-left: -.5em;
|
||||
}
|
||||
&:hover {
|
||||
color: var(--fgHighlighted);
|
||||
}
|
||||
|
||||
> .count {
|
||||
display: inline;
|
||||
margin: 0 0 0 8px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.reacted {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:first-child > .main > .body {
|
||||
margin-top: -200px;
|
||||
padding-top: 200px;
|
||||
}
|
||||
&.reply {
|
||||
--avatarSize: 38px;
|
||||
.avatar-container {
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
}
|
||||
> .reply, > .more {
|
||||
border-left: solid 0.5px var(--divider);
|
||||
margin-top: 10px;
|
||||
&.single {
|
||||
padding: 0 !important;
|
||||
> .line {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .more {
|
||||
padding: 10px 0 0 16px;
|
||||
display: flex;
|
||||
padding-block: 10px;
|
||||
font-weight: 600;
|
||||
> .line {
|
||||
flex-grow: 0 !important;
|
||||
margin-top: -10px !important;
|
||||
margin-bottom: 10px !important;
|
||||
margin-right: 10px !important;
|
||||
&::before {
|
||||
border-left-style: dashed !important;
|
||||
border-bottom-left-radius: 100px !important;
|
||||
}
|
||||
}
|
||||
i {
|
||||
font-size: 1em !important;
|
||||
vertical-align: middle !important;
|
||||
}
|
||||
a {
|
||||
position: static;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
&::after {
|
||||
content: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.reply, &.reply-to, &.reply-to-more {
|
||||
> .main:hover, > .main:focus-within {
|
||||
:deep(.footer .button) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.reply-to, &.reply-to-more {
|
||||
|
@ -135,7 +375,16 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
&:first-child {
|
||||
padding-top: 30px;
|
||||
}
|
||||
.avatar-container {
|
||||
.line::before {
|
||||
margin-bottom: -16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Reply Lines
|
||||
&.reply, &.reply-to, &.reply-to-more {
|
||||
--indent: calc(var(--avatarSize) - 5px);
|
||||
> .main {
|
||||
> .avatar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@ -146,30 +395,90 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
|
|||
height: var(--avatarSize);
|
||||
margin: 0;
|
||||
}
|
||||
> .line {
|
||||
}
|
||||
}
|
||||
.line {
|
||||
position: relative;
|
||||
width: var(--avatarSize);
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
margin-bottom: -10px;
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 2px;
|
||||
background-color: var(--divider);
|
||||
margin-inline: auto;
|
||||
.note > & {
|
||||
margin-bottom: -16px;
|
||||
position: absolute;
|
||||
border-left: 2px solid var(--X13);
|
||||
margin-left: calc((var(--avatarSize) / 2) - 1px);
|
||||
width: calc(var(--indent) / 2);
|
||||
inset-block: 0;
|
||||
min-height: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.reply-to, &.reply-to-more {
|
||||
> .main > .avatar-container > .line {
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
> .main > .body {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
&.single, &.singleStart {
|
||||
> .main > .avatar-container > .line {
|
||||
margin-bottom: -10px !important;
|
||||
}
|
||||
}
|
||||
.reply.children:not(:last-child) { // Line that goes through multiple replies
|
||||
position: relative;
|
||||
> .line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
// Reply line connectors
|
||||
.reply.children:not(.single) {
|
||||
position: relative;
|
||||
> .line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-left: 2px solid var(--X13);
|
||||
border-bottom: 2px solid var(--X13);
|
||||
margin-left: calc((var(--avatarSize) / 2) - 1px);
|
||||
width: calc(var(--indent) / 2);
|
||||
height: calc((var(--avatarSize) / 2));
|
||||
border-bottom-left-radius: calc(var(--indent) / 2);
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
&:not(:last-child) > .line::after {
|
||||
mask: linear-gradient(to right, transparent 2px, black 2px);
|
||||
-webkit-mask: linear-gradient(to right, transparent 2px, black 2px);
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
:not(.reply) > & {
|
||||
.reply {
|
||||
--avatarSize: 24px;
|
||||
--indent: calc(var(--avatarSize) - 4px);
|
||||
}
|
||||
}
|
||||
&.firstColumn {
|
||||
> .main, > .line, > .children:not(.single) > .line {
|
||||
--avatarSize: 35px;
|
||||
--indent: 35px;
|
||||
}
|
||||
> .children:not(.single) {
|
||||
padding-left: 28px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.max-width_450px {
|
||||
padding: 14px 16px;
|
||||
&.reply-to, &.reply-to-more {
|
||||
padding: 14px 16px;
|
||||
padding-top: 14px !important;
|
||||
padding-bottom: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div class="tivcixzd" :class="{ done: closed || isVoted }">
|
||||
<ul>
|
||||
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click="vote(i)">
|
||||
<li v-for="(choice, i) in note.poll.choices" :key="i" :class="{ voted: choice.voted }" @click.stop="vote(i)">
|
||||
<div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
|
||||
<span>
|
||||
<template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg"></i></template>
|
||||
|
@ -13,7 +13,7 @@
|
|||
<p v-if="!readOnly">
|
||||
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
|
||||
<span> · </span>
|
||||
<a v-if="!closed && !isVoted" @click="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
|
||||
<a v-if="!closed && !isVoted" @click.stop="showResult = !showResult">{{ showResult ? i18n.ts._poll.vote : i18n.ts._poll.showResult }}</a>
|
||||
<span v-if="isVoted">{{ i18n.ts._poll.voted }}</span>
|
||||
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
|
||||
<span v-if="remaining > 0"> · {{ timer }}</span>
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
<span v-if="visibility === 'specified'"><i class="ph-envelope-simple-open ph-bold ph-lg"></i></span>
|
||||
</button>
|
||||
<button v-tooltip="i18n.ts.previewNoteText" class="_button preview" :class="{ active: showPreview }" @click="showPreview = !showPreview"><i class="ph-file-code ph-bold ph-lg"></i></button>
|
||||
<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ph-arrow-bend-up-left ph-bold ph-lg' : renote ? 'ph-quotes ph-bold ph-lg' : 'ph-paper-plane-tilt ph-bold ph-lg'"></i></button>
|
||||
<button class="submit _buttonGradate" :disabled="!canPost" data-cy-open-post-form-submit @click="post">{{ submitText }}<i :class="reply ? 'ph-arrow-u-up-left ph-bold ph-lg' : renote ? 'ph-quotes ph-bold ph-lg' : 'ph-paper-plane-tilt ph-bold ph-lg'"></i></button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="form" :class="{ fixed }">
|
||||
|
@ -91,6 +91,7 @@ import { instance } from '@/instance';
|
|||
import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
|
||||
import { uploadFile } from '@/scripts/upload';
|
||||
import { deepClone } from '@/scripts/clone';
|
||||
import { nyaize } from '@/scripts/nyaize';
|
||||
import XCheatSheet from '@/components/MkCheatSheetDialog.vue';
|
||||
|
||||
const modal = inject('modal');
|
||||
|
@ -582,6 +583,10 @@ async function post() {
|
|||
}
|
||||
}
|
||||
|
||||
if ($i?.isCat) {
|
||||
postData.text = nyaize(`${postData.text}`);
|
||||
}
|
||||
|
||||
let token = undefined;
|
||||
|
||||
if (postAccount) {
|
||||
|
@ -796,6 +801,8 @@ onMounted(() => {
|
|||
}
|
||||
|
||||
> .submit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 16px 16px 16px 0;
|
||||
padding: 0 12px;
|
||||
line-height: 34px;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
ref="buttonRef"
|
||||
v-ripple="canToggle"
|
||||
class="hkzvhatu _button"
|
||||
:class="{ reacted: note.myReaction == reaction, canToggle }"
|
||||
:class="{ reacted: note.myReaction == reaction, canToggle, newlyAdded: !isInitial }"
|
||||
@click="toggleReaction()"
|
||||
>
|
||||
<XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/>
|
||||
|
@ -13,7 +13,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as misskey from 'calckey-js';
|
||||
import XDetails from '@/components/MkReactionsViewer.details.vue';
|
||||
import XReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
|
@ -55,20 +55,6 @@ const toggleReaction = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const anime = () => {
|
||||
if (document.hidden) return;
|
||||
|
||||
// TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション
|
||||
};
|
||||
|
||||
watch(() => props.count, (newCount, oldCount) => {
|
||||
if (oldCount < newCount) anime();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.isInitial) anime();
|
||||
});
|
||||
|
||||
useTooltip(buttonRef, async (showing) => {
|
||||
const reactions = await os.apiGet('notes/reactions', {
|
||||
noteId: props.note.id,
|
||||
|
@ -97,7 +83,25 @@ useTooltip(buttonRef, async (showing) => {
|
|||
margin: 2px;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
pointer-events: all;
|
||||
&.newlyAdded {
|
||||
animation: scaleInSmall .3s cubic-bezier(0,0,0,1.2);
|
||||
:deep(.mk-emoji) {
|
||||
animation: scaleIn .4s cubic-bezier(0.7, 0, 0, 1.5);
|
||||
}
|
||||
}
|
||||
:deep(.mk-emoji) {
|
||||
transition: transform .4s cubic-bezier(0,0,0,6);
|
||||
}
|
||||
&.reacted :deep(.mk-emoji) {
|
||||
transition: transform .4s cubic-bezier(0,0,0,1);
|
||||
}
|
||||
&:active {
|
||||
:deep(.mk-emoji) {
|
||||
transition: transform .4s cubic-bezier(0,0,0,1);
|
||||
transform: scale(.85);
|
||||
}
|
||||
}
|
||||
&.canToggle {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
|
||||
|
@ -119,6 +123,7 @@ useTooltip(buttonRef, async (showing) => {
|
|||
|
||||
> .count {
|
||||
color: var(--fgOnAccent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
|
|
|
@ -21,7 +21,8 @@ const isMe = computed(() => $i && $i.id === props.note.userId);
|
|||
|
||||
<style lang="scss" scoped>
|
||||
.tdflqwzn {
|
||||
margin: 4px -2px 0 -2px;
|
||||
margin-inline: -2px;
|
||||
margin-top: .2em;
|
||||
width: 100%;
|
||||
|
||||
&:empty {
|
||||
|
|
|
@ -2,22 +2,34 @@
|
|||
<div class="wrmlmaau" :class="{ collapsed, isLong }">
|
||||
<div class="body">
|
||||
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span>
|
||||
<MkA v-if="note.replyId" class="reply" :to="`/notes/${note.replyId}`"><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i></MkA>
|
||||
<template v-if="!note.cw">
|
||||
<MkA v-if="note.replyId" :to="`/notes/${note.replyId}`" class="reply-icon" @click.stop>
|
||||
<i class="ph-arrow-bend-left-up ph-bold ph-lg"></i>
|
||||
</MkA>
|
||||
<MkA v-if="conversation && note.renoteId && note.renoteId != parentId" :to="`/notes/${note.renoteId}`" class="reply-icon" @click.stop>
|
||||
<i class="ph-quotes ph-bold ph-lg"></i>
|
||||
</MkA>
|
||||
</template>
|
||||
<Mfm v-if="note.text" :text="note.text" :author="note.user" :i="$i" :custom-emojis="note.emojis"/>
|
||||
<MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">{{ i18n.ts.quoteAttached }}: ...</MkA>
|
||||
</div>
|
||||
<div v-if="note.files.length > 0">
|
||||
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
|
||||
<XMediaList :media-list="note.files"/>
|
||||
</div>
|
||||
<div v-if="note.poll">
|
||||
<summary>{{ i18n.ts.poll }}</summary>
|
||||
<XPoll :note="note"/>
|
||||
</div>
|
||||
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false">
|
||||
<template v-if="detailed">
|
||||
<!-- <div v-if="note.renoteId" class="renote">
|
||||
<XNoteSimple :note="note.renote"/>
|
||||
</div> -->
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
|
||||
</template>
|
||||
<button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false">
|
||||
<span>{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
<button v-if="isLong && !collapsed" class="showLess _button" @click.stop.prevent="collapsed = true">
|
||||
<button v-if="isLong && !collapsed" class="showLess _button" @click.stop="collapsed = true">
|
||||
<span>{{ i18n.ts.showLess }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
@ -26,15 +38,21 @@
|
|||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as misskey from 'calckey-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import XNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import XMediaList from '@/components/MkMediaList.vue';
|
||||
import XPoll from '@/components/MkPoll.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
parentId?;
|
||||
conversation?;
|
||||
detailed?: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
const isLong = (
|
||||
props.note.cw == null && props.note.text != null && (
|
||||
(props.note.text.split('\n').length > 9) ||
|
||||
|
@ -42,6 +60,8 @@ const isLong = (
|
|||
)
|
||||
);
|
||||
const collapsed = $ref(props.note.cw == null && isLong);
|
||||
const urls = props.note.text ? extractUrlFromMfm(mfm.parse(props.note.text)) : null;
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -49,16 +69,26 @@ const collapsed = $ref(props.note.cw == null && isLong);
|
|||
overflow-wrap: break-word;
|
||||
|
||||
> .body {
|
||||
> .reply {
|
||||
margin-right: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> .rp {
|
||||
margin-left: 4px;
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
.reply-icon {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: .2em .2em;
|
||||
margin-right: .2em;
|
||||
color: var(--accent);
|
||||
transition: background .2s;
|
||||
&:hover, &:focus {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .mk-url-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.collapsed {
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
|
||||
<div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`" @click.stop>
|
||||
<button class="disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ph-x ph-bold ph-lg"></i></button>
|
||||
<iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
|
||||
</div>
|
||||
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter">
|
||||
<div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter" @click.stop>
|
||||
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe>
|
||||
</div>
|
||||
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview">
|
||||
<div v-else v-size="{ max: [400, 350] }" class="mk-url-preview" @click.stop>
|
||||
<transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
|
||||
<component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
||||
<div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`">
|
||||
|
@ -214,9 +214,10 @@ onUnmounted(() => {
|
|||
border: 1px solid var(--divider);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transition: background .2s;
|
||||
&:hover, &:focus-within {
|
||||
text-decoration: none;
|
||||
background-color: var(--panelHighlight);
|
||||
> article > header > h1 {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu">
|
||||
<a :href="to" :class="active ? activeClass : null" @click.prevent="nav" @contextmenu.prevent.stop="onContextmenu" @click.stop>
|
||||
<slot></slot>
|
||||
</a>
|
||||
</template>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<img class="inner" :src="url" decoding="async"/>
|
||||
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
|
||||
</span>
|
||||
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target">
|
||||
<MkA v-else v-user-preview="disablePreview ? undefined : user.id" class="eiwwqkts _noSelect" :class="{ cat: user.isCat, square: $store.state.squareAvatars }" :style="{ color }" :to="userPage(user)" :title="acct(user)" :target="target" @click.stop>
|
||||
<img class="inner" :src="url" decoding="async"/>
|
||||
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
|
||||
</MkA>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<component
|
||||
:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
|
||||
@contextmenu.stop="() => {}"
|
||||
@contextmenu.stop="() => {}" @click.stop
|
||||
>
|
||||
<template v-if="!self">
|
||||
<span class="schema">{{ schema }}//</span>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<Mfm :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
|
||||
<Mfm :class="$style.root" :text="user.name || user.username" :plain="true" :nowrap="nowrap" :custom-emojis="user.emojis"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -13,3 +13,9 @@ const props = withDefaults(defineProps<{
|
|||
nowrap: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -547,7 +547,7 @@
|
|||
{ "category": "animals_and_nature", "char": "🦤", "name": "dodo", "keywords": ["animal", "nature"] },
|
||||
{ "category": "animals_and_nature", "char": "🪶", "name": "feather", "keywords": ["animal", "nature"] },
|
||||
{ "category": "animals_and_nature", "char": "🦭", "name": "seal", "keywords": ["animal", "nature"] },
|
||||
{ "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet"] },
|
||||
{ "category": "animals_and_nature", "char": "🐾", "name": "paw_prints", "keywords": ["animal", "tracking", "footprints", "dog", "cat", "pet", "feet", "paws", "kitty"] },
|
||||
{ "category": "animals_and_nature", "char": "🐉", "name": "dragon", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
|
||||
{ "category": "animals_and_nature", "char": "🐲", "name": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
|
||||
{ "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] },
|
||||
|
|
|
@ -359,6 +359,40 @@ export function inputText(props: {
|
|||
});
|
||||
}
|
||||
|
||||
export function inputParagraph(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
placeholder?: string | null;
|
||||
default?: string | null;
|
||||
}): Promise<
|
||||
| { canceled: true; result: undefined }
|
||||
| {
|
||||
canceled: false;
|
||||
result: string;
|
||||
}
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(
|
||||
defineAsyncComponent(() => import("@/components/MkDialog.vue")),
|
||||
{
|
||||
title: props.title,
|
||||
text: props.text,
|
||||
input: {
|
||||
type: "paragraph",
|
||||
placeholder: props.placeholder,
|
||||
default: props.default,
|
||||
},
|
||||
},
|
||||
{
|
||||
done: (result) => {
|
||||
resolve(result ? result : { canceled: true });
|
||||
},
|
||||
},
|
||||
"closed",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function inputNumber(props: {
|
||||
title?: string | null;
|
||||
text?: string | null;
|
||||
|
|
|
@ -22,6 +22,9 @@
|
|||
<template #label>{{ i18n.ts.tags }}</template>
|
||||
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-model="license" class="_formBlock">
|
||||
<template #label>{{ i18n.ts.license }}</template>
|
||||
</MkTextarea>
|
||||
<MkButton danger @click="del()"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -33,6 +36,7 @@ import { } from 'vue';
|
|||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import * as os from '@/os';
|
||||
import { unique } from '@/scripts/array';
|
||||
import { i18n } from '@/i18n';
|
||||
|
@ -47,6 +51,7 @@ let name: string = $ref(props.emoji.name);
|
|||
let category: string = $ref(props.emoji.category);
|
||||
let aliases: string = $ref(props.emoji.aliases.join(' '));
|
||||
let categories: string[] = $ref(emojiCategories);
|
||||
let license: string = $ref(props.emoji.license ?? '');
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { deleted?: boolean, updated?: any }): void,
|
||||
|
@ -63,6 +68,7 @@ async function update() {
|
|||
name,
|
||||
category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
});
|
||||
|
||||
emit('done', {
|
||||
|
@ -71,6 +77,7 @@ async function update() {
|
|||
name,
|
||||
category,
|
||||
aliases: aliases.split(' '),
|
||||
license: license === '' ? null : license,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@
|
|||
<MkButton inline @click="addTagBulk">Add tag</MkButton>
|
||||
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
|
||||
<MkButton inline @click="setTagBulk">Set tag</MkButton>
|
||||
<MkButton inline @click="setLicenseBulk">Set license</MkButton>
|
||||
<MkButton inline danger @click="delBulk">Delete</MkButton>
|
||||
</div>
|
||||
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
|
||||
|
@ -258,6 +259,18 @@ const setTagBulk = async () => {
|
|||
emojisPaginationComponent.value.reload();
|
||||
};
|
||||
|
||||
const setLicenseBulk = async () => {
|
||||
const { canceled, result } = await os.inputParagraph({
|
||||
title: 'License',
|
||||
});
|
||||
if (canceled) return;
|
||||
await os.apiWithDialog('admin/emoji/set-license-bulk', {
|
||||
ids: selectedEmojis.value,
|
||||
license: result,
|
||||
});
|
||||
emojisPaginationComponent.value.reload();
|
||||
};
|
||||
|
||||
const delBulk = async () => {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
|
||||
<MkInfo v-if="updateAvailable" warn class="info">{{ i18n.ts.updateAvailable }} <a href="https://codeberg.org/calckey/calckey/releases" target="_bank" class="_link">{{ i18n.ts.check }}</a></MkInfo>
|
||||
|
||||
<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
|
||||
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
|
@ -219,6 +219,12 @@ onUnmounted(() => {
|
|||
ro.disconnect();
|
||||
});
|
||||
|
||||
watch(router.currentRef, (to) => {
|
||||
if (to.route.path === "/admin" && to.child?.route.name == null && !narrow) {
|
||||
router.replace('/admin/overview');
|
||||
}
|
||||
});
|
||||
|
||||
provideMetadataReceiver((info) => {
|
||||
if (info == null) {
|
||||
childInfo = null;
|
||||
|
|
|
@ -51,13 +51,6 @@ const props = defineProps<{
|
|||
|
||||
let channel = $ref(null);
|
||||
let showBanner = $ref(true);
|
||||
const pagination = {
|
||||
endpoint: 'channels/timeline' as const,
|
||||
limit: 10,
|
||||
params: computed(() => ({
|
||||
channelId: props.channelId,
|
||||
})),
|
||||
};
|
||||
|
||||
watch(() => props.channelId, async () => {
|
||||
channel = await os.api('channels/show', {
|
||||
|
@ -66,14 +59,23 @@ watch(() => props.channelId, async () => {
|
|||
}, { immediate: true });
|
||||
|
||||
function edit() {
|
||||
router.push(`/channels/${channel.id}/edit`);
|
||||
router.push(`/channels/${channel?.id}/edit`);
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => channel && channel.userId ? [{
|
||||
const headerActions = $computed(() => [
|
||||
...(
|
||||
channel
|
||||
&& channel?.userId === $i?.id
|
||||
? [
|
||||
{
|
||||
icon: 'ph-gear-six ph-bold ph-lg',
|
||||
text: i18n.ts.edit,
|
||||
handler: edit,
|
||||
}] : null);
|
||||
}
|
||||
]
|
||||
: []
|
||||
),
|
||||
]);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<img :src="emoji.url" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
<div class="name _monospace">{{ emoji.name }}</div>
|
||||
<div class="info">{{ emoji.aliases.join(' ') }}</div>
|
||||
<div class="info">{{ emoji.aliases.join(" ") }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
@ -20,15 +20,26 @@ const props = defineProps<{
|
|||
|
||||
function menu(ev) {
|
||||
os.popupMenu([{
|
||||
type: 'label',
|
||||
text: ':' + props.emoji.name + ':',
|
||||
type: "label",
|
||||
text: ":" + props.emoji.name + ":",
|
||||
}, {
|
||||
text: i18n.ts.copy,
|
||||
icon: 'ph-clipboard-text ph-bold ph-lg',
|
||||
icon: "ph-clipboard-text ph-bold ph-lg",
|
||||
action: () => {
|
||||
copyToClipboard(`:${props.emoji.name}:`);
|
||||
os.success();
|
||||
}
|
||||
},
|
||||
}, {
|
||||
text: i18n.ts.license,
|
||||
icon: "ph-info ph-bold ph-lg",
|
||||
action: () => {
|
||||
os.apiGet("emoji", { name: props.emoji.name }).then(res => {
|
||||
os.alert({
|
||||
type: "info",
|
||||
text: `${res.license || i18n.ts.notSet}`,
|
||||
});
|
||||
});
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<button class="_button" @click="chooseFile"><i class="ph-upload ph-bold ph-lg"></i></button>
|
||||
<button class="_button" @click="insertEmoji"><i class="ph-smiley ph-bold ph-lg"></i></button>
|
||||
<button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send">
|
||||
<template v-if="!sending"><i class="ph-paper-plane-tilt-bold ph-lg"></i></template><template v-if="sending"><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i></template>
|
||||
<template v-if="!sending"><i class="ph-paper-plane-tilt ph-bold ph-lg"></i></template><template v-if="sending"><i class="ph-circle-notch ph-bold ph-lg fa-pulse ph-fw ph-lg"></i></template>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
@ -57,7 +57,7 @@ const typing = throttle(3000, () => {
|
|||
});
|
||||
|
||||
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id);
|
||||
let canSend = $computed(() => (text != null && text !== '') || file != null);
|
||||
let canSend = $computed(() => (text != null && text.trim() !== '') || file != null);
|
||||
|
||||
watch([$$(text), $$(file)], saveDraft);
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<MkSpacer :content-max="800" :marginMin="6">
|
||||
<div class="fcuexfpr">
|
||||
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="note" class="note">
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
|
||||
<div class="baaadecd">
|
||||
<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
|
||||
<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
|
||||
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
|
||||
|
@ -230,6 +230,12 @@ onUnmounted(() => {
|
|||
ro.disconnect();
|
||||
});
|
||||
|
||||
watch(router.currentRef, (to) => {
|
||||
if (to.route.name === "settings" && to.child?.route.name == null && !narrow) {
|
||||
router.replace('/settings/profile');
|
||||
}
|
||||
});
|
||||
|
||||
const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
|
||||
|
||||
provideMetadataReceiver((info) => {
|
||||
|
|
|
@ -167,8 +167,8 @@ const timeForThem = $computed(() => {
|
|||
const tzInfo = cityTimezones.lookupViaCity(props.user.location!.replace(/\s.*/,''));
|
||||
if (tzInfo.length == 0) return "";
|
||||
const tz = tzInfo[0].timezone;
|
||||
const theirTime = new Date().toLocaleString("en-US", { timeZone: tz, hour12: true })
|
||||
return ` (${theirTime.split(",")[1].trim().split(":")[0]} ${theirTime.split(" ")[1].slice(-2)})`
|
||||
const theirTime = new Date().toLocaleString("en-US", { timeZone: tz, hour12: false });
|
||||
return ` (${theirTime.split(",")[1].trim().split(":")[0]}:${theirTime.split(" ")[1].slice(-5,-3)})`;
|
||||
})
|
||||
|
||||
function menu(ev) {
|
||||
|
|
|
@ -24,6 +24,8 @@ export const getBuiltinThemes = () =>
|
|||
[
|
||||
"l-rosepinedawn",
|
||||
"l-light",
|
||||
"l-nord",
|
||||
"l-gruvbox",
|
||||
"l-coffee",
|
||||
"l-apricot",
|
||||
"l-rainy",
|
||||
|
@ -35,6 +37,10 @@ export const getBuiltinThemes = () =>
|
|||
"d-rosepine",
|
||||
"d-rosepinemoon",
|
||||
"d-dark",
|
||||
"d-nord",
|
||||
"d-gruvbox",
|
||||
"d-catppuccin-frappe",
|
||||
"d-catppuccin-mocha",
|
||||
"d-persimmon",
|
||||
"d-astro",
|
||||
"d-future",
|
||||
|
|
|
@ -32,7 +32,7 @@ html {
|
|||
overflow-wrap: break-word;
|
||||
font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
line-height: 1.6;
|
||||
text-size-adjust: 100%;
|
||||
tab-size: 2;
|
||||
|
||||
|
@ -88,7 +88,6 @@ html._themeChanging_ {
|
|||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -155,6 +154,10 @@ hr {
|
|||
box-shadow: 0px 4px 32px var(--shadow) !important;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
overflow: clip !important;
|
||||
}
|
||||
|
||||
._button {
|
||||
appearance: none;
|
||||
display: inline-block;
|
||||
|
@ -479,6 +482,7 @@ hr {
|
|||
}
|
||||
|
||||
._link {
|
||||
position: relative;
|
||||
color: var(--link);
|
||||
|
||||
&:after {
|
||||
|
@ -680,3 +684,19 @@ hr {
|
|||
width: 1.25em;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
|
||||
@media(prefers-reduced-motion: no-preference) {
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes scaleInSmall {
|
||||
from {
|
||||
transform: scale(.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
94
packages/client/src/themes/d-catppuccin-frappe.json5
Normal file
94
packages/client/src/themes/d-catppuccin-frappe.json5
Normal file
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
id: 'ffcd3328-5c57-4ca3-9dac-4580cbf7742f',
|
||||
base: 'dark',
|
||||
name: 'Catppuccin frappe',
|
||||
props: {
|
||||
X2: ':darken<2<@panel',
|
||||
X3: 'rgba(255, 255, 255, 0.05)',
|
||||
X4: 'rgba(255, 255, 255, 0.1)',
|
||||
X5: 'rgba(255, 255, 255, 0.05)',
|
||||
X6: 'rgba(255, 255, 255, 0.15)',
|
||||
X7: 'rgba(255, 255, 255, 0.05)',
|
||||
X8: ':lighten<5<@accent',
|
||||
X9: ':darken<5<@accent',
|
||||
bg: '#232634',
|
||||
fg: '#c6d0f5',
|
||||
X10: ':alpha<0.4<@accent',
|
||||
X11: 'rgba(0, 0, 0, 0.3)',
|
||||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
cwBg: '#51576d',
|
||||
cwFg: '#b5bfe2',
|
||||
link: '#8caaee',
|
||||
warn: '#ef9f76',
|
||||
badge: '#8caaee',
|
||||
error: '#e78284',
|
||||
focus: ':alpha<0.3<@accent',
|
||||
navBg: '@panel',
|
||||
navFg: '@fg',
|
||||
panel: ':lighten<3<@bg',
|
||||
popup: ':lighten<3<@panel',
|
||||
accent: '#eebebe',
|
||||
header: ':alpha<0.7<@panel',
|
||||
infoBg: '#414559',
|
||||
infoFg: '#a5adce',
|
||||
renote: '#8caaee',
|
||||
shadow: 'rgba(0, 0, 0, 0.3)',
|
||||
divider: 'rgba(255, 255, 255, 0.1)',
|
||||
hashtag: '#85c1dc',
|
||||
mention: '@accent',
|
||||
modalBg: 'rgba(0, 0, 0, 0.5)',
|
||||
success: '#a6d189',
|
||||
buttonBg: 'rgba(255, 255, 255, 0.05)',
|
||||
switchBg: 'rgba(255, 255, 255, 0.15)',
|
||||
acrylicBg: ':alpha<0.5<@bg',
|
||||
cwHoverBg: '#626880',
|
||||
indicator: '@accent',
|
||||
mentionMe: '@mention',
|
||||
messageBg: '@bg',
|
||||
navActive: '@accent',
|
||||
accentedBg: ':alpha<0.15<@accent',
|
||||
codeNumber: '#a6d189',
|
||||
codeString: '#ef9f76',
|
||||
fgOnAccent: '#303446',
|
||||
infoWarnBg: '#414559',
|
||||
infoWarnFg: '#b5bfe2',
|
||||
navHoverFg: ':lighten<17<@fg',
|
||||
swutchOnBg: '@accentedBg',
|
||||
swutchOnFg: '@accent',
|
||||
codeBoolean: '@accent',
|
||||
dateLabelFg: '@fg',
|
||||
deckDivider: '#737994',
|
||||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
panelBorder: 'solid 1px var(--divider)',
|
||||
swutchOffBg: 'rgba(255, 255, 255, 0.1)',
|
||||
swutchOffFg: '@fg',
|
||||
accentDarken: ':darken<10<@accent',
|
||||
acrylicPanel: ':alpha<0.5<@panel',
|
||||
navIndicator: '@indicator',
|
||||
windowHeader: ':alpha<0.85<@panel',
|
||||
accentLighten: ':lighten<10<@accent',
|
||||
buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
fgTransparent: ':alpha<0.5<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
buttonGradateA: '@accent',
|
||||
buttonGradateB: ':hue<20<@accent',
|
||||
htmlThemeColor: '@bg',
|
||||
panelHighlight: ':lighten<3<@panel',
|
||||
listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
|
||||
scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
|
||||
inputBorderHover: 'rgba(255, 255, 255, 0.2)',
|
||||
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
|
||||
fgTransparentWeak: ':alpha<0.75<@fg',
|
||||
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
|
||||
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
author: 'somebody ¯_(ツ)_/¯',
|
||||
}
|
94
packages/client/src/themes/d-catppuccin-mocha.json5
Normal file
94
packages/client/src/themes/d-catppuccin-mocha.json5
Normal file
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
id: 'd413f41f-a489-48be-9e20-3532ffbb4363',
|
||||
base: 'dark',
|
||||
name: 'Catppuccin mocha',
|
||||
props: {
|
||||
X2: ':darken<2<@panel',
|
||||
X3: 'rgba(255, 255, 255, 0.05)',
|
||||
X4: 'rgba(255, 255, 255, 0.1)',
|
||||
X5: 'rgba(255, 255, 255, 0.05)',
|
||||
X6: 'rgba(255, 255, 255, 0.15)',
|
||||
X7: 'rgba(255, 255, 255, 0.05)',
|
||||
X8: ':lighten<5<@accent',
|
||||
X9: ':darken<5<@accent',
|
||||
bg: '#11111b',
|
||||
fg: '#cdd6f4',
|
||||
X10: ':alpha<0.4<@accent',
|
||||
X11: 'rgba(0, 0, 0, 0.3)',
|
||||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
cwBg: '#45475a',
|
||||
cwFg: '#bac2de',
|
||||
link: '#89b4fa',
|
||||
warn: '#fab387',
|
||||
badge: '#89b4fa',
|
||||
error: '#f38ba8',
|
||||
focus: ':alpha<0.3<@accent',
|
||||
navBg: '@panel',
|
||||
navFg: '@fg',
|
||||
panel: ':lighten<3<@bg',
|
||||
popup: ':lighten<3<@panel',
|
||||
accent: '#f2cdcd',
|
||||
header: ':alpha<0.7<@panel',
|
||||
infoBg: '#313244',
|
||||
infoFg: '#a6adc8',
|
||||
renote: '#89b4fa',
|
||||
shadow: 'rgba(0, 0, 0, 0.3)',
|
||||
divider: 'rgba(255, 255, 255, 0.1)',
|
||||
hashtag: '#74c7ec',
|
||||
mention: '@accent',
|
||||
modalBg: 'rgba(0, 0, 0, 0.5)',
|
||||
success: '#a6e3a1',
|
||||
buttonBg: 'rgba(255, 255, 255, 0.05)',
|
||||
switchBg: 'rgba(255, 255, 255, 0.15)',
|
||||
acrylicBg: ':alpha<0.5<@bg',
|
||||
cwHoverBg: '#585b70',
|
||||
indicator: '@accent',
|
||||
mentionMe: '@mention',
|
||||
messageBg: '@bg',
|
||||
navActive: '@accent',
|
||||
accentedBg: ':alpha<0.15<@accent',
|
||||
codeNumber: '#a6e3a1',
|
||||
codeString: '#fab387',
|
||||
fgOnAccent: '#1e1e2e',
|
||||
infoWarnBg: '#313244',
|
||||
infoWarnFg: '#bac2de',
|
||||
navHoverFg: ':lighten<17<@fg',
|
||||
swutchOnBg: '@accentedBg',
|
||||
swutchOnFg: '@accent',
|
||||
codeBoolean: '@accent',
|
||||
dateLabelFg: '@fg',
|
||||
deckDivider: '#6c7086',
|
||||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
panelBorder: 'solid 1px var(--divider)',
|
||||
swutchOffBg: 'rgba(255, 255, 255, 0.1)',
|
||||
swutchOffFg: '@fg',
|
||||
accentDarken: ':darken<10<@accent',
|
||||
acrylicPanel: ':alpha<0.5<@panel',
|
||||
navIndicator: '@indicator',
|
||||
windowHeader: ':alpha<0.85<@panel',
|
||||
accentLighten: ':lighten<10<@accent',
|
||||
buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
fgTransparent: ':alpha<0.5<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
buttonGradateA: '@accent',
|
||||
buttonGradateB: ':hue<20<@accent',
|
||||
htmlThemeColor: '@bg',
|
||||
panelHighlight: ':lighten<3<@panel',
|
||||
listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
|
||||
scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
|
||||
inputBorderHover: 'rgba(255, 255, 255, 0.2)',
|
||||
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
|
||||
fgTransparentWeak: ':alpha<0.75<@fg',
|
||||
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
|
||||
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
author: 'somebody ¯_(ツ)_/¯',
|
||||
}
|
30
packages/client/src/themes/d-gruvbox.json5
Normal file
30
packages/client/src/themes/d-gruvbox.json5
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
id: '256a2e52-440f-4a00-8a76-c93501354dfb',
|
||||
base: 'dark',
|
||||
desc: 'Misskey gruvbox-dark-medium theme. Inspired by https://github.com/morhetz/gruvbox',
|
||||
name: 'Gruvbox Dark Medium',
|
||||
props: {
|
||||
bg: '#282828',
|
||||
fg: '#ebdbb2',
|
||||
link: '#b16286',
|
||||
warn: '#d65d0e',
|
||||
badge: '#458588',
|
||||
error: '#fb4934',
|
||||
navBg: '#32302f',
|
||||
panel: '#32302f',
|
||||
accent: '#98971a',
|
||||
header: ':alpha<0.7<@panel',
|
||||
renote: '@accent',
|
||||
divider: '#7c6f64',
|
||||
hashtag: '#458588',
|
||||
mention: '#98971a',
|
||||
success: '#98971a',
|
||||
mentionMe: '#fb4934',
|
||||
fgHighlighted: '#fbf1c7',
|
||||
panelHeaderBg: '@panel',
|
||||
buttonGradateA: '#98971a',
|
||||
buttonGradateB: '#98971a',
|
||||
panelHeaderDivider: '@divider',
|
||||
},
|
||||
author: '@razzlom@quietplace.xyz',
|
||||
}
|
94
packages/client/src/themes/d-nord.json5
Normal file
94
packages/client/src/themes/d-nord.json5
Normal file
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
id: 'dddbc0c6-af2c-46f8-b8f3-05964adcde0b',
|
||||
base: 'dark',
|
||||
desc: 'Nord: an arctic, north-bluish color palette',
|
||||
name: 'Nord Dark',
|
||||
props: {
|
||||
X2: ':darken<2<@panel',
|
||||
X3: 'rgba(255, 255, 255, 0.05)',
|
||||
X4: 'rgba(255, 255, 255, 0.1)',
|
||||
X5: 'rgba(255, 255, 255, 0.05)',
|
||||
X6: 'rgba(255, 255, 255, 0.15)',
|
||||
X7: 'rgba(255, 255, 255, 0.05)',
|
||||
X8: ':lighten<5<@accent',
|
||||
X9: ':darken<5<@accent',
|
||||
bg: '#2e3440',
|
||||
fg: '#eceff4',
|
||||
X10: ':alpha<0.4<@accent',
|
||||
X11: 'rgba(0, 0, 0, 0.3)',
|
||||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
cwBg: '#4c566a',
|
||||
cwFg: '#393f4f',
|
||||
link: '#b48ead',
|
||||
warn: '#d08770',
|
||||
badge: '#d08770',
|
||||
error: '#bf616a',
|
||||
focus: ':alpha<0.3<@accent',
|
||||
navBg: '@panel',
|
||||
navFg: '@fg',
|
||||
panel: ':lighten<3<@bg',
|
||||
popup: ':lighten<3<@panel',
|
||||
accent: '#81a1c1',
|
||||
header: ':alpha<0.7<@panel',
|
||||
infoBg: '#4c566a',
|
||||
infoFg: '#d08770',
|
||||
renote: '#ebcb8b',
|
||||
shadow: 'rgba(0, 0, 0, 0.3)',
|
||||
divider: 'rgba(255, 255, 255, 0.1)',
|
||||
hashtag: '#a3be8c',
|
||||
mention: '@accent',
|
||||
modalBg: 'rgba(0, 0, 0, 0.5)',
|
||||
success: '#a3be8c',
|
||||
buttonBg: 'rgba(255, 255, 255, 0.05)',
|
||||
switchBg: 'rgba(255, 255, 255, 0.15)',
|
||||
acrylicBg: ':alpha<0.5<@bg',
|
||||
cwHoverBg: '#707b97',
|
||||
indicator: '@accent',
|
||||
mentionMe: '@mention',
|
||||
messageBg: '@bg',
|
||||
navActive: '@accent',
|
||||
accentedBg: ':alpha<0.15<@accent',
|
||||
codeNumber: '#a3be8c',
|
||||
codeString: '#b48ead',
|
||||
fgOnAccent: '#eceff4',
|
||||
infoWarnBg: '#4c566a',
|
||||
infoWarnFg: '#bf616a',
|
||||
navHoverFg: ':lighten<17<@fg',
|
||||
swutchOnBg: '@accentedBg',
|
||||
swutchOnFg: '@accent',
|
||||
codeBoolean: '#ebcb8b',
|
||||
dateLabelFg: '@fg',
|
||||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
panelBorder: '" solid 1px var(--divider)',
|
||||
swutchOffBg: 'rgba(255, 255, 255, 0.1)',
|
||||
swutchOffFg: '@fg',
|
||||
accentDarken: '#5e81ac',
|
||||
acrylicPanel: ':alpha<0.5<@panel',
|
||||
navIndicator: '@indicator',
|
||||
windowHeader: ':alpha<0.85<@panel',
|
||||
accentLighten: '#88c0d0',
|
||||
buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
fgTransparent: ':alpha<0.5<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
buttonGradateA: '@accent',
|
||||
buttonGradateB: '#8fbcbb',
|
||||
htmlThemeColor: '@bg',
|
||||
panelHighlight: ':lighten<3<@panel',
|
||||
listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
|
||||
scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
|
||||
inputBorderHover: 'rgba(255, 255, 255, 0.2)',
|
||||
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
|
||||
fgTransparentWeak: ':alpha<0.75<@fg',
|
||||
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
|
||||
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
author: '@thatonecalculator@stop.voring.me',
|
||||
}
|
30
packages/client/src/themes/l-gruvbox.json5
Normal file
30
packages/client/src/themes/l-gruvbox.json5
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
id: '9be7b20e-58b4-4bd2-8b1d-49d41a676685',
|
||||
base: 'light',
|
||||
desc: 'Misskey gruvbox-light-medium theme. Inspired by https://github.com/morhetz/gruvbox',
|
||||
name: 'Gruvbox Light Medium',
|
||||
props: {
|
||||
bg: '#fbf1c7',
|
||||
fg: '#3c3836',
|
||||
link: '#b16286',
|
||||
warn: '#d65d0e',
|
||||
badge: '#458588',
|
||||
error: '#fb4934',
|
||||
navBg: '#f9f5c7',
|
||||
panel: '#f9f5c7',
|
||||
accent: '#98971a',
|
||||
header: ':alpha<0.7<@panel',
|
||||
renote: '@accent',
|
||||
divider: '#7c6f64',
|
||||
hashtag: '#458588',
|
||||
mention: '#98971a',
|
||||
success: '#98971a',
|
||||
mentionMe: '#9d0006',
|
||||
fgHighlighted: '#fbf1c7',
|
||||
panelHeaderBg: '@panel',
|
||||
buttonGradateA: '#98971a',
|
||||
buttonGradateB: '#98971a',
|
||||
panelHeaderDivider: '@divider',
|
||||
},
|
||||
author: '@razzlom@quietplace.xyz',
|
||||
}
|
94
packages/client/src/themes/l-nord.json5
Normal file
94
packages/client/src/themes/l-nord.json5
Normal file
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
id: 'a4b1932e-740c-4ca4-b5d7-06e3322dced4',
|
||||
base: 'light',
|
||||
desc: 'Nord: an arctic, north-bluish color palette',
|
||||
name: 'Nord Light',
|
||||
props: {
|
||||
X2: ':darken<2<@panel',
|
||||
X3: 'rgba(255, 255, 255, 0.05)',
|
||||
X4: 'rgba(255, 255, 255, 0.1)',
|
||||
X5: 'rgba(255, 255, 255, 0.05)',
|
||||
X6: 'rgba(255, 255, 255, 0.15)',
|
||||
X7: 'rgba(255, 255, 255, 0.05)',
|
||||
X8: ':lighten<5<@accent',
|
||||
X9: ':darken<5<@accent',
|
||||
bg: '#d8dee9',
|
||||
fg: '#3b4252',
|
||||
X10: ':alpha<0.4<@accent',
|
||||
X11: 'rgba(0, 0, 0, 0.3)',
|
||||
X12: 'rgba(255, 255, 255, 0.1)',
|
||||
X13: 'rgba(255, 255, 255, 0.15)',
|
||||
X14: ':alpha<0.5<@navBg',
|
||||
X15: ':alpha<0<@panel',
|
||||
X16: ':alpha<0.7<@panel',
|
||||
X17: ':alpha<0.8<@bg',
|
||||
cwBg: '#687390',
|
||||
cwFg: '#393f4f',
|
||||
link: '#44a4c1',
|
||||
warn: '#ecb637',
|
||||
badge: '#31b1ce',
|
||||
error: '#ec4137',
|
||||
focus: ':alpha<0.3<@accent',
|
||||
navBg: '@panel',
|
||||
navFg: '@fg',
|
||||
panel: ':lighten<3<@bg',
|
||||
popup: ':lighten<3<@panel',
|
||||
accent: '#81a1c1',
|
||||
header: ':alpha<0.7<@panel',
|
||||
infoBg: '#253142',
|
||||
infoFg: '#fff',
|
||||
renote: '#229e82',
|
||||
shadow: 'rgba(0, 0, 0, 0.3)',
|
||||
divider: 'rgba(255, 255, 255, 0.1)',
|
||||
hashtag: '#ff9156',
|
||||
mention: '@accent',
|
||||
modalBg: 'rgba(0, 0, 0, 0.5)',
|
||||
success: '#86b300',
|
||||
buttonBg: 'rgba(255, 255, 255, 0.05)',
|
||||
switchBg: 'rgba(255, 255, 255, 0.15)',
|
||||
acrylicBg: ':alpha<0.5<@bg',
|
||||
cwHoverBg: '#707b97',
|
||||
indicator: '@accent',
|
||||
mentionMe: '@mention',
|
||||
messageBg: '@bg',
|
||||
navActive: '@accent',
|
||||
accentedBg: ':alpha<0.15<@accent',
|
||||
codeNumber: '#cfff9e',
|
||||
codeString: '#ffb675',
|
||||
fgOnAccent: '#fff',
|
||||
infoWarnBg: '#42321c',
|
||||
infoWarnFg: '#ffbd3e',
|
||||
navHoverFg: ':lighten<17<@fg',
|
||||
swutchOnBg: '@accentedBg',
|
||||
swutchOnFg: '@accent',
|
||||
codeBoolean: '#c59eff',
|
||||
dateLabelFg: '@fg',
|
||||
inputBorder: 'rgba(255, 255, 255, 0.1)',
|
||||
panelBorder: '" solid 1px var(--divider)',
|
||||
swutchOffBg: 'rgba(255, 255, 255, 0.1)',
|
||||
swutchOffFg: '@fg',
|
||||
accentDarken: ':darken<10<@accent',
|
||||
acrylicPanel: ':alpha<0.5<@panel',
|
||||
navIndicator: '@indicator',
|
||||
windowHeader: ':alpha<0.85<@panel',
|
||||
accentLighten: ':lighten<10<@accent',
|
||||
buttonHoverBg: 'rgba(255, 255, 255, 0.1)',
|
||||
driveFolderBg: ':alpha<0.3<@accent',
|
||||
fgHighlighted: ':lighten<3<@fg',
|
||||
fgTransparent: ':alpha<0.5<@fg',
|
||||
panelHeaderBg: ':lighten<3<@panel',
|
||||
panelHeaderFg: '@fg',
|
||||
buttonGradateA: '@accent',
|
||||
buttonGradateB: ':hue<20<@accent',
|
||||
htmlThemeColor: '@bg',
|
||||
panelHighlight: ':lighten<3<@panel',
|
||||
listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
|
||||
scrollbarHandle: 'rgba(255, 255, 255, 0.2)',
|
||||
inputBorderHover: 'rgba(255, 255, 255, 0.2)',
|
||||
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
|
||||
fgTransparentWeak: ':alpha<0.75<@fg',
|
||||
panelHeaderDivider: 'rgba(0, 0, 0, 0)',
|
||||
scrollbarHandleHover: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
author: '@thatonecalculator@stop.voring.me',
|
||||
}
|
|
@ -376,10 +376,8 @@ const wallpaper = localStorage.getItem('wallpaper') != null;
|
|||
}
|
||||
|
||||
> .button-wrapper {
|
||||
|
||||
> i {
|
||||
transform: translateY(0.05em);
|
||||
}
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
|
||||
&.on {
|
||||
background-color: var(--accentedBg);
|
||||
|
|
|
@ -67,7 +67,7 @@ defineExpose<WidgetComponentExpose>({
|
|||
|
||||
> .text {
|
||||
::v-deep(b) {
|
||||
color: #41b781;
|
||||
color: var(--badge);
|
||||
}
|
||||
|
||||
::v-deep(span) {
|
||||
|
|
|
@ -5,6 +5,7 @@ import { defineConfig } from 'vite';
|
|||
import locales from '../../locales';
|
||||
import meta from '../../package.json';
|
||||
import pluginJson5 from './vite.json5';
|
||||
import viteCompression from 'vite-plugin-compression';
|
||||
|
||||
const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue'];
|
||||
|
||||
|
@ -20,6 +21,9 @@ export default defineConfig(({ command, mode }) => {
|
|||
reactivityTransform: true,
|
||||
}),
|
||||
pluginJson5(),
|
||||
viteCompression({
|
||||
algorithm: 'brotliCompress'
|
||||
}),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
|
|
|
@ -55,7 +55,7 @@ importers:
|
|||
'@bull-board/api': ^4.6.4
|
||||
'@bull-board/koa': ^4.6.4
|
||||
'@bull-board/ui': ^4.6.4
|
||||
'@calckey/megalodon': 5.1.2
|
||||
'@calckey/megalodon': 5.1.21
|
||||
'@discordapp/twemoji': 14.0.2
|
||||
'@elastic/elasticsearch': 7.17.0
|
||||
'@koa/cors': 3.4.3
|
||||
|
@ -196,6 +196,7 @@ importers:
|
|||
seedrandom: ^3.0.5
|
||||
semver: 7.3.8
|
||||
sharp: 0.31.3
|
||||
sonic-channel: ^1.3.1
|
||||
speakeasy: 2.0.0
|
||||
strict-event-emitter-types: 2.0.0
|
||||
stringz: 2.1.0
|
||||
|
@ -224,7 +225,7 @@ importers:
|
|||
'@bull-board/api': 4.10.2
|
||||
'@bull-board/koa': 4.10.2_6tybghmia4wsnt33xeid7y4rby
|
||||
'@bull-board/ui': 4.10.2
|
||||
'@calckey/megalodon': 5.1.2
|
||||
'@calckey/megalodon': 5.1.21
|
||||
'@discordapp/twemoji': 14.0.2
|
||||
'@elastic/elasticsearch': 7.17.0
|
||||
'@koa/cors': 3.4.3
|
||||
|
@ -310,6 +311,7 @@ importers:
|
|||
seedrandom: 3.0.5
|
||||
semver: 7.3.8
|
||||
sharp: 0.31.3
|
||||
sonic-channel: 1.3.1
|
||||
speakeasy: 2.0.0
|
||||
stringz: 2.1.0
|
||||
summaly: 2.7.0
|
||||
|
@ -465,6 +467,7 @@ importers:
|
|||
uuid: 9.0.0
|
||||
vanilla-tilt: 1.8.0
|
||||
vite: ^4.1.1
|
||||
vite-plugin-compression: ^0.5.1
|
||||
vue: 3.2.45
|
||||
vue-isyourpasswordsafe: ^2.0.0
|
||||
vue-plyr: ^7.0.0
|
||||
|
@ -542,6 +545,7 @@ importers:
|
|||
uuid: 9.0.0
|
||||
vanilla-tilt: 1.8.0
|
||||
vite: 4.1.1_sass@1.57.1
|
||||
vite-plugin-compression: 0.5.1_vite@4.1.1
|
||||
vue: 3.2.45
|
||||
vue-isyourpasswordsafe: 2.0.0
|
||||
vue-plyr: 7.0.0
|
||||
|
@ -759,8 +763,8 @@ packages:
|
|||
'@bull-board/api': 4.10.2
|
||||
dev: false
|
||||
|
||||
/@calckey/megalodon/5.1.2:
|
||||
resolution: {integrity: sha512-bUjPOfASy8X2NxdBvYDOWN9Rw/KdkfbTxy5vMQBcrGXepFbT4M+00blEYNc00Uu/epwH9YoNqpQC8PKQr/WU4w==}
|
||||
/@calckey/megalodon/5.1.21:
|
||||
resolution: {integrity: sha512-wThdyNb/UofklvYzyeFNQwxJNHqaSLD+z0glQLzV1tmAvSB0EZOL0D15dvpdB0LED0Q2rpJlwaonejkTI9JQhA==}
|
||||
engines: {node: '>=15.0.0'}
|
||||
dependencies:
|
||||
'@types/oauth': 0.9.1
|
||||
|
@ -6514,6 +6518,15 @@ packages:
|
|||
/fs-constants/1.0.0:
|
||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||
|
||||
/fs-extra/10.1.0:
|
||||
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
|
||||
engines: {node: '>=12'}
|
||||
dependencies:
|
||||
graceful-fs: 4.2.10
|
||||
jsonfile: 6.1.0
|
||||
universalify: 2.0.0
|
||||
dev: true
|
||||
|
||||
/fs-extra/8.1.0:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
@ -11583,6 +11596,11 @@ packages:
|
|||
smart-buffer: 4.2.0
|
||||
dev: false
|
||||
|
||||
/sonic-channel/1.3.1:
|
||||
resolution: {integrity: sha512-+K4IZVFE7Tf2DB4EFZ23xo7a/+gJaiOHhFzXVZpzkX6Rs/rvf4YbSxnEGdYw8mrTcjtpG+jLVQEhP8sNTtN5VA==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
dev: false
|
||||
|
||||
/sort-keys-length/1.0.1:
|
||||
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
@ -12922,6 +12940,19 @@ packages:
|
|||
replace-ext: 1.0.1
|
||||
dev: true
|
||||
|
||||
/vite-plugin-compression/0.5.1_vite@4.1.1:
|
||||
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
|
||||
peerDependencies:
|
||||
vite: '>=2.0.0'
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
debug: 4.3.4
|
||||
fs-extra: 10.1.0
|
||||
vite: 4.1.1_sass@1.57.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/vite/4.1.1_sass@1.57.1:
|
||||
resolution: {integrity: sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "13.1.3-rc",
|
||||
"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",
|
||||
"version": "13.1.3",
|
||||
"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• New headerbar style\n• Bug + security fixes and performance improvements",
|
||||
"screenshots": []
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue