Merge branch 'develop' into beta

This commit is contained in:
ThatOneCalculator 2023-03-20 20:54:01 -07:00
commit a57530160a
88 changed files with 1864 additions and 238 deletions

View file

@ -72,6 +72,16 @@ redis:
# user: # user:
# pass: # pass:
# ┌─────────────────────┐
#───┘ Sonic configuration └─────────────────────────────────────
#sonic:
# host: localhost
# port: 1491
# auth: SecretPassword
# collection: notes
# bucket: default
# ┌───────────────┐ # ┌───────────────┐
#───┘ ID generation └─────────────────────────────────────────── #───┘ ID generation └───────────────────────────────────────────

1
.gitignore vendored
View file

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

View file

@ -3,21 +3,17 @@
## Planned ## Planned
- Stucture - Stucture
- [Sonic](https://crates.io/crates/sonic-server) support as an ElasticSearch alternative
- [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative - [DragonflyDB](https://dragonflydb.io/) support as a Redis alternative
- Optionally use [ScyllaDB](https://www.scylladb.com/open-source-nosql-database/) for storing notes - 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) - Rewrite backend in Rust and [Axum](https://github.com/tokio-rs/axum)
- Function - Function
- Federate with note edits - Federate with note edits
- Admin customizable max note length (100-8000)
- User "choices" (recommended users) like Mastodon and Soapbox - User "choices" (recommended users) like Mastodon and Soapbox
- Join Reason system like Mastodon/Pleroma - Join Reason system like Mastodon/Pleroma
- Option to publicize instance blocks - Option to publicize instance blocks
- Backfill remote users
- Build flag to remove NSFW/AI stuff - Build flag to remove NSFW/AI stuff
- Timeline filters - Timeline filters
- Filter notifications by user - Filter notifications by user
- Non-nyaify cat mode
- Exclude self from antenna - Exclude self from antenna
- Form - Form
- MFM button - MFM button
@ -37,6 +33,7 @@
- Admin custom CSS - Admin custom CSS
- Add back time machine (jump to date) - Add back time machine (jump to date)
- Improve accesibility - Improve accesibility
- Non-nyaify cat mode
## Implemented ## Implemented
@ -108,6 +105,14 @@
- Allows custom emoji - Allows custom emoji
- Fix lint errors - Fix lint errors
- Use Rome instead of ESLint - 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) - MissV: [fix Misskey Forkbomb](https://code.vtopia.live/Vtopia/MissV/commit/40b23c070bd4adbb3188c73546c6c625138fb3c1)
- [Make showing ads optional](https://github.com/misskey-dev/misskey/pull/8996) - [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) - [Tapping avatar in mobile opens account modal](https://github.com/misskey-dev/misskey/pull/9056)

View file

@ -34,6 +34,9 @@
- OCR image captioning - OCR image captioning
- New and improved Groups - New and improved Groups
- Better intro tutorial - Better intro tutorial
- Compatibility with Mastodon clients/apps
- Backfill user information
- Sonic search
- Many more user and admin settings - Many more user and admin settings
- [So much more!](./CALCKEY.md) - [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 ### 😗 Optional dependencies
- [FFmpeg](https://ffmpeg.org/) for video transcoding - [FFmpeg](https://ffmpeg.org/) for video transcoding
- [ElasticSearch](https://www.elastic.co/elasticsearch/) for full-text search - Full text search (choost one of the following)
- OpenSearch/Sonic are not supported as of right now - 🦔 [Sonic](https://crates.io/crates/sonic-server) (highly recommended!)
- [ElasticSearch](https://www.elastic.co/elasticsearch/)
- Management (choose one of the following) - Management (choose one of the following)
- 🛰️ [pm2](https://pm2.io/) - 🛰️ [pm2](https://pm2.io/)
- 🐳 [Docker](https://docker.com) - 🐳 [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';" 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 ## 💅 Customize
- To add custom CSS for all users, edit `./custom/assets/instance.css`. - 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 ```sh
# git pull # 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 pm2 start "NODE_ENV=production pnpm run start" --name Calckey
``` ```

View file

@ -835,7 +835,7 @@ muteThread: "Mute thread"
unmuteThread: "Unmute thread" unmuteThread: "Unmute thread"
ffVisibility: "Follows/Followers Visibility" ffVisibility: "Follows/Followers Visibility"
ffVisibilityDescription: "Allows you to configure who can see who you follow and who follows you." 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?" deleteAccountConfirm: "This will irreversibly delete your account. Proceed?"
incorrectPassword: "Incorrect password." incorrectPassword: "Incorrect password."
voteConfirm: "Confirm your vote for \"{choice}\"?" 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" 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." 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" defaultReaction: "Default emoji reaction for outgoing and incoming posts"
license: "License"
_sensitiveMediaDetection: _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." 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" metadataContent: "Content"
changeAvatar: "Change avatar" changeAvatar: "Change avatar"
changeBanner: "Change banner" 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: _exportOrImport:
allNotes: "All posts" allNotes: "All posts"
followingList: "Followed users" followingList: "Followed users"

View file

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

View file

@ -1,12 +1,12 @@
{ {
"name": "calckey", "name": "calckey",
"version": "13.2.0-beta", "version": "13.2.0-beta2",
"codename": "aqua", "codename": "aqua",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://codeberg.org/calckey/calckey.git" "url": "https://codeberg.org/calckey/calckey.git"
}, },
"packageManager": "pnpm@7.27.1", "packageManager": "pnpm@7.29.3",
"private": true, "private": true,
"scripts": { "scripts": {
"rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp", "rebuild": "pnpm run clean && pnpm -r run build && pnpm run gulp",

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-send": "5.0.1",
"koa-slow": "2.1.0", "koa-slow": "2.1.0",
"koa-views": "7.0.2", "koa-views": "7.0.2",
"@calckey/megalodon": "5.1.2", "@calckey/megalodon": "5.1.21",
"mfm-js": "0.23.2", "mfm-js": "0.23.2",
"mime-types": "2.1.35", "mime-types": "2.1.35",
"multer": "1.4.4-lts.1", "multer": "1.4.4-lts.1",
@ -112,6 +112,7 @@
"seedrandom": "^3.0.5", "seedrandom": "^3.0.5",
"semver": "7.3.8", "semver": "7.3.8",
"sharp": "0.31.3", "sharp": "0.31.3",
"sonic-channel": "^1.3.1",
"speakeasy": "2.0.0", "speakeasy": "2.0.0",
"stringz": "2.1.0", "stringz": "2.1.0",
"summaly": "2.7.0", "summaly": "2.7.0",

View file

@ -32,6 +32,13 @@ export type Source = {
pass?: string; pass?: string;
index?: string; index?: string;
}; };
sonic: {
host: string;
port: number;
auth?: string;
collection?: string;
bucket?: string;
};
proxy?: string; proxy?: string;
proxySmtp?: 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: '{}', array: true, length: 128, default: '{}',
}) })
public aliases: string[]; 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, host: emoji.host,
// || emoji.originalUrl してるのは後方互換性のため // || emoji.originalUrl してるのは後方互換性のため
url: emoji.publicUrl || emoji.originalUrl, url: emoji.publicUrl || emoji.originalUrl,
license: emoji.license,
}; };
}, },

View file

@ -12,7 +12,6 @@ import {
Channels, Channels,
} from "../index.js"; } from "../index.js";
import type { Packed } from "@/misc/schema.js"; import type { Packed } from "@/misc/schema.js";
import { nyaize } from "@/misc/nyaize.js";
import { awaitAll } from "@/prelude/await-all.js"; import { awaitAll } from "@/prelude/await-all.js";
import { import {
convertLegacyReaction, 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) : []; const tokens = packed.text ? mfm.parse(packed.text) : [];
mfm.inspect(tokens, (node) => { mfm.inspect(tokens, (node) => {
if (node.type === "text") { if (node.type === "text") {
@ -272,7 +271,7 @@ export const NoteRepository = db.getRepository(Note).extend({
} }
}); });
packed.text = mfm.toString(tokens); packed.text = mfm.toString(tokens);
} } */
return packed; return packed;
}, },

View file

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

View file

@ -13,6 +13,7 @@ import processDb from "./processors/db/index.js";
import processObjectStorage from "./processors/object-storage/index.js"; import processObjectStorage from "./processors/object-storage/index.js";
import processSystemQueue from "./processors/system/index.js"; import processSystemQueue from "./processors/system/index.js";
import processWebhookDeliver from "./processors/webhook-deliver.js"; import processWebhookDeliver from "./processors/webhook-deliver.js";
import processBackground from "./processors/background/index.js";
import { endedPollNotification } from "./processors/ended-poll-notification.js"; import { endedPollNotification } from "./processors/ended-poll-notification.js";
import { queueLogger } from "./logger.js"; import { queueLogger } from "./logger.js";
import { getJobInfo } from "./get-job-info.js"; import { getJobInfo } from "./get-job-info.js";
@ -24,6 +25,7 @@ import {
objectStorageQueue, objectStorageQueue,
endedPollNotificationQueue, endedPollNotificationQueue,
webhookDeliverQueue, webhookDeliverQueue,
backgroundQueue,
} from "./queues.js"; } from "./queues.js";
import type { ThinUser } from "./types.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( export function webhookDeliver(
webhook: Webhook, webhook: Webhook,
type: typeof webhookEventTypes[number], type: typeof webhookEventTypes[number],
@ -454,6 +467,7 @@ export default function () {
webhookDeliverQueue.process(64, processWebhookDeliver); webhookDeliverQueue.process(64, processWebhookDeliver);
processDb(dbQueue); processDb(dbQueue);
processObjectStorage(objectStorageQueue); processObjectStorage(objectStorageQueue);
processBackground(backgroundQueue);
systemQueue.add( systemQueue.add(
"tickCharts", "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, originalUrl: driveFile.url,
publicUrl: driveFile.webpublicUrl ?? driveFile.url, publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type, type: driveFile.webpublicType ?? driveFile.type,
license: emojiInfo.license,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); }).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
} }

View file

@ -27,6 +27,7 @@ export const webhookDeliverQueue = initializeQueue<WebhookDeliverJobData>(
"webhookDeliver", "webhookDeliver",
64, 64,
); );
export const backgroundQueue = initializeQueue<Record<string, unknown>>("bg");
export const queues = [ export const queues = [
systemQueue, systemQueue,
@ -36,4 +37,5 @@ export const queues = [
dbQueue, dbQueue,
objectStorageQueue, objectStorageQueue,
webhookDeliverQueue, webhookDeliverQueue,
backgroundQueue,
]; ];

View file

@ -198,9 +198,36 @@ export async function createPerson(
const url = getOneApHrefNullable(person.url); const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith("https://")) { 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 // Create user
let user: IRemoteUser; let user: IRemoteUser;
try { try {
@ -228,6 +255,16 @@ export async function createPerson(
followersUri: person.followers followersUri: person.followers
? getApId(person.followers) ? getApId(person.followers)
: undefined, : 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, featured: person.featured ? getApId(person.featured) : undefined,
uri: person.id, uri: person.id,
tags, tags,
@ -396,7 +433,34 @@ export async function updatePerson(
const url = getOneApHrefNullable(person.url); const url = getOneApHrefNullable(person.url);
if (url && !url.startsWith("https://")) { 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 = { const updates = {
@ -406,6 +470,16 @@ export async function updatePerson(
person.sharedInbox || person.sharedInbox ||
(person.endpoints ? person.endpoints.sharedInbox : undefined), (person.endpoints ? person.endpoints.sharedInbox : undefined),
followersUri: person.followers ? getApId(person.followers) : 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, featured: person.featured,
emojis: emojiNames, emojis: emojiNames,
name: truncate(person.name, nameLength), 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_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_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_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_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_deleteAllFiles from "./endpoints/admin/federation/delete-all-files.js";
import * as ep___admin_federation_refreshRemoteInstanceMetadata from "./endpoints/admin/federation/refresh-remote-instance-metadata.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_relays_remove from "./endpoints/admin/relays/remove.js";
import * as ep___admin_resetPassword from "./endpoints/admin/reset-password.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_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_sendEmail from "./endpoints/admin/send-email.js";
import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js"; import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js";
import * as ep___admin_showModerationLogs from "./endpoints/admin/show-moderation-logs.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_folders_update from "./endpoints/drive/folders/update.js";
import * as ep___drive_stream from "./endpoints/drive/stream.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___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___endpoint from "./endpoints/endpoint.js";
import * as ep___endpoints from "./endpoints/endpoints.js"; import * as ep___endpoints from "./endpoints/endpoints.js";
import * as ep___exportCustomEmojis from "./endpoints/export-custom-emojis.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/remove-aliases-bulk", ep___admin_emoji_removeAliasesBulk],
["admin/emoji/set-aliases-bulk", ep___admin_emoji_setAliasesBulk], ["admin/emoji/set-aliases-bulk", ep___admin_emoji_setAliasesBulk],
["admin/emoji/set-category-bulk", ep___admin_emoji_setCategoryBulk], ["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/emoji/update", ep___admin_emoji_update],
["admin/federation/delete-all-files", ep___admin_federation_deleteAllFiles], ["admin/federation/delete-all-files", ep___admin_federation_deleteAllFiles],
[ [
@ -390,6 +394,7 @@ const eps = [
["admin/relays/remove", ep___admin_relays_remove], ["admin/relays/remove", ep___admin_relays_remove],
["admin/reset-password", ep___admin_resetPassword], ["admin/reset-password", ep___admin_resetPassword],
["admin/resolve-abuse-user-report", ep___admin_resolveAbuseUserReport], ["admin/resolve-abuse-user-report", ep___admin_resolveAbuseUserReport],
["admin/search/index-all", ep___admin_search_indexAll],
["admin/send-email", ep___admin_sendEmail], ["admin/send-email", ep___admin_sendEmail],
["admin/server-info", ep___admin_serverInfo], ["admin/server-info", ep___admin_serverInfo],
["admin/show-moderation-logs", ep___admin_showModerationLogs], ["admin/show-moderation-logs", ep___admin_showModerationLogs],
@ -471,6 +476,7 @@ const eps = [
["drive/folders/update", ep___drive_folders_update], ["drive/folders/update", ep___drive_folders_update],
["drive/stream", ep___drive_stream], ["drive/stream", ep___drive_stream],
["email-address/available", ep___emailAddress_available], ["email-address/available", ep___emailAddress_available],
["emoji", ep___emoji],
["endpoint", ep___endpoint], ["endpoint", ep___endpoint],
["endpoints", ep___endpoints], ["endpoints", ep___endpoints],
["export-custom-emojis", ep___exportCustomEmojis], ["export-custom-emojis", ep___exportCustomEmojis],

View file

@ -49,6 +49,7 @@ export default define(meta, paramDef, async (ps, me) => {
originalUrl: file.url, originalUrl: file.url,
publicUrl: file.webpublicUrl ?? file.url, publicUrl: file.webpublicUrl ?? file.url,
type: file.webpublicType ?? file.type, type: file.webpublicType ?? file.type,
license: null,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); }).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache!.remove(["meta_emojis"]); await db.queryResultCache!.remove(["meta_emojis"]);

View file

@ -73,6 +73,7 @@ export default define(meta, paramDef, async (ps, me) => {
originalUrl: driveFile.url, originalUrl: driveFile.url,
publicUrl: driveFile.webpublicUrl ?? driveFile.url, publicUrl: driveFile.webpublicUrl ?? driveFile.url,
type: driveFile.webpublicType ?? driveFile.type, type: driveFile.webpublicType ?? driveFile.type,
license: emoji.license,
}).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); }).then((x) => Emojis.findOneByOrFail(x.identifiers[0]));
await db.queryResultCache!.remove(["meta_emojis"]); await db.queryResultCache!.remove(["meta_emojis"]);

View file

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

View file

@ -55,6 +55,11 @@ export const meta = {
optional: false, optional: false,
nullable: 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", type: "string",
}, },
}, },
license: {
type: "string",
nullable: true,
},
}, },
required: ["id", "name", "aliases"], required: ["id", "name", "aliases"],
} as const; } as const;
@ -48,6 +52,7 @@ export default define(meta, paramDef, async (ps) => {
name: ps.name, name: ps.name,
category: ps.category, category: ps.category,
aliases: ps.aliases, aliases: ps.aliases,
license: ps.license,
}); });
await db.queryResultCache!.remove(["meta_emojis"]); await db.queryResultCache!.remove(["meta_emojis"]);

View file

@ -3,6 +3,7 @@ import {
inboxQueue, inboxQueue,
dbQueue, dbQueue,
objectStorageQueue, objectStorageQueue,
backgroundQueue,
} from "@/queue/queues.js"; } from "@/queue/queues.js";
import define from "../../../define.js"; import define from "../../../define.js";
@ -37,6 +38,11 @@ export const meta = {
nullable: false, nullable: false,
ref: "QueueCount", ref: "QueueCount",
}, },
backgroundQueue: {
optional: false,
nullable: false,
ref: "QueueCount",
},
}, },
}, },
} as const; } as const;
@ -52,11 +58,13 @@ export default define(meta, paramDef, async (ps) => {
const inboxJobCounts = await inboxQueue.getJobCounts(); const inboxJobCounts = await inboxQueue.getJobCounts();
const dbJobCounts = await dbQueue.getJobCounts(); const dbJobCounts = await dbQueue.getJobCounts();
const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); const objectStorageJobCounts = await objectStorageQueue.getJobCounts();
const backgroundJobCounts = await backgroundQueue.getJobCounts();
return { return {
deliver: deliverJobCounts, deliver: deliverJobCounts,
inbox: inboxJobCounts, inbox: inboxJobCounts,
db: dbJobCounts, db: dbJobCounts,
objectStorage: objectStorageJobCounts, 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 }, folderId: { type: "string", format: "misskey:id", nullable: true },
name: { type: "string" }, name: { type: "string" },
isSensitive: { type: "boolean" }, isSensitive: { type: "boolean" },
comment: { type: "string", nullable: true, maxLength: 512 }, comment: { type: "string", nullable: true, maxLength: DB_MAX_IMAGE_COMMENT_LENGTH },
}, },
required: ["fileId"], required: ["fileId"],
} as const; } 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); throw new ApiError(meta.errors.contentRequired);
} }

View file

@ -1,7 +1,9 @@
import { In } from "typeorm"; import { In } from "typeorm";
import { Notes } from "@/models/index.js"; import { Notes } from "@/models/index.js";
import { Note } from "@/models/entities/note.js";
import config from "@/config/index.js"; import config from "@/config/index.js";
import es from "../../../../db/elasticsearch.js"; import es from "../../../../db/elasticsearch.js";
import sonic from "../../../../db/sonic.js";
import define from "../../define.js"; import define from "../../define.js";
import { makePaginationQuery } from "../../common/make-pagination-query.js"; import { makePaginationQuery } from "../../common/make-pagination-query.js";
import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; import { generateVisibilityQuery } from "../../common/generate-visibility-query.js";
@ -59,7 +61,7 @@ export const paramDef = {
} as const; } as const;
export default define(meta, paramDef, async (ps, me) => { export default define(meta, paramDef, async (ps, me) => {
if (es == null) { if (es == null && sonic == null) {
const query = makePaginationQuery( const query = makePaginationQuery(
Notes.createQueryBuilder("note"), Notes.createQueryBuilder("note"),
ps.sinceId, ps.sinceId,
@ -92,9 +94,82 @@ export default define(meta, paramDef, async (ps, me) => {
if (me) generateMutedUserQuery(query, me); if (me) generateMutedUserQuery(query, me);
if (me) generateBlockedUserQuery(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); 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 { } else {
const userQuery = const userQuery =
ps.userId != null ps.userId != null

View file

@ -33,10 +33,10 @@ export function apiAccountMastodon(router: Router): void {
let acct = data.data; let acct = data.data;
acct.id = convertId(acct.id, IdType.MastodonId); acct.id = convertId(acct.id, IdType.MastodonId);
acct.url = `${BASE_URL}/@${acct.url}`; acct.url = `${BASE_URL}/@${acct.url}`;
acct.note = ""; acct.note = acct.note || "";
acct.avatar_static = acct.avatar; acct.avatar_static = acct.avatar;
acct.header = acct.header || ""; acct.header = acct.header || "https://http.cat/404";
acct.header_static = acct.header || ""; acct.header_static = acct.header || "https://http.cat/404";
acct.source = { acct.source = {
note: acct.note, note: acct.note,
fields: acct.fields, fields: acct.fields,
@ -339,7 +339,12 @@ export function apiAccountMastodon(router: Router): void {
return; return;
} }
const data = await client.getRelationships(ids); let reqIds = [];
for (let i = 0; i < ids.length; i++) {
reqIds.push(convertId(ids[i], IdType.CalckeyId));
}
const data = await client.getRelationships(reqIds);
let resp = data.data; let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) { for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId); 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 accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { 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; let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) { for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId); 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 accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { try {
const data = await client.getFavourites(ctx.query as any); const data = await client.getFavourites(limitToInt(ctx.query as any));
let resp = data.data; let resp = data.data;
for (let statIdx = 0; statIdx < resp.length; statIdx++) { for (let statIdx = 0; statIdx < resp.length; statIdx++) {
resp[statIdx].id = convertId(resp[statIdx].id, IdType.MastodonId); 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 accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { try {
const data = await client.getMutes(ctx.query as any); const data = await client.getMutes(limitToInt(ctx.query as any));
let resp = data.data; let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) { for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId); 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 accessTokens = ctx.headers.authorization;
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { try {
const data = await client.getBlocks(ctx.query as any); const data = await client.getBlocks(limitToInt(ctx.query as any));
let resp = data.data; let resp = data.data;
for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) { for (let acctIdx = 0; acctIdx < resp.length; acctIdx++) {
resp[acctIdx].id = convertId(resp[acctIdx].id, IdType.MastodonId); 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 axios from "axios";
import querystring from 'node:querystring' import querystring from 'node:querystring'
import qs from 'qs' import qs from 'qs'
import { limitToInt } from "./timeline.js";
function normalizeQuery(data: any) { function normalizeQuery(data: any) {
const str = querystring.stringify(data); const str = querystring.stringify(data);
return qs.parse(str); return qs.parse(str);
@ -101,9 +103,14 @@ export function apiStatusMastodon(router: Router): void {
const client = getClient(BASE_URL, accessTokens); const client = getClient(BASE_URL, accessTokens);
try { try {
const id = ctx.params.id; 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 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}`, `${BASE_URL}/api/notes/reactions?noteId=${id}`,
); );
const reactions: IReaction[] = reactionsAxios.data; const reactions: IReaction[] = reactionsAxios.data;

View file

@ -15,13 +15,16 @@ export function limitToInt(q: ParsedUrlQuery) {
} }
export function argsToBools(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; let object: any = q;
if (q.only_media) if (q.only_media)
if (typeof q.only_media === "string") 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 (q.exclude_replies)
if (typeof q.exclude_replies === "string") if (typeof q.exclude_replies === "string")
object.exclude_replies = q.exclude_replies.toLowerCase() === "true"; object.exclude_replies = toBoolean(q.exclude_replies);
return q; return q;
} }
@ -92,8 +95,8 @@ export function apiTimelineMastodon(router: Router): void {
try { try {
const query: any = ctx.query; const query: any = ctx.query;
const data = query.local const data = query.local
? await client.getLocalTimeline(limitToInt(query)) ? await client.getLocalTimeline(argsToBools(limitToInt(query)))
: await client.getPublicTimeline(limitToInt(query)); : await client.getPublicTimeline(argsToBools(limitToInt(query)));
ctx.body = toTextWithReaction(data.data, ctx.hostname); ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
@ -111,7 +114,7 @@ export function apiTimelineMastodon(router: Router): void {
try { try {
const data = await client.getTagTimeline( const data = await client.getTagTimeline(
ctx.params.hashtag, ctx.params.hashtag,
limitToInt(ctx.query), argsToBools(limitToInt(ctx.query)),
); );
ctx.body = toTextWithReaction(data.data, ctx.hostname); ctx.body = toTextWithReaction(data.data, ctx.hostname);
} catch (e: any) { } catch (e: any) {

View file

@ -6,27 +6,38 @@ main {
border-radius: 10px; border-radius: 10px;
} }
#tl > div { #tl > div {
padding: 16px; border: 1px solid #908caa;
border-bottom: 1px solid #908caa; border-radius: 10px;
margin: 10px;
padding: 10px;
width: fit-content;
} }
#tl > div > header { #tl > div > header {
font-weight: 700; font-weight: 700;
display: inline-flex;
} }
* { img {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; border-radius: 10px;
margin-right: 10px;
} }
#form {
text-align: center;
}
#calckey_app { #calckey_app {
display: none !important; display: none !important;
} }
body, body,
html { html {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
background-color: #191724; background-color: #191724;
color: #e0def4; color: #e0def4;
justify-content: center; justify-content: center;
margin: auto; margin: auto;
padding: 10px; padding: 10px;
text-align: center;
} }
button { button {
border-radius:999px; border-radius:999px;

View file

@ -45,12 +45,27 @@ window.onload = async () => {
const tl = document.getElementById("tl"); const tl = document.getElementById("tl");
for (const note of notes) { for (const note of notes) {
const el = document.createElement("div"); 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}`; name.textContent = `${note.user.name} @${note.user.username}`;
avatar.src = note.user.avatarUrl;
avatar.style = 'height: 40px'
const text = document.createElement("div"); const text = document.createElement("div");
text.textContent = `${note.text}`; text.textContent = `${note.text}`;
el.appendChild(name); el.appendChild(header);
header.appendChild(avatar);
header.appendChild(name);
if (note.text) {
el.appendChild(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); tl.appendChild(el);
} }
}); });

View file

@ -1,5 +1,6 @@
import * as mfm from "mfm-js"; import * as mfm from "mfm-js";
import es from "../../db/elasticsearch.js"; import es from "../../db/elasticsearch.js";
import sonic from "../../db/sonic.js";
import { import {
publishMainStream, publishMainStream,
publishNotesStream, publishNotesStream,
@ -588,7 +589,7 @@ export default async (
} }
// Register to search database // Register to search database
index(note); await index(note);
}); });
async function renderNoteOrRenoteActivity(data: Option, note: Note) { async function renderNoteOrRenoteActivity(data: Option, note: Note) {
@ -728,10 +729,11 @@ async function insertNote(
} }
} }
function index(note: Note) { export async function index(note: Note): Promise<void> {
if (note.text == null || config.elasticsearch == null) return; if (!note.text) return;
es!.index({ if (config.elasticsearch && es) {
es.index({
index: config.elasticsearch.index || "misskey_note", index: config.elasticsearch.index || "misskey_note",
id: note.id.toString(), id: note.id.toString(),
body: { body: {
@ -742,6 +744,21 @@ function index(note: Note) {
}); });
} }
if (sonic) {
await sonic.ingest.push(
sonic.collection,
sonic.bucket,
JSON.stringify({
id: note.id,
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
}),
note.text,
);
}
}
async function notifyToWatchersOfRenotee( async function notifyToWatchersOfRenotee(
renote: Note, renote: Note,
user: { id: User["id"] }, user: { id: User["id"] },

View file

@ -78,6 +78,7 @@
"uuid": "9.0.0", "uuid": "9.0.0",
"vanilla-tilt": "1.8.0", "vanilla-tilt": "1.8.0",
"vite": "^4.1.1", "vite": "^4.1.1",
"vite-plugin-compression": "^0.5.1",
"vue": "3.2.45", "vue": "3.2.45",
"vue-isyourpasswordsafe": "^2.0.0", "vue-isyourpasswordsafe": "^2.0.0",
"vue-plyr": "^7.0.0", "vue-plyr": "^7.0.0",

View file

@ -1,5 +1,5 @@
<template> <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> <b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
<span v-if="!modelValue">{{ label }}</span> <span v-if="!modelValue">{{ label }}</span>
</button> </button>
@ -36,6 +36,8 @@ const toggle = () => {
<style lang="scss" scoped> <style lang="scss" scoped>
.nrvgflfu { .nrvgflfu {
position: relative;
z-index: 2;
display: inline-block; display: inline-block;
padding: 4px 8px; padding: 4px 8px;
font-size: 0.8em; font-size: 0.8em;
@ -44,6 +46,7 @@ const toggle = () => {
padding: 6px 10px; padding: 6px 10px;
width: 90%; width: 90%;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--divider);
margin-top: 10px; margin-top: 10px;
margin-bottom: 10px; margin-bottom: 10px;
transition: background-color 0.25s ease-in-out; transition: background-color 0.25s ease-in-out;

View file

@ -14,9 +14,11 @@
</div> </div>
<header v-if="title" :class="$style.title"><Mfm :text="title"/></header> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header>
<div v-if="text" :class="$style.text"><Mfm :text="text"/></div> <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> <template v-if="input.type === 'password'" #prefix><i class="ph-password ph-bold ph-lg"></i></template>
</MkInput> </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> <MkSelect v-if="select" v-model="selectedValue" autofocus>
<template v-if="select.items"> <template v-if="select.items">
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option> <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 MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/form/input.vue'; import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import MkSelect from '@/components/form/select.vue'; import MkSelect from '@/components/form/select.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';

View file

@ -144,13 +144,12 @@ onBeforeUnmount(() => {
display: inline-block; display: inline-block;
font-weight: bold; font-weight: bold;
color: var(--accent); color: var(--accent);
background: transparent;
border: solid 1px var(--accent); border: solid 1px var(--accent);
padding: 0; padding: 0;
height: 31px; height: 31px;
font-size: 16px; font-size: 16px;
border-radius: 32px; border-radius: 32px;
background: #fff; background: var(--accentedBg);
&.full { &.full {
padding: 0 8px 0 12px; padding: 0 8px 0 12px;

View file

@ -1,6 +1,6 @@
<template> <template>
<component :is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" <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> <slot></slot>
<i v-if="target === '_blank'" class="ph-arrow-square-out ph-bold ph-lg icon"></i> <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"> <div class="hoawjimk">
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }"> <div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container" :class="{ dmWidth: inDm }">
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length" @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))"> <template v-for="media in mediaList.filter(media => previewable(media))">
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/> <XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/> <XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>

View file

@ -1,12 +1,12 @@
<template> <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=""> <img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
<span class="main"> <span class="main">
<span class="username">@{{ username }}</span> <span class="username">@{{ username }}</span>
<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span> <span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
</span> </span>
</MkA> </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="main">
<span class="username">@{{ username }}</span> <span class="username">@{{ username }}</span>
<span class="host">@{{ toUnicode(host) }}</span> <span class="host">@{{ toUnicode(host) }}</span>
@ -42,8 +42,13 @@ const bgCss = bg.toRgbString();
<style lang="scss" scoped> <style lang="scss" scoped>
.akbvjaqn { .akbvjaqn {
display: inline-block; display: inline-block;
padding: 4px 8px 4px 4px; padding: 2px 8px 2px 2px;
margin-block: 2px;
border-radius: 999px; border-radius: 999px;
max-width: 100%;
white-space: nowrap;
overflow: clip;
text-overflow: ellipsis;
color: var(--mention); color: var(--mention);
&.isMe { &.isMe {

View file

@ -25,7 +25,7 @@
</template> </template>
</I18n> </I18n>
<div class="info"> <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> <i v-if="isMyRenote" class="ph-dots-three-outline ph-bold ph-lg dropdownIcon"></i>
<MkTime :time="note.createdAt"/> <MkTime :time="note.createdAt"/>
</button> </button>
@ -33,19 +33,20 @@
</div> </div>
</div> </div>
</div> </div>
<article class="article" @contextmenu.stop="onContextmenu" @click.self="router.push(notePage(appearNote))"> <article class="article" @contextmenu.stop="onContextmenu" @click="noteClick">
<div class="main" @click.self="router.push(notePage(appearNote))"> <div class="main">
<div class="header-container"> <div class="header-container">
<MkAvatar class="avatar" :user="appearNote.user"/> <MkAvatar class="avatar" :user="appearNote.user"/>
<XNoteHeader class="header" :note="appearNote" :mini="true"/> <XNoteHeader class="header" :note="appearNote" :mini="true"/>
</div> </div>
<div class="body"> <div class="body">
<p v-if="appearNote.cw != null" class="cw"> <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"/> <XCwButton v-model="showContent" :note="appearNote"/>
</p> </p>
<div v-show="appearNote.cw == null || showContent" class="content" :class="{ collapsed, isLong }"> <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"/> <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> --> <!-- <a v-if="appearNote.renote != null" class="rp">RN:</a> -->
<div v-if="translating || translation" class="translation"> <div v-if="translating || translation" class="translation">
@ -61,22 +62,23 @@
</div> </div>
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> <XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/> <MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" class="url-preview"/>
<div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote"/></div> <div v-if="appearNote.renote" class="renote"><XNoteSimple :note="appearNote.renote" @click.stop="router.push(notePage(appearNote.renote))"/></div>
<button v-if="isLong && collapsed" class="fade _button" @click.stop.prevent="collapsed = false"> <button v-if="isLong && collapsed" class="fade _button" @click.stop="collapsed = false">
<span>{{ i18n.ts.showMore }}</span> <span>{{ i18n.ts.showMore }}</span>
</button> </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> <span>{{ i18n.ts.showLess }}</span>
</button> </button>
</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> <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> </div>
<footer class="footer"> <footer ref="el" class="footer" @click.stop>
<XReactionsViewer ref="reactionsViewer" :note="appearNote"/> <XReactionsViewer ref="reactionsViewer" :note="appearNote"/>
<button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()"> <button v-tooltip.noDelay.bottom="i18n.ts.reply" class="button _button" @click="reply()">
<template v-if="appearNote.reply"><i class="ph-arrow-u-up-left ph-bold ph-lg"></i></template> <i class="ph-arrow-u-up-left ph-bold ph-lg"></i>
<template v-else><i class="ph-arrow-bend-up-left ph-bold ph-lg"></i></template> <template v-if="appearNote.repliesCount > 0">
<p v-if="appearNote.repliesCount > 0" class="count">{{ appearNote.repliesCount }}</p> <p class="count">{{ appearNote.repliesCount }}</p>
</template>
</button> </button>
<XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/> <XRenoteButton ref="renoteButton" class="button" :note="appearNote" :count="appearNote.renoteCount"/>
<XStarButton v-if="appearNote.myReaction == null" ref="starButton" class="button" :note="appearNote"/> <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> <i class="ph-dots-three-outline ph-bold ph-lg"></i>
</button> </button>
</footer> </footer>
<!-- <MkNoteFooter :note="appearNote"></MkNoteFooter> -->
</div> </div>
</article> </article>
</div> </div>
@ -113,15 +116,14 @@ import type * as misskey from 'calckey-js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
import XNoteHeader from '@/components/MkNoteHeader.vue'; import XNoteHeader from '@/components/MkNoteHeader.vue';
import XNoteSimple from '@/components/MkNoteSimple.vue'; import XNoteSimple from '@/components/MkNoteSimple.vue';
import XReactionsViewer from '@/components/MkReactionsViewer.vue';
import XMediaList from '@/components/MkMediaList.vue'; import XMediaList from '@/components/MkMediaList.vue';
import XCwButton from '@/components/MkCwButton.vue'; import XCwButton from '@/components/MkCwButton.vue';
import XPoll from '@/components/MkPoll.vue'; import XPoll from '@/components/MkPoll.vue';
import XStarButton from '@/components/MkStarButton.vue';
import XRenoteButton from '@/components/MkRenoteButton.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 XQuoteButton from '@/components/MkQuoteButton.vue';
import MkUrlPreview from '@/components/MkUrlPreview.vue'; import MkUrlPreview from '@/components/MkUrlPreview.vue';
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
import MkVisibility from '@/components/MkVisibility.vue'; import MkVisibility from '@/components/MkVisibility.vue';
import { pleaseLogin } from '@/scripts/please-login'; import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus'; import { focusPrev, focusNext } from '@/scripts/focus';
@ -187,7 +189,6 @@ const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
const translation = ref(null); const translation = ref(null);
const translating = ref(false); const translating = ref(false);
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)).slice(0, 5) : null; 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 = { const keymap = {
'r': () => reply(true), 'r': () => reply(true),
@ -296,6 +297,14 @@ function focusAfter() {
focusNext(el.value); focusNext(el.value);
} }
function noteClick(e) {
if (document.getSelection().type === 'Range') {
e.stopPropagation();
} else {
router.push(notePage(appearNote))
}
}
function readPromo() { function readPromo() {
os.api('promo/read', { os.api('promo/read', {
noteId: appearNote.id, noteId: appearNote.id,
@ -342,9 +351,13 @@ function readPromo() {
} }
} }
&:hover > .article > .main > .footer > .button { & > .article > .main {
&:hover, &:focus-within {
:deep(.footer .button) {
opacity: 1; opacity: 1;
} }
}
}
> .reply-to { > .reply-to {
& + .note-context { & + .note-context {
@ -352,9 +365,9 @@ function readPromo() {
content: ""; content: "";
display: block; display: block;
margin-bottom: -10px; margin-bottom: -10px;
width: 2px; margin-top: 16px;
background-color: var(--divider); border-left: 2px solid var(--divider);
margin-inline: auto; margin-left: calc((var(--avatarSize) / 2) - 1px);
} }
} }
} }
@ -477,7 +490,6 @@ function readPromo() {
> .body { > .body {
margin-top: .7em; margin-top: .7em;
overflow: hidden;
> .cw { > .cw {
cursor: default; cursor: default;
@ -585,6 +597,10 @@ function readPromo() {
padding: 16px; padding: 16px;
border: solid 1px var(--renote); border: solid 1px var(--renote);
border-radius: 8px; border-radius: 8px;
transition: background .2s;
&:hover, &:focus-within {
background-color: var(--panelHighlight);
}
} }
} }
} }
@ -594,10 +610,13 @@ function readPromo() {
font-size: 80%; font-size: 80%;
} }
} }
> .footer { > .footer {
position: relative;
z-index: 2;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
pointer-events: none; // Allow clicking anything w/out pointer-events: all; to open post
> .button { > .button {
margin: 0; margin: 0;
padding: 8px; padding: 8px;
@ -606,6 +625,8 @@ function readPromo() {
max-width: 3.5em; max-width: 3.5em;
width: max-content; width: max-content;
min-width: max-content; min-width: max-content;
pointer-events: all;
transition: opacity .2s;
&:first-of-type { &:first-of-type {
margin-left: -.5em; margin-left: -.5em;
} }
@ -627,6 +648,7 @@ function readPromo() {
} }
} }
> .reply { > .reply {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
} }

View file

@ -9,8 +9,8 @@
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :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-for="note in conversation" :key="note.id" class="reply-to-more" :note="note"/>
<MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to" @click.self="router.push(notePage(appearNote))"/> <MkNoteSub v-if="appearNote.reply" :note="appearNote.reply" class="reply-to"/>
<div v-if="isRenote" class="renote"> <div v-if="isRenote" class="renote">
<MkAvatar class="avatar" :user="note.user"/> <MkAvatar class="avatar" :user="note.user"/>
<i class="ph-repeat ph-bold ph-lg"></i> <i class="ph-repeat ph-bold ph-lg"></i>
@ -29,7 +29,7 @@
<MkVisibility :note="note"/> <MkVisibility :note="note"/>
</div> </div>
</div> </div>
<article class="article" @contextmenu.stop="onContextmenu"> <article ref="noteEl" class="article" @contextmenu.stop="onContextmenu" tabindex="-1">
<header class="header"> <header class="header">
<MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/> <MkAvatar class="avatar" :user="appearNote.user" :show-indicator="true"/>
<div class="body"> <div class="body">
@ -48,12 +48,13 @@
</header> </header>
<div class="main"> <div class="main">
<div class="body"> <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"/> <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"/> <XCwButton v-model="showContent" :note="appearNote"/>
</p> </div>
<div v-show="appearNote.cw == null || showContent" class="content"> <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"/> <Mfm v-if="appearNote.text" :text="appearNote.text" :author="appearNote.user" :i="$i" :custom-emojis="appearNote.emojis"/>
<div v-if="translating || translation" class="translation"> <div v-if="translating || translation" class="translation">
<MkLoading v-if="translating" mini/> <MkLoading v-if="translating" mini/>
@ -68,7 +69,7 @@
</div> </div>
<XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/> <XPoll v-if="appearNote.poll" ref="pollViewer" :note="appearNote" class="poll"/>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" class="url-preview"/> <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> </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}`"><i class="ph-television ph-bold ph-lg"></i> {{ appearNote.channel.name }}</MkA>
</div> </div>
@ -99,7 +100,7 @@
</footer> </footer>
</div> </div>
</article> </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>
<div v-else class="_panel muted" @click="muted = false"> <div v-else class="_panel muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small"> <I18n :src="i18n.ts.userSaysSomething" tag="small">
@ -113,7 +114,7 @@
</template> </template>
<script lang="ts" setup> <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 * as mfm from 'mfm-js';
import type * as misskey from 'calckey-js'; import type * as misskey from 'calckey-js';
import MkNoteSub from '@/components/MkNoteSub.vue'; import MkNoteSub from '@/components/MkNoteSub.vue';
@ -175,6 +176,7 @@ const isRenote = (
); );
const el = ref<HTMLElement>(); const el = ref<HTMLElement>();
const noteEl = $ref();
const menuButton = ref<HTMLElement>(); const menuButton = ref<HTMLElement>();
const starButton = ref<InstanceType<typeof XStarButton>>(); const starButton = ref<InstanceType<typeof XStarButton>>();
const renoteButton = ref<InstanceType<typeof XRenoteButton>>(); const renoteButton = ref<InstanceType<typeof XRenoteButton>>();
@ -192,6 +194,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
const conversation = ref<misskey.entities.Note[]>([]); const conversation = ref<misskey.entities.Note[]>([]);
const replies = ref<misskey.entities.Note[]>([]); const replies = ref<misskey.entities.Note[]>([]);
const directReplies = ref<misskey.entities.Note[]>([]); const directReplies = ref<misskey.entities.Note[]>([]);
let isScrolling;
const keymap = { const keymap = {
'r': () => reply(true), 'r': () => reply(true),
@ -281,20 +285,20 @@ function showRenoteMenu(viaKeyboard = false): void {
} }
function focus() { function focus() {
el.value.focus(); noteEl.focus();
} }
function blur() { function blur() {
el.value.blur(); noteEl.blur();
} }
os.api('notes/children', { os.api('notes/children', {
noteId: appearNote.id, noteId: appearNote.id,
limit: 30, limit: 30,
depth: 6, depth: 12,
}).then(res => { }).then(res => {
replies.value = 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) { if (appearNote.replyId) {
@ -302,6 +306,7 @@ if (appearNote.replyId) {
noteId: appearNote.replyId, noteId: appearNote.replyId,
}).then(res => { }).then(res => {
conversation.value = res.reverse(); conversation.value = res.reverse();
focus();
}); });
} }
@ -322,20 +327,32 @@ function onNoteReplied(noteData: NoteUpdatedEvent): void {
} }
document.addEventListener("wheel", () => {
isScrolling = true;
})
onMounted(() => { onMounted(() => {
stream.on("noteUpdated", onNoteReplied); stream.on("noteUpdated", onNoteReplied);
isScrolling = false;
noteEl.scrollIntoView();
}); });
onUpdated(() => {
if (!isScrolling) {
noteEl.scrollIntoView()
}
})
onUnmounted(() => { onUnmounted(() => {
stream.off("noteUpdated", onNoteReplied); stream.off("noteUpdated", onNoteReplied);
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.lxwezrsl { .lxwezrsl {
position: relative; position: relative;
transition: box-shadow 0.1s ease; transition: box-shadow 0.1s ease;
overflow: hidden;
contain: content; contain: content;
&:focus-visible { &:focus-visible {
@ -429,8 +446,14 @@ onUnmounted(() => {
> .article { > .article {
padding: 32px; padding: 32px;
padding-bottom: 6px;
&:last-child {
padding-bottom: 24px;
}
font-size: 1.2em; font-size: 1.2em;
overflow: clip;
outline: none;
scroll-margin-top: calc(var(--stickyTop) + 20vh);
> .header { > .header {
display: flex; display: flex;
position: relative; position: relative;
@ -530,6 +553,10 @@ onUnmounted(() => {
padding: 16px; padding: 16px;
border: solid 1px var(--renote); border: solid 1px var(--renote);
border-radius: 8px; border-radius: 8px;
transition: background .2s;
&:hover, &:focus-within {
background-color: var(--panelHighlight);
}
} }
} }
} }
@ -577,19 +604,65 @@ onUnmounted(() => {
> .reply { > .reply {
border-top: solid 0.5px var(--divider); border-top: solid 0.5px var(--divider);
cursor: pointer; cursor: pointer;
padding-top: 24px;
padding-bottom: 10px;
@media (pointer: coarse) { @media (pointer: coarse) {
cursor: default; cursor: default;
} }
} }
> .reply, .reply-to, .reply-to-more { // Hover
transition: background-color 0.25s ease-in-out; .reply :deep(.main), .reply-to, .reply-to-more, :deep(.more) {
position: relative;
&::before {
content: "";
position: absolute;
inset: -12px -24px;
bottom: -0px;
background: var(--panelHighlight);
border-radius: var(--radius);
opacity: 0;
transition: opacity .2s;
z-index: -1;
}
&.reply-to, &.reply-to-more {
&::before {
inset: 0px 8px;
}
&:first-of-type::before {
top: 12px;
}
}
// &::after {
// content: "";
// position: absolute;
// inset: -9999px;
// background: var(--modalBg);
// opacity: 0;
// z-index: -2;
// pointer-events: none;
// transition: opacity .2s;
// }
&.more::before {
inset: 0 !important;
}
&:hover, &:focus-within {
&::before {
opacity: 1;
}
}
// @media (pointer: coarse) {
// &:has(.button:focus-within) {
// z-index: 2;
// --X13: transparent;
// &::after {
// opacity: 1;
// backdrop-filter: var(--modalBgFilter);
// }
// }
// }
}
&:hover {
background-color: var(--panelHighlight);
}
}
&.max-width_500px { &.max-width_500px {
font-size: 0.9em; font-size: 0.9em;

View file

@ -2,7 +2,7 @@
<header class="kkwtjztg"> <header class="kkwtjztg">
<div class="user-info"> <div class="user-info">
<div> <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"> <MkUserName :user="note.user" class="mkusername">
<span v-if="note.user.isBot" class="is-bot">bot</span> <span v-if="note.user.isBot" class="is-bot">bot</span>
</MkUserName> </MkUserName>
@ -47,6 +47,8 @@ const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultS
<style lang="scss" scoped> <style lang="scss" scoped>
.kkwtjztg { .kkwtjztg {
position: relative;
z-index: 2;
display: flex; display: flex;
align-items: center; align-items: center;
white-space: nowrap; white-space: nowrap;

View file

@ -6,6 +6,7 @@
<div class="body"> <div class="body">
<p v-if="note.cw != null" class="cw"> <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"/> <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"/> <XCwButton v-model="showContent" :note="note"/>
</p> </p>
<div v-show="note.cw == null || showContent" class="content"> <div v-show="note.cw == null || showContent" class="content">

View file

@ -1,28 +1,73 @@
<template> <template>
<div v-size="{ max: [450] }" class="wrpstxzv" :class="{ children: depth > 1 }"> <div ref="el"
<div class="main" @click="router.push(notePage(note))"> 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"> <div class="avatar-container">
<MkAvatar class="avatar" :user="note.user"/> <MkAvatar class="avatar" :user="note.user"/>
<div class="line"></div> <div v-if="(!conversation) || replies.length > 0" class="line"></div>
</div> </div>
<div class="body"> <div class="body">
<XNoteHeader class="header" :note="note" :mini="true"/> <XNoteHeader class="header" :note="note" :mini="true"/>
<div class="body"> <div class="body">
<p v-if="note.cw != null" class="cw"> <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"/> <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"/> <XCwButton v-model="showContent" :note="note"/>
</p> </p>
<div v-show="note.cw == null || showContent" class="content" @click="router.push(notePage(note))"> <div v-show="note.cw == null || showContent" class="content">
<MkSubNoteContent class="text" :note="note"/> <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> </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> </div>
<template v-if="conversation"> <template v-if="conversation">
<template v-if="depth < 5"> <template v-if="replies.length == 1">
<MkNoteSub v-for="reply in replies" :key="reply.id" :note="reply" class="reply" :conversation="conversation" :depth="depth + 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> </template>
<div v-else-if="replies.length > 0" class="more"> <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> <MkA class="text _link" :to="notePage(note)">{{ i18n.ts.continueThread }} <i class="ph-caret-double-right ph-bold ph-lg"></i></MkA>
</div> </div>
</template> </template>
@ -30,21 +75,33 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { inject, ref } from 'vue';
import type { Ref } from 'vue';
import * as misskey from 'calckey-js'; import * as misskey from 'calckey-js';
import XNoteHeader from '@/components/MkNoteHeader.vue'; import XNoteHeader from '@/components/MkNoteHeader.vue';
import MkSubNoteContent from '@/components/MkSubNoteContent.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 XCwButton from '@/components/MkCwButton.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { getNoteMenu } from '@/scripts/get-note-menu';
import { notePage } from '@/filters/note'; import { notePage } from '@/filters/note';
import { useRouter } from '@/router'; import { useRouter } from '@/router';
import * as os from '@/os'; import * as os from '@/os';
import { reactionPicker } from '@/scripts/reaction-picker';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { deepClone } from '@/scripts/clone';
import { useNoteCapture } from '@/scripts/use-note-capture';
const router = useRouter(); const router = useRouter();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
conversation?: misskey.entities.Note[]; conversation?: misskey.entities.Note[];
parentId?;
// how many notes are in between this one and the note being viewed in detail // how many notes are in between this one and the note being viewed in detail
depth?: number; depth?: number;
@ -52,17 +109,96 @@ const props = withDefaults(defineProps<{
depth: 1, 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); 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.wrpstxzv { .wrpstxzv {
padding: 16px 32px; padding: 16px 32px;
&.children { &.children {
padding: 10px 0 0 16px; padding: 10px 0 0 var(--indent);
padding-left: var(--indent) !important;
font-size: 1em; font-size: 1em;
cursor: auto; cursor: auto;
@ -71,6 +207,7 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
} }
} }
> .main { > .main {
display: flex; display: flex;
@ -89,6 +226,9 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
flex: 1; flex: 1;
min-width: 0; min-width: 0;
cursor: pointer; cursor: pointer;
margin: 0 -200px;
padding: 0 200px;
overflow: clip;
@media (pointer: coarse) { @media (pointer: coarse) {
cursor: default; cursor: default;
} }
@ -99,6 +239,17 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
} }
> .body { > .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 { > .cw {
cursor: default; cursor: default;
display: block; display: block;
@ -110,24 +261,113 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
margin-right: 8px; margin-right: 8px;
} }
} }
> .content { > .content {
> .text { > .text {
margin: 0; margin: 0;
padding: 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 { > .reply, > .more {
border-left: solid 0.5px var(--divider);
margin-top: 10px; margin-top: 10px;
&.single {
padding: 0 !important;
> .line {
display: none;
}
}
} }
> .more { > .more {
padding: 10px 0 0 16px; display: flex;
padding-block: 10px;
font-weight: 600;
> .line {
flex-grow: 0 !important;
margin-top: -10px !important;
margin-bottom: 10px !important;
margin-right: 10px !important;
&::before {
border-left-style: dashed !important;
border-bottom-left-radius: 100px !important;
}
}
i {
font-size: 1em !important;
vertical-align: middle !important;
}
a {
position: static;
&::before {
content: "";
position: absolute;
inset: 0;
}
&::after {
content: unset;
}
}
}
&.reply, &.reply-to, &.reply-to-more {
> .main:hover, > .main:focus-within {
:deep(.footer .button) {
opacity: 1;
}
}
} }
&.reply-to, &.reply-to-more { &.reply-to, &.reply-to-more {
@ -135,7 +375,16 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
&:first-child { &:first-child {
padding-top: 30px; padding-top: 30px;
} }
.avatar-container { .line::before {
margin-bottom: -16px;
}
}
// Reply Lines
&.reply, &.reply-to, &.reply-to-more {
--indent: calc(var(--avatarSize) - 5px);
> .main {
> .avatar-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -146,30 +395,90 @@ const replies: misskey.entities.Note[] = props.conversation?.filter(item => item
height: var(--avatarSize); height: var(--avatarSize);
margin: 0; margin: 0;
} }
> .line { }
}
.line {
position: relative;
width: var(--avatarSize); width: var(--avatarSize);
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
margin-bottom: -10px;
&::before { &::before {
content: ""; content: "";
display: block; position: absolute;
width: 2px; border-left: 2px solid var(--X13);
background-color: var(--divider); margin-left: calc((var(--avatarSize) / 2) - 1px);
margin-inline: auto; width: calc(var(--indent) / 2);
.note > & { inset-block: 0;
margin-bottom: -16px; min-height: 8px;
} }
} }
} }
&.reply-to, &.reply-to-more {
> .main > .avatar-container > .line {
margin-bottom: 0px !important;
} }
> .main > .body { }
padding-bottom: 16px; &.single, &.singleStart {
> .main > .avatar-container > .line {
margin-bottom: -10px !important;
}
}
.reply.children:not(:last-child) { // Line that goes through multiple replies
position: relative;
> .line {
position: absolute;
top: 0;
left: 0;
bottom: 0;
}
}
// Reply line connectors
.reply.children:not(.single) {
position: relative;
> .line {
position: absolute;
left: 0;
top: 0;
&::after {
content: "";
position: absolute;
border-left: 2px solid var(--X13);
border-bottom: 2px solid var(--X13);
margin-left: calc((var(--avatarSize) / 2) - 1px);
width: calc(var(--indent) / 2);
height: calc((var(--avatarSize) / 2));
border-bottom-left-radius: calc(var(--indent) / 2);
top: 8px;
}
}
&:not(:last-child) > .line::after {
mask: linear-gradient(to right, transparent 2px, black 2px);
-webkit-mask: linear-gradient(to right, transparent 2px, black 2px);
} }
} }
&.max-width_500px {
:not(.reply) > & {
.reply {
--avatarSize: 24px;
--indent: calc(var(--avatarSize) - 4px);
}
}
&.firstColumn {
> .main, > .line, > .children:not(.single) > .line {
--avatarSize: 35px;
--indent: 35px;
}
> .children:not(.single) {
padding-left: 28px !important;
}
}
}
&.max-width_450px { &.max-width_450px {
padding: 14px 16px; padding: 14px 16px;
&.reply-to, &.reply-to-more { &.reply-to, &.reply-to-more {
padding: 14px 16px;
padding-top: 14px !important; padding-top: 14px !important;
padding-bottom: 0 !important; padding-bottom: 0 !important;
margin-bottom: 0 !important; margin-bottom: 0 !important;

View file

@ -1,7 +1,7 @@
<template> <template>
<div class="tivcixzd" :class="{ done: closed || isVoted }"> <div class="tivcixzd" :class="{ done: closed || isVoted }">
<ul> <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> <div class="backdrop" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
<span> <span>
<template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg"></i></template> <template v-if="choice.isVoted"><i class="ph-check ph-bold ph-lg"></i></template>
@ -13,7 +13,7 @@
<p v-if="!readOnly"> <p v-if="!readOnly">
<span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span> <span>{{ i18n.t('_poll.totalVotes', { n: total }) }}</span>
<span> · </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-if="isVoted">{{ i18n.ts._poll.voted }}</span>
<span v-else-if="closed">{{ i18n.ts._poll.closed }}</span> <span v-else-if="closed">{{ i18n.ts._poll.closed }}</span>
<span v-if="remaining > 0"> · {{ timer }}</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> <span v-if="visibility === 'specified'"><i class="ph-envelope-simple-open ph-bold ph-lg"></i></span>
</button> </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 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> </div>
</header> </header>
<div class="form" :class="{ fixed }"> <div class="form" :class="{ fixed }">
@ -91,6 +91,7 @@ import { instance } from '@/instance';
import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account'; import { $i, getAccounts, openAccountMenu as openAccountMenu_ } from '@/account';
import { uploadFile } from '@/scripts/upload'; import { uploadFile } from '@/scripts/upload';
import { deepClone } from '@/scripts/clone'; import { deepClone } from '@/scripts/clone';
import { nyaize } from '@/scripts/nyaize';
import XCheatSheet from '@/components/MkCheatSheetDialog.vue'; import XCheatSheet from '@/components/MkCheatSheetDialog.vue';
const modal = inject('modal'); const modal = inject('modal');
@ -582,6 +583,10 @@ async function post() {
} }
} }
if ($i?.isCat) {
postData.text = nyaize(`${postData.text}`);
}
let token = undefined; let token = undefined;
if (postAccount) { if (postAccount) {
@ -796,6 +801,8 @@ onMounted(() => {
} }
> .submit { > .submit {
display: inline-flex;
align-items: center;
margin: 16px 16px 16px 0; margin: 16px 16px 16px 0;
padding: 0 12px; padding: 0 12px;
line-height: 34px; line-height: 34px;

View file

@ -4,7 +4,7 @@
ref="buttonRef" ref="buttonRef"
v-ripple="canToggle" v-ripple="canToggle"
class="hkzvhatu _button" class="hkzvhatu _button"
:class="{ reacted: note.myReaction == reaction, canToggle }" :class="{ reacted: note.myReaction == reaction, canToggle, newlyAdded: !isInitial }"
@click="toggleReaction()" @click="toggleReaction()"
> >
<XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/> <XReactionIcon class="icon" :reaction="reaction" :custom-emojis="note.emojis"/>
@ -13,7 +13,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue'; import { computed, ref } from 'vue';
import * as misskey from 'calckey-js'; import * as misskey from 'calckey-js';
import XDetails from '@/components/MkReactionsViewer.details.vue'; import XDetails from '@/components/MkReactionsViewer.details.vue';
import XReactionIcon from '@/components/MkReactionIcon.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) => { useTooltip(buttonRef, async (showing) => {
const reactions = await os.apiGet('notes/reactions', { const reactions = await os.apiGet('notes/reactions', {
noteId: props.note.id, noteId: props.note.id,
@ -97,7 +83,25 @@ useTooltip(buttonRef, async (showing) => {
margin: 2px; margin: 2px;
padding: 0 6px; padding: 0 6px;
border-radius: 4px; 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 { &.canToggle {
background: rgba(0, 0, 0, 0.05); background: rgba(0, 0, 0, 0.05);
@ -119,6 +123,7 @@ useTooltip(buttonRef, async (showing) => {
> .count { > .count {
color: var(--fgOnAccent); color: var(--fgOnAccent);
font-weight: 600;
} }
> .icon { > .icon {

View file

@ -21,7 +21,8 @@ const isMe = computed(() => $i && $i.id === props.note.userId);
<style lang="scss" scoped> <style lang="scss" scoped>
.tdflqwzn { .tdflqwzn {
margin: 4px -2px 0 -2px; margin-inline: -2px;
margin-top: .2em;
width: 100%; width: 100%;
&:empty { &:empty {

View file

@ -2,22 +2,34 @@
<div class="wrmlmaau" :class="{ collapsed, isLong }"> <div class="wrmlmaau" :class="{ collapsed, isLong }">
<div class="body"> <div class="body">
<span v-if="note.deletedAt" style="opacity: 0.5">({{ i18n.ts.deleted }})</span> <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"/> <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> <MkA v-if="note.renoteId" class="rp" :to="`/notes/${note.renoteId}`">{{ i18n.ts.quoteAttached }}: ...</MkA>
</div> </div>
<div v-if="note.files.length > 0"> <div v-if="note.files.length > 0">
<summary>({{ i18n.t('withNFiles', { n: note.files.length }) }})</summary>
<XMediaList :media-list="note.files"/> <XMediaList :media-list="note.files"/>
</div> </div>
<div v-if="note.poll"> <div v-if="note.poll">
<summary>{{ i18n.ts.poll }}</summary> <summary>{{ i18n.ts.poll }}</summary>
<XPoll :note="note"/> <XPoll :note="note"/>
</div> </div>
<button v-if="isLong && collapsed" class="fade _button" @click.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> <span>{{ i18n.ts.showMore }}</span>
</button> </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> <span>{{ i18n.ts.showLess }}</span>
</button> </button>
</div> </div>
@ -26,15 +38,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import * as misskey from 'calckey-js'; 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 XMediaList from '@/components/MkMediaList.vue';
import XPoll from '@/components/MkPoll.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'; import { i18n } from '@/i18n';
const props = defineProps<{ const props = defineProps<{
note: misskey.entities.Note; note: misskey.entities.Note;
parentId?;
conversation?;
detailed?: boolean;
}>(); }>();
const isLong = ( const isLong = (
props.note.cw == null && props.note.text != null && ( props.note.cw == null && props.note.text != null && (
(props.note.text.split('\n').length > 9) || (props.note.text.split('\n').length > 9) ||
@ -42,6 +60,8 @@ const isLong = (
) )
); );
const collapsed = $ref(props.note.cw == null && isLong); const collapsed = $ref(props.note.cw == null && isLong);
const urls = props.note.text ? extractUrlFromMfm(mfm.parse(props.note.text)) : null;
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -49,16 +69,26 @@ const collapsed = $ref(props.note.cw == null && isLong);
overflow-wrap: break-word; overflow-wrap: break-word;
> .body { > .body {
> .reply {
margin-right: 6px;
color: var(--accent);
}
> .rp { > .rp {
margin-left: 4px; margin-left: 4px;
font-style: oblique; font-style: oblique;
color: var(--renote); 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 { &.collapsed {

View file

@ -1,12 +1,12 @@
<template> <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> <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/> <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>
<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> <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>
<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"> <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"> <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}')`"> <div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`">
@ -214,9 +214,10 @@ onUnmounted(() => {
border: 1px solid var(--divider); border: 1px solid var(--divider);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
transition: background .2s;
&:hover { &:hover, &:focus-within {
text-decoration: none; text-decoration: none;
background-color: var(--panelHighlight);
> article > header > h1 { > article > header > h1 {
text-decoration: underline; text-decoration: underline;
} }

View file

@ -1,5 +1,5 @@
<template> <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> <slot></slot>
</a> </a>
</template> </template>

View file

@ -3,7 +3,7 @@
<img class="inner" :src="url" decoding="async"/> <img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/> <MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
</span> </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"/> <img class="inner" :src="url" decoding="async"/>
<MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/> <MkUserOnlineIndicator v-if="showIndicator && user.instance == null" class="indicator" :user="user"/>
</MkA> </MkA>

View file

@ -1,7 +1,7 @@
<template> <template>
<component <component
:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target" :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"> <template v-if="!self">
<span class="schema">{{ schema }}//</span> <span class="schema">{{ schema }}//</span>

View file

@ -1,5 +1,5 @@
<template> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -13,3 +13,9 @@ const props = withDefaults(defineProps<{
nowrap: true, nowrap: true,
}); });
</script> </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": "dodo", "keywords": ["animal", "nature"] },
{ "category": "animals_and_nature", "char": "🪶", "name": "feather", "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": "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", "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": "dragon_face", "keywords": ["animal", "myth", "nature", "chinese", "green"] },
{ "category": "animals_and_nature", "char": "🦧", "name": "orangutan", "keywords": ["animal", "nature"] }, { "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: { export function inputNumber(props: {
title?: string | null; title?: string | null;
text?: string | null; text?: string | null;

View file

@ -22,6 +22,9 @@
<template #label>{{ i18n.ts.tags }}</template> <template #label>{{ i18n.ts.tags }}</template>
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template> <template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
</MkInput> </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> <MkButton danger @click="del()"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.delete }}</MkButton>
</div> </div>
</div> </div>
@ -33,6 +36,7 @@ import { } from 'vue';
import XModalWindow from '@/components/MkModalWindow.vue'; import XModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/form/input.vue'; import MkInput from '@/components/form/input.vue';
import MkTextarea from '@/components/form/textarea.vue';
import * as os from '@/os'; import * as os from '@/os';
import { unique } from '@/scripts/array'; import { unique } from '@/scripts/array';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
@ -47,6 +51,7 @@ let name: string = $ref(props.emoji.name);
let category: string = $ref(props.emoji.category); let category: string = $ref(props.emoji.category);
let aliases: string = $ref(props.emoji.aliases.join(' ')); let aliases: string = $ref(props.emoji.aliases.join(' '));
let categories: string[] = $ref(emojiCategories); let categories: string[] = $ref(emojiCategories);
let license: string = $ref(props.emoji.license ?? '');
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean, updated?: any }): void, (ev: 'done', v: { deleted?: boolean, updated?: any }): void,
@ -63,6 +68,7 @@ async function update() {
name, name,
category, category,
aliases: aliases.split(' '), aliases: aliases.split(' '),
license: license === '' ? null : license,
}); });
emit('done', { emit('done', {
@ -71,6 +77,7 @@ async function update() {
name, name,
category, category,
aliases: aliases.split(' '), aliases: aliases.split(' '),
license: license === '' ? null : license,
}, },
}); });

View file

@ -18,6 +18,7 @@
<MkButton inline @click="addTagBulk">Add tag</MkButton> <MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton> <MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setTagBulk">Set tag</MkButton> <MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="setLicenseBulk">Set license</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton> <MkButton inline danger @click="delBulk">Delete</MkButton>
</div> </div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination"> <MkPagination ref="emojisPaginationComponent" :pagination="pagination">
@ -258,6 +259,18 @@ const setTagBulk = async () => {
emojisPaginationComponent.value.reload(); 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 delBulk = async () => {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', 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="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> <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> </div>
</MkSpacer> </MkSpacer>
</div> </div>
@ -219,6 +219,12 @@ onUnmounted(() => {
ro.disconnect(); ro.disconnect();
}); });
watch(router.currentRef, (to) => {
if (to.route.path === "/admin" && to.child?.route.name == null && !narrow) {
router.replace('/admin/overview');
}
});
provideMetadataReceiver((info) => { provideMetadataReceiver((info) => {
if (info == null) { if (info == null) {
childInfo = null; childInfo = null;

View file

@ -51,13 +51,6 @@ const props = defineProps<{
let channel = $ref(null); let channel = $ref(null);
let showBanner = $ref(true); let showBanner = $ref(true);
const pagination = {
endpoint: 'channels/timeline' as const,
limit: 10,
params: computed(() => ({
channelId: props.channelId,
})),
};
watch(() => props.channelId, async () => { watch(() => props.channelId, async () => {
channel = await os.api('channels/show', { channel = await os.api('channels/show', {
@ -66,14 +59,23 @@ watch(() => props.channelId, async () => {
}, { immediate: true }); }, { immediate: true });
function edit() { function edit() {
router.push(`/channels/${channel.id}/edit`); router.push(`/channels/${channel?.id}/edit`);
} }
const headerActions = $computed(() => channel && channel.userId ? [{ const headerActions = $computed(() => [
...(
channel
&& channel?.userId === $i?.id
? [
{
icon: 'ph-gear-six ph-bold ph-lg', icon: 'ph-gear-six ph-bold ph-lg',
text: i18n.ts.edit, text: i18n.ts.edit,
handler: edit, handler: edit,
}] : null); }
]
: []
),
]);
const headerTabs = $computed(() => []); const headerTabs = $computed(() => []);

View file

@ -3,7 +3,7 @@
<img :src="emoji.url" class="img" :alt="emoji.name"/> <img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body"> <div class="body">
<div class="name _monospace">{{ emoji.name }}</div> <div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div> <div class="info">{{ emoji.aliases.join(" ") }}</div>
</div> </div>
</button> </button>
</template> </template>
@ -20,15 +20,26 @@ const props = defineProps<{
function menu(ev) { function menu(ev) {
os.popupMenu([{ os.popupMenu([{
type: 'label', type: "label",
text: ':' + props.emoji.name + ':', text: ":" + props.emoji.name + ":",
}, { }, {
text: i18n.ts.copy, text: i18n.ts.copy,
icon: 'ph-clipboard-text ph-bold ph-lg', icon: "ph-clipboard-text ph-bold ph-lg",
action: () => { action: () => {
copyToClipboard(`:${props.emoji.name}:`); copyToClipboard(`:${props.emoji.name}:`);
os.success(); 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); }], ev.currentTarget ?? ev.target);
} }
</script> </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="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="_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"> <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> </button>
</div> </div>
</footer> </footer>
@ -57,7 +57,7 @@ const typing = throttle(3000, () => {
}); });
let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); 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); watch([$$(text), $$(file)], saveDraft);

View file

@ -1,7 +1,7 @@
<template> <template>
<MkStickyContainer> <MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="800"> <MkSpacer :content-max="800" :marginMin="6">
<div class="fcuexfpr"> <div class="fcuexfpr">
<transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="note" class="note"> <div v-if="note" class="note">

View file

@ -7,7 +7,7 @@
<div v-if="!narrow || currentPage?.route.name == null" class="nav"> <div v-if="!narrow || currentPage?.route.name == null" class="nav">
<div class="baaadecd"> <div class="baaadecd">
<MkInfo v-if="emailNotConfigured" warn class="info">{{ i18n.ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <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> </div>
<div v-if="!(narrow && currentPage?.route.name == null)" class="main"> <div v-if="!(narrow && currentPage?.route.name == null)" class="main">
@ -230,6 +230,12 @@ onUnmounted(() => {
ro.disconnect(); 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)); const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified));
provideMetadataReceiver((info) => { provideMetadataReceiver((info) => {

View file

@ -167,8 +167,8 @@ const timeForThem = $computed(() => {
const tzInfo = cityTimezones.lookupViaCity(props.user.location!.replace(/\s.*/,'')); const tzInfo = cityTimezones.lookupViaCity(props.user.location!.replace(/\s.*/,''));
if (tzInfo.length == 0) return ""; if (tzInfo.length == 0) return "";
const tz = tzInfo[0].timezone; const tz = tzInfo[0].timezone;
const theirTime = new Date().toLocaleString("en-US", { timeZone: tz, hour12: true }) const theirTime = new Date().toLocaleString("en-US", { timeZone: tz, hour12: false });
return ` (${theirTime.split(",")[1].trim().split(":")[0]} ${theirTime.split(" ")[1].slice(-2)})` return ` (${theirTime.split(",")[1].trim().split(":")[0]}:${theirTime.split(" ")[1].slice(-5,-3)})`;
}) })
function menu(ev) { function menu(ev) {

View file

@ -24,6 +24,8 @@ export const getBuiltinThemes = () =>
[ [
"l-rosepinedawn", "l-rosepinedawn",
"l-light", "l-light",
"l-nord",
"l-gruvbox",
"l-coffee", "l-coffee",
"l-apricot", "l-apricot",
"l-rainy", "l-rainy",
@ -35,6 +37,10 @@ export const getBuiltinThemes = () =>
"d-rosepine", "d-rosepine",
"d-rosepinemoon", "d-rosepinemoon",
"d-dark", "d-dark",
"d-nord",
"d-gruvbox",
"d-catppuccin-frappe",
"d-catppuccin-mocha",
"d-persimmon", "d-persimmon",
"d-astro", "d-astro",
"d-future", "d-future",

View file

@ -32,7 +32,7 @@ html {
overflow-wrap: break-word; overflow-wrap: break-word;
font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif; font-family: "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.35; line-height: 1.6;
text-size-adjust: 100%; text-size-adjust: 100%;
tab-size: 2; tab-size: 2;
@ -88,7 +88,6 @@ html._themeChanging_ {
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
scroll-behavior: smooth;
} }
a { a {
@ -155,6 +154,10 @@ hr {
box-shadow: 0px 4px 32px var(--shadow) !important; box-shadow: 0px 4px 32px var(--shadow) !important;
} }
.swiper {
overflow: clip !important;
}
._button { ._button {
appearance: none; appearance: none;
display: inline-block; display: inline-block;
@ -479,6 +482,7 @@ hr {
} }
._link { ._link {
position: relative;
color: var(--link); color: var(--link);
&:after { &:after {
@ -680,3 +684,19 @@ hr {
width: 1.25em; width: 1.25em;
display: inline-flex; 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 { > .button-wrapper {
display: inline-flex;
> i { justify-content: center;
transform: translateY(0.05em);
}
&.on { &.on {
background-color: var(--accentedBg); background-color: var(--accentedBg);

View file

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

View file

@ -5,6 +5,7 @@ import { defineConfig } from 'vite';
import locales from '../../locales'; import locales from '../../locales';
import meta from '../../package.json'; import meta from '../../package.json';
import pluginJson5 from './vite.json5'; 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']; 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, reactivityTransform: true,
}), }),
pluginJson5(), pluginJson5(),
viteCompression({
algorithm: 'brotliCompress'
}),
], ],
resolve: { resolve: {

View file

@ -55,7 +55,7 @@ importers:
'@bull-board/api': ^4.6.4 '@bull-board/api': ^4.6.4
'@bull-board/koa': ^4.6.4 '@bull-board/koa': ^4.6.4
'@bull-board/ui': ^4.6.4 '@bull-board/ui': ^4.6.4
'@calckey/megalodon': 5.1.2 '@calckey/megalodon': 5.1.21
'@discordapp/twemoji': 14.0.2 '@discordapp/twemoji': 14.0.2
'@elastic/elasticsearch': 7.17.0 '@elastic/elasticsearch': 7.17.0
'@koa/cors': 3.4.3 '@koa/cors': 3.4.3
@ -196,6 +196,7 @@ importers:
seedrandom: ^3.0.5 seedrandom: ^3.0.5
semver: 7.3.8 semver: 7.3.8
sharp: 0.31.3 sharp: 0.31.3
sonic-channel: ^1.3.1
speakeasy: 2.0.0 speakeasy: 2.0.0
strict-event-emitter-types: 2.0.0 strict-event-emitter-types: 2.0.0
stringz: 2.1.0 stringz: 2.1.0
@ -224,7 +225,7 @@ importers:
'@bull-board/api': 4.10.2 '@bull-board/api': 4.10.2
'@bull-board/koa': 4.10.2_6tybghmia4wsnt33xeid7y4rby '@bull-board/koa': 4.10.2_6tybghmia4wsnt33xeid7y4rby
'@bull-board/ui': 4.10.2 '@bull-board/ui': 4.10.2
'@calckey/megalodon': 5.1.2 '@calckey/megalodon': 5.1.21
'@discordapp/twemoji': 14.0.2 '@discordapp/twemoji': 14.0.2
'@elastic/elasticsearch': 7.17.0 '@elastic/elasticsearch': 7.17.0
'@koa/cors': 3.4.3 '@koa/cors': 3.4.3
@ -310,6 +311,7 @@ importers:
seedrandom: 3.0.5 seedrandom: 3.0.5
semver: 7.3.8 semver: 7.3.8
sharp: 0.31.3 sharp: 0.31.3
sonic-channel: 1.3.1
speakeasy: 2.0.0 speakeasy: 2.0.0
stringz: 2.1.0 stringz: 2.1.0
summaly: 2.7.0 summaly: 2.7.0
@ -465,6 +467,7 @@ importers:
uuid: 9.0.0 uuid: 9.0.0
vanilla-tilt: 1.8.0 vanilla-tilt: 1.8.0
vite: ^4.1.1 vite: ^4.1.1
vite-plugin-compression: ^0.5.1
vue: 3.2.45 vue: 3.2.45
vue-isyourpasswordsafe: ^2.0.0 vue-isyourpasswordsafe: ^2.0.0
vue-plyr: ^7.0.0 vue-plyr: ^7.0.0
@ -542,6 +545,7 @@ importers:
uuid: 9.0.0 uuid: 9.0.0
vanilla-tilt: 1.8.0 vanilla-tilt: 1.8.0
vite: 4.1.1_sass@1.57.1 vite: 4.1.1_sass@1.57.1
vite-plugin-compression: 0.5.1_vite@4.1.1
vue: 3.2.45 vue: 3.2.45
vue-isyourpasswordsafe: 2.0.0 vue-isyourpasswordsafe: 2.0.0
vue-plyr: 7.0.0 vue-plyr: 7.0.0
@ -759,8 +763,8 @@ packages:
'@bull-board/api': 4.10.2 '@bull-board/api': 4.10.2
dev: false dev: false
/@calckey/megalodon/5.1.2: /@calckey/megalodon/5.1.21:
resolution: {integrity: sha512-bUjPOfASy8X2NxdBvYDOWN9Rw/KdkfbTxy5vMQBcrGXepFbT4M+00blEYNc00Uu/epwH9YoNqpQC8PKQr/WU4w==} resolution: {integrity: sha512-wThdyNb/UofklvYzyeFNQwxJNHqaSLD+z0glQLzV1tmAvSB0EZOL0D15dvpdB0LED0Q2rpJlwaonejkTI9JQhA==}
engines: {node: '>=15.0.0'} engines: {node: '>=15.0.0'}
dependencies: dependencies:
'@types/oauth': 0.9.1 '@types/oauth': 0.9.1
@ -6514,6 +6518,15 @@ packages:
/fs-constants/1.0.0: /fs-constants/1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} 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: /fs-extra/8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'} engines: {node: '>=6 <7 || >=8'}
@ -11583,6 +11596,11 @@ packages:
smart-buffer: 4.2.0 smart-buffer: 4.2.0
dev: false 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: /sort-keys-length/1.0.1:
resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==} resolution: {integrity: sha512-GRbEOUqCxemTAk/b32F2xa8wDTs+Z1QHOkbhJDQTvv/6G3ZkbJ+frYWsTcc7cBB3Fu4wy4XlLCuNtJuMn7Gsvw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -12922,6 +12940,19 @@ packages:
replace-ext: 1.0.1 replace-ext: 1.0.1
dev: true 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: /vite/4.1.1_sass@1.57.1:
resolution: {integrity: sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==} resolution: {integrity: sha512-LM9WWea8vsxhr782r9ntg+bhSFS06FJgCvvB0+8hf8UWtvaiDagKYWXndjfX6kGl74keHJUcpzrQliDXZlF5yg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}

View file

@ -1,5 +1,5 @@
{ {
"version": "13.1.3-rc", "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• Bug fixes and performance improvements", "notes": "This release candidate has the following changes:\n• Better blocking/muting\n• Better user refreshing\n• New help menu with app list (More! > Help)\n• New headerbar style\n• Bug + security fixes and performance improvements",
"screenshots": [] "screenshots": []
} }