Merge branch 'develop' into beta

This commit is contained in:
ThatOneCalculator 2023-03-20 20:54:01 -07:00
commit e674ae1c25
No known key found for this signature in database
GPG key ID: 8703CACD01000000
88 changed files with 1864 additions and 238 deletions

View file

@ -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
View file

@ -44,6 +44,7 @@ ormconfig.json
packages/backend/assets/instance.css
packages/backend/assets/sounds/None.mp3
!packages/backend/src/db
# blender backups
*.blend1

View file

@ -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)

View file

@ -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
```

View file

@ -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"

View file

@ -935,6 +935,7 @@ moveFromLabel: "引っ越し元のアカウント:"
moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@instance.com"
migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用することはできません。\nまた、引っ越し先のアカウントでエイリアスを作成したことを確認してください。"
defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション"
license: "ライセンス"
_sensitiveMediaDetection:
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"

View file

@ -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",

View file

@ -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"`);
}
}

View 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'`);
}
}

View file

@ -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",

View file

@ -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;

View 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;

View file

@ -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;
}

View file

@ -15,6 +15,7 @@ export const EmojiRepository = db.getRepository(Emoji).extend({
host: emoji.host,
// || emoji.originalUrl してるのは後方互換性のため
url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license,
};
},

View file

@ -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;
},

View file

@ -40,5 +40,10 @@ export const packedEmojiSchema = {
optional: false,
nullable: false,
},
license: {
type: "string",
optional: false,
nullable: true,
},
},
} as const;

View file

@ -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",

View file

@ -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.");
}

View 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);
}
}

View file

@ -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]));
}

View file

@ -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,
];

View file

@ -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),

View file

@ -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],

View file

@ -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"]);

View file

@ -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"]);

View file

@ -55,6 +55,11 @@ export const meta = {
optional: false,
nullable: false,
},
license: {
type: "string",
optional: false,
nullable: true,
},
},
},
},

View file

@ -55,6 +55,11 @@ export const meta = {
optional: false,
nullable: false,
},
license: {
type: "string",
optional: false,
nullable: true,
},
},
},
},

View file

@ -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"]);
});

View file

@ -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"]);

View file

@ -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,
};
});

View file

@ -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,
});
});

View file

@ -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;

View 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);
});

View file

@ -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);
}

View file

@ -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

View file

@ -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,
@ -338,8 +338,13 @@ export function apiAccountMastodon(router: Router): void {
ctx.body = [relationshipModel];
return;
}
let reqIds = [];
for (let i = 0; i < ids.length; i++) {
reqIds.push(convertId(ids[i], IdType.CalckeyId));
}
const data = await client.getRelationships(ids);
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);

View file

@ -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;

View file

@ -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) {

View file

@ -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;

View file

@ -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(text);
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);
}
});

View file

@ -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,18 +729,34 @@ 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({
index: config.elasticsearch.index || "misskey_note",
id: note.id.toString(),
body: {
text: normalizeForSearch(note.text),
userId: note.userId,
userHost: note.userHost,
},
});
if (config.elasticsearch && es) {
es.index({
index: config.elasticsearch.index || "misskey_note",
id: note.id.toString(),
body: {
text: normalizeForSearch(note.text),
userId: note.userId,
userHost: note.userHost,
},
});
}
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(

View file

@ -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",

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -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>

View file

@ -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"/>

View file

@ -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 {

View file

@ -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,19 +351,23 @@ function readPromo() {
}
}
&:hover > .article > .main > .footer > .button {
opacity: 1;
& > .article > .main {
&:hover, &:focus-within {
:deep(.footer .button) {
opacity: 1;
}
}
}
> .reply-to {
& + .note-context {
.line::before {
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;
}
@ -626,6 +647,7 @@ function readPromo() {
}
}
}
> .reply {
border-top: solid 0.5px var(--divider);

View file

@ -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,26 +604,72 @@ 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 {
background-color: var(--panelHighlight);
// 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);
// }
// }
// }
}
&.max-width_500px {
font-size: 0.9em;
}
&.max-width_450px {
> .reply-to-more:first-child {
padding-top: 14px;
}

View file

@ -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;

View file

@ -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">

View file

@ -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 {
margin-top: 10px;
&.single {
padding: 0 !important;
> .line {
display: none;
}
}
}
> .reply, > .more {
border-left: solid 0.5px var(--divider);
margin-top: 10px;
> .more {
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;
}
}
}
> .more {
padding: 10px 0 0 16px;
&.reply, &.reply-to, &.reply-to-more {
> .main:hover, > .main:focus-within {
:deep(.footer .button) {
opacity: 1;
}
}
}
&.reply-to, &.reply-to-more {
@ -135,41 +375,110 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
&:first-child {
padding-top: 30px;
}
.avatar-container {
display: flex;
flex-direction: column;
align-items: center;
margin-right: 14px;
width: var(--avatarSize);
> .avatar {
width: var(--avatarSize);
height: var(--avatarSize);
margin: 0;
}
> .line {
width: var(--avatarSize);
.line::before {
margin-bottom: -16px;
}
}
// Reply Lines
&.reply, &.reply-to, &.reply-to-more {
--indent: calc(var(--avatarSize) - 5px);
> .main {
> .avatar-container {
display: flex;
flex-grow: 1;
&::before {
content: "";
display: block;
width: 2px;
background-color: var(--divider);
margin-inline: auto;
.note > & {
margin-bottom: -16px;
}
flex-direction: column;
align-items: center;
margin-right: 14px;
width: var(--avatarSize);
> .avatar {
width: var(--avatarSize);
height: var(--avatarSize);
margin: 0;
}
}
}
> .main > .body {
padding-bottom: 16px;
.line {
position: relative;
width: var(--avatarSize);
display: flex;
flex-grow: 1;
margin-bottom: -10px;
&::before {
content: "";
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;
}
}
&.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;

View file

@ -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>

View file

@ -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;

View file

@ -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 {

View file

@ -21,9 +21,10 @@ 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 {
display: none;
}

View file

@ -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,23 +60,35 @@ 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>
.wrmlmaau {
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 {

View file

@ -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}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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"] },

View file

@ -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;

View file

@ -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,
},
});

View file

@ -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',

View file

@ -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;

View file

@ -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 ? [{
icon: 'ph-gear-six ph-bold ph-lg',
text: i18n.ts.edit,
handler: edit,
}] : null);
const headerActions = $computed(() => [
...(
channel
&& channel?.userId === $i?.id
? [
{
icon: 'ph-gear-six ph-bold ph-lg',
text: i18n.ts.edit,
handler: edit,
}
]
: []
),
]);
const headerTabs = $computed(() => []);

View file

@ -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>

View file

@ -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);

View file

@ -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">

View file

@ -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) => {

View file

@ -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) {

View file

@ -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",

View file

@ -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;
}
}
}

View 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 ¯_(ツ)_/¯',
}

View 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 ¯_(ツ)_/¯',
}

View 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',
}

View 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',
}

View 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',
}

View 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',
}

View file

@ -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);

View file

@ -67,7 +67,7 @@ defineExpose<WidgetComponentExpose>({
> .text {
::v-deep(b) {
color: #41b781;
color: var(--badge);
}
::v-deep(span) {

View file

@ -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: {

View file

@ -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}

View file

@ -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": []
}