Merge branch 'develop' into pr/ThatOneCalculator/8764

This commit is contained in:
tamaina 2022-07-19 06:21:39 +00:00
commit 6fab21c6a7
233 changed files with 6639 additions and 18111 deletions

View file

@ -28,11 +28,11 @@ export class foreignKeyReports1651224615271 {
queryRunner.query(`CREATE INDEX "IDX_315c779174fe8247ab324f036e" ON "drive_file" ("isLink")`),
queryRunner.query(`CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId")`),
queryRunner.query(`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`),
//queryRunner.query(`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`),
queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`).then(() => {
queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}),
//queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`).then(() => {
// queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
//}),
queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b" UNIQUE ("noteId")`),
queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6" UNIQUE ("userId")`),

View file

@ -0,0 +1,23 @@
export class nsfwDetection1655368940105 {
name = 'nsfwDetection1655368940105'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetection_enum"`);
await queryRunner.query(`COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`);
}
}

View file

@ -0,0 +1,15 @@
export class nsfwDetection21655371960534 {
name = 'nsfwDetection21655371960534'
async up(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`);
}
}

View file

@ -0,0 +1,21 @@
export class nsfwDetection31655388169582 {
name = 'nsfwDetection31655388169582'
async up(queryRunner) {
await queryRunner.query(`ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum_old"`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum"`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old"`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" AS ENUM('medium', 'low', 'high')`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum_old"`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`);
await queryRunner.query(`ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum"`);
}
}

View file

@ -0,0 +1,25 @@
export class nsfwDetection41655393015659 {
name = 'nsfwDetection41655393015659'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetection_enum"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetection_enum" AS ENUM('none', 'all', 'local', 'remote')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetection" "public"."meta_sensitivemediadetection_enum" NOT NULL DEFAULT 'none'`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionSensitivity" "public"."meta_sensitivemediadetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionSensitivity"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitivemediadetectionsensitivity_enum"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetection"`);
await queryRunner.query(`DROP TYPE "public"."meta_sensitivemediadetection_enum"`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`);
await queryRunner.query(`CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`);
await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`);
}
}

View file

@ -0,0 +1,33 @@
export class nsfwDetection51656251734807 {
name = 'nsfwDetection51656251734807'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybeSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybePorn" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "setSensitiveFlagAutomatically" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_3b33dff77bb64b23c88151d23e" ON "drive_file" ("maybeSensitive") `);
await queryRunner.query(`CREATE INDEX "IDX_8bdcd3dd2bddb78014999a16ce" ON "drive_file" ("maybePorn") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_8bdcd3dd2bddb78014999a16ce"`);
await queryRunner.query(`DROP INDEX "public"."IDX_3b33dff77bb64b23c88151d23e"`);
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "autoSensitive"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "setSensitiveFlagAutomatically"`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "maybePorn"`);
await queryRunner.query(`COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "maybeSensitive"`);
await queryRunner.query(`ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `);
}
}

View file

@ -0,0 +1,11 @@
export class nsfwDetection61656408772602 {
name = 'nsfwDetection61656408772602'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableSensitiveMediaDetectionForVideos"`);
}
}

View file

@ -0,0 +1,11 @@
export class activeEmailValidation1657346559800 {
name = 'activeEmailValidation1657346559800'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableActiveEmailValidation" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableActiveEmailValidation"`);
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -13,6 +13,9 @@
"mocha": "NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha",
"test": "yarn mocha"
},
"optionalDependencies": {
"@tensorflow/tfjs-node": "3.18.0"
},
"dependencies": {
"@bull-board/api": "4.0.0",
"@bull-board/koa": "4.0.0",
@ -38,6 +41,7 @@
"cbor": "8.1.0",
"chalk": "5.0.1",
"chalk-template": "0.4.0",
"chokidar": "3.5.3",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
@ -67,15 +71,16 @@
"koa-send": "5.0.1",
"koa-slow": "2.1.0",
"koa-views": "7.0.2",
"mfm-js": "0.22.1",
"mfm-js": "0.23.0-canary.1",
"mime-types": "2.1.35",
"misskey-js": "0.0.14",
"mocha": "10.0.0",
"ms": "3.0.0-canary.1",
"multer": "1.4.4",
"nested-property": "4.0.0",
"node-fetch": "3.2.6",
"nodemailer": "6.7.6",
"node-fetch": "3.2.8",
"nodemailer": "6.7.7",
"nsfwjs": "2.4.1",
"oauth": "^0.9.15",
"os-utils": "0.0.14",
"parse5": "7.0.0",
@ -104,13 +109,13 @@
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"style-loader": "3.3.1",
"summaly": "2.6.0",
"summaly": "2.7.0",
"syslog-pro": "1.0.0",
"systeminformation": "5.11.22",
"systeminformation": "5.12.0",
"tinycolor2": "1.4.2",
"tmp": "0.2.1",
"ts-loader": "9.3.1",
"ts-node": "10.8.1",
"ts-node": "10.8.2",
"tsc-alias": "1.6.11",
"tsconfig-paths": "4.0.0",
"twemoji-parser": "14.0.0",
@ -135,7 +140,7 @@
"@types/jsdom": "16.2.14",
"@types/jsonld": "1.5.6",
"@types/jsrsasign": "10.5.1",
"@types/koa": "2.13.4",
"@types/koa": "2.13.5",
"@types/koa-bodyparser": "4.3.7",
"@types/koa-cors": "0.0.2",
"@types/koa-favicon": "2.0.21",
@ -147,7 +152,7 @@
"@types/koa__multer": "2.0.4",
"@types/koa__router": "8.0.11",
"@types/mocha": "9.1.1",
"@types/node": "18.0.0",
"@types/node": "18.0.3",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.4",
"@types/oauth": "0.9.1",
@ -169,10 +174,10 @@
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.30.0",
"@typescript-eslint/parser": "5.30.0",
"@typescript-eslint/eslint-plugin": "5.30.6",
"@typescript-eslint/parser": "5.30.6",
"cross-env": "7.0.3",
"eslint": "8.18.0",
"eslint": "8.19.0",
"eslint-plugin-import": "2.26.0",
"execa": "6.1.0",
"form-data": "^4.0.0",

View file

@ -145,6 +145,12 @@ export function toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMenti
a.textContent = node.props.content;
return a;
},
plain(node) {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
},
};
appendChildren(nodes, doc.body);

View file

@ -7,28 +7,31 @@ import { WriteStream } from 'node:fs';
import * as p from 'pureimage';
import gen from 'random-seed';
const size = 256; // px
const size = 128; // px
const n = 5; // resolution
const margin = (size / n);
const margin = (size / 4);
const colors = [
'#e57373',
'#F06292',
'#BA68C8',
'#9575CD',
'#7986CB',
'#64B5F6',
'#4FC3F7',
'#4DD0E1',
'#4DB6AC',
'#81C784',
'#8BC34A',
'#AFB42B',
'#F57F17',
'#FF5722',
'#795548',
'#455A64',
['#FF512F', '#DD2476'],
['#FF61D2', '#FE9090'],
['#72FFB6', '#10D164'],
['#FD8451', '#FFBD6F'],
['#305170', '#6DFC6B'],
['#00C0FF', '#4218B8'],
['#009245', '#FCEE21'],
['#0100EC', '#FB36F4'],
['#FDABDD', '#374A5A'],
['#38A2D7', '#561139'],
['#121C84', '#8278DA'],
['#5761B2', '#1FC5A8'],
['#FFDB01', '#0E197D'],
['#FF3E9D', '#0E1F40'],
['#766eff', '#00d4ff'],
['#9bff6e', '#00d4ff'],
['#ff6e94', '#00d4ff'],
['#ffa96e', '#00d4ff'],
['#ffa96e', '#ff009d'],
['#ffdd6e', '#ff009d'],
];
const bg = '#e9e9e9';
const actualSize = size - (margin * 2);
const cellSize = actualSize / n;
@ -42,11 +45,17 @@ export function genIdenticon(seed: string, stream: WriteStream): Promise<void> {
const canvas = p.make(size, size, undefined);
const ctx = canvas.getContext('2d');
const bgColors = colors[rand(colors.length)];
const bg = ctx.createLinearGradient(0, 0, size, size);
bg.addColorStop(0, bgColors[0]);
bg.addColorStop(1, bgColors[1]);
ctx.fillStyle = bg;
ctx.beginPath();
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = colors[rand(colors.length)];
ctx.fillStyle = '#ffffff';
// side bitmap (filled by false)
const side: boolean[][] = new Array(sideN);

View file

@ -1,12 +1,18 @@
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { join } from 'node:path';
import * as stream from 'node:stream';
import * as util from 'node:util';
import { FSWatcher } from 'chokidar';
import { fileTypeFromFile } from 'file-type';
import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { type predictionType } from 'nsfwjs';
import sharp from 'sharp';
import { encode } from 'blurhash';
import { detectSensitive } from '@/services/detect-sensitive.js';
import { createTempDir } from './create-temp.js';
const pipeline = util.promisify(stream.pipeline);
@ -21,6 +27,8 @@ export type FileInfo = {
height?: number;
orientation?: number;
blurhash?: string;
sensitive: boolean;
porn: boolean;
warnings: string[];
};
@ -37,7 +45,12 @@ const TYPE_SVG = {
/**
* Get file information
*/
export async function getFileInfo(path: string): Promise<FileInfo> {
export async function getFileInfo(path: string, opts: {
skipSensitiveDetection: boolean;
sensitiveThreshold?: number;
sensitiveThresholdForPorn?: number;
enableSensitiveMediaDetectionForVideos?: boolean;
}): Promise<FileInfo> {
const warnings = [] as string[];
const size = await getFileSize(path);
@ -58,7 +71,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
// うまく判定できない画像は octet-stream にする
if (!imageSize) {
warnings.push(`cannot detect image dimensions`);
warnings.push('cannot detect image dimensions');
type = TYPE_OCTET_STREAM;
} else if (imageSize.wUnits === 'px') {
width = imageSize.width;
@ -67,7 +80,7 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
// 制限を超えている画像は octet-stream にする
if (imageSize.width > 16383 || imageSize.height > 16383) {
warnings.push(`image dimensions exceeds limits`);
warnings.push('image dimensions exceeds limits');
type = TYPE_OCTET_STREAM;
}
} else {
@ -84,6 +97,19 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
});
}
let sensitive = false;
let porn = false;
if (!opts.skipSensitiveDetection) {
[sensitive, porn] = await detectSensitivity(
path,
type.mime,
opts.sensitiveThreshold ?? 0.5,
opts.sensitiveThresholdForPorn ?? 0.75,
opts.enableSensitiveMediaDetectionForVideos ?? false,
);
}
return {
size,
md5,
@ -92,10 +118,150 @@ export async function getFileInfo(path: string): Promise<FileInfo> {
height,
orientation,
blurhash,
sensitive,
porn,
warnings,
};
}
async function detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
let sensitive = false;
let porn = false;
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
let sensitive = false;
let porn = false;
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
return [sensitive, porn];
}
if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) {
const result = await detectSensitive(source);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
const [outDir, disposeOutDir] = await createTempDir();
try {
const command = FFmpeg()
.input(source)
.inputOptions([
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
])
.noAudio()
.videoFilters([
{
filter: 'select', // フレームのフィルタリング
options: {
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタするVP9 とかはデコードしてみないとわからないっぽい)
},
},
{
filter: 'blackframe', // 暗いフレームの検出
options: {
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
},
},
{
filter: 'metadata',
options: {
mode: 'select', // フレーム選択モード
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
value: '50',
function: 'less', // 50% 未満のフレームを選択する50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
},
},
{
filter: 'scale',
options: {
w: 299,
h: 299,
},
},
])
.format('image2')
.output(join(outDir, '%d.png'))
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
const results: ReturnType<typeof judgePrediction>[] = [];
let frameIndex = 0;
let targetIndex = 0;
let nextIndex = 1;
for await (const path of asyncIterateFrames(outDir, command)) {
try {
const index = frameIndex++;
if (index !== targetIndex) {
continue;
}
targetIndex = nextIndex;
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
const result = await detectSensitive(path);
if (result) {
results.push(judgePrediction(result));
}
} finally {
fs.promises.unlink(path);
}
}
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
} finally {
disposeOutDir();
}
}
return [sensitive, porn];
}
async function* asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
const watcher = new FSWatcher({
cwd,
disableGlobbing: true,
});
let finished = false;
command.once('end', () => {
finished = true;
watcher.close();
});
command.run();
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
const current = `${i}.png`;
const next = `${i + 1}.png`;
const framePath = join(cwd, current);
if (await exists(join(cwd, next))) {
yield framePath;
} else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
watcher.add(next);
await new Promise<void>((resolve, reject) => {
watcher.on('add', function onAdd(path) {
if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている
watcher.unwatch(current);
watcher.off('add', onAdd);
resolve();
}
});
command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている
command.once('error', reject);
});
yield framePath;
} else if (await exists(framePath)) {
yield framePath;
} else {
return;
}
}
}
function exists(path: string): Promise<boolean> {
return fs.promises.access(path).then(() => true, () => false);
}
/**
* Detect MIME Type and extension
*/

View file

@ -156,6 +156,19 @@ export class DriveFile {
})
public isSensitive: boolean;
@Index()
@Column('boolean', {
default: false,
comment: 'Whether the DriveFile is NSFW. (predict)',
})
public maybeSensitive: boolean;
@Index()
@Column('boolean', {
default: false,
})
public maybePorn: boolean;
/**
* ()URLへの直リンクか否か
*/

View file

@ -188,6 +188,28 @@ export class Meta {
})
public recaptchaSecretKey: string | null;
@Column('enum', {
enum: ['none', 'all', 'local', 'remote'],
default: 'none',
})
public sensitiveMediaDetection: 'none' | 'all' | 'local' | 'remote';
@Column('enum', {
enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'],
default: 'medium',
})
public sensitiveMediaDetectionSensitivity: 'medium' | 'low' | 'high' | 'veryLow' | 'veryHigh';
@Column('boolean', {
default: false,
})
public setSensitiveFlagAutomatically: boolean;
@Column('boolean', {
default: false,
})
public enableSensitiveMediaDetectionForVideos: boolean;
@Column('integer', {
default: 1024,
comment: 'Drive capacity of a local user (MB)',
@ -432,4 +454,9 @@ export class Meta {
default: false,
})
public enableIpLogging: boolean;
@Column('boolean', {
default: true,
})
public enableActiveEmailValidation: boolean;
}

View file

@ -152,6 +152,11 @@ export class UserProfile {
})
public alwaysMarkNsfw: boolean;
@Column('boolean', {
default: false,
})
public autoSensitive: boolean;
@Column('boolean', {
default: false,
})

View file

@ -360,6 +360,7 @@ export const UserRepository = db.getRepository(User).extend({
injectFeaturedNote: profile!.injectFeaturedNote,
receiveAnnouncementEmail: profile!.receiveAnnouncementEmail,
alwaysMarkNsfw: profile!.alwaysMarkNsfw,
autoSensitive: profile!.autoSensitive,
carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed,
noCrawle: profile!.noCrawle,

View file

@ -161,19 +161,19 @@ export const packedUserDetailedNotMeOnlySchema = {
type: 'array',
nullable: false, optional: false,
items: {
type: 'object',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
value: {
type: 'string',
nullable: false, optional: false,
},
type: 'object',
nullable: false, optional: false,
properties: {
name: {
type: 'string',
nullable: false, optional: false,
},
maxLength: 4,
value: {
type: 'string',
nullable: false, optional: false,
},
},
maxLength: 4,
},
},
followersCount: {
@ -292,6 +292,10 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: true, optional: false,
},
autoSensitive: {
type: 'boolean',
nullable: true, optional: false,
},
carefulBot: {
type: 'boolean',
nullable: true, optional: false,

View file

@ -8,7 +8,7 @@ import { queueLogger } from '../../logger.js';
import { addFile } from '@/services/drive/add-file.js';
import { format as dateFormat } from 'date-fns';
import { Users, Emojis } from '@/models/index.js';
import { } from '@/queue/types.js';
import { } from '@/queue/types.js';
import { createTemp, createTempDir } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import config from '@/config/index.js';

View file

@ -1,6 +1,6 @@
import config from '@/config/index.js';
import Resolver from '../resolver.js';
import { IObject, IQuestion, isQuestion } from '../type.js';
import { IObject, IQuestion, isQuestion } from '../type.js';
import { apLogger } from '../logger.js';
import { Notes, Polls } from '@/models/index.js';
import { IPoll } from '@/models/entities/poll.js';

View file

@ -8,7 +8,7 @@ import renderEmoji from './emoji.js';
export const renderLike = async (noteReaction: NoteReaction, note: Note) => {
const reaction = noteReaction.reaction;
const object = {
const object = {
type: 'Like',
id: `${config.url}/likes/${noteReaction.id}`,
actor: `${config.url}/users/${noteReaction.userId}`,

View file

@ -68,11 +68,11 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res
}
try {
UserIps.insert({
UserIps.createQueryBuilder().insert().values({
createdAt: new Date(),
userId: user.id,
ip: ip,
});
}).orIgnore(true).execute();
} catch {
}
}

View file

@ -12,13 +12,15 @@ export async function readNotification(
if (notificationIds.length === 0) return;
// Update documents
await Notifications.update({
const result = await Notifications.update({
id: In(notificationIds),
isRead: false,
}, {
isRead: true,
});
if (result.affected === 0) return;
if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId);
else return postReadNotifications(userId, notificationIds);
}
@ -27,7 +29,7 @@ export async function readNotificationByQuery(
userId: User['id'],
query: Record<string, any>
) {
const notificationIds = await Notifications.find({
const notificationIds = await Notifications.findBy({
...query,
notifieeId: userId,
isRead: false,

View file

@ -195,6 +195,22 @@ export const meta = {
type: 'string',
optional: true, nullable: true,
},
sensitiveMediaDetection: {
type: 'string',
optional: true, nullable: false,
},
sensitiveMediaDetectionSensitivity: {
type: 'string',
optional: true, nullable: false,
},
setSensitiveFlagAutomatically: {
type: 'boolean',
optional: true, nullable: false,
},
enableSensitiveMediaDetectionForVideos: {
type: 'boolean',
optional: true, nullable: false,
},
proxyAccountId: {
type: 'string',
optional: true, nullable: true,
@ -308,6 +324,10 @@ export const meta = {
type: 'boolean',
optional: true, nullable: false,
},
enableActiveEmailValidation: {
type: 'boolean',
optional: true, nullable: false,
},
},
},
} as const;
@ -370,6 +390,10 @@ export default define(meta, paramDef, async (ps, me) => {
blockedHosts: instance.blockedHosts,
hcaptchaSecretKey: instance.hcaptchaSecretKey,
recaptchaSecretKey: instance.recaptchaSecretKey,
sensitiveMediaDetection: instance.sensitiveMediaDetection,
sensitiveMediaDetectionSensitivity: instance.sensitiveMediaDetectionSensitivity,
setSensitiveFlagAutomatically: instance.setSensitiveFlagAutomatically,
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
proxyAccountId: instance.proxyAccountId,
twitterConsumerKey: instance.twitterConsumerKey,
twitterConsumerSecret: instance.twitterConsumerSecret,
@ -401,5 +425,6 @@ export default define(meta, paramDef, async (ps, me) => {
deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro,
enableIpLogging: instance.enableIpLogging,
enableActiveEmailValidation: instance.enableActiveEmailValidation,
};
});

View file

@ -58,6 +58,7 @@ export default define(meta, paramDef, async (ps, me) => {
autoAcceptFollowed: profile.autoAcceptFollowed,
noCrawle: profile.noCrawle,
alwaysMarkNsfw: profile.alwaysMarkNsfw,
autoSensitive: profile.autoSensitive,
carefulBot: profile.carefulBot,
injectFeaturedNote: profile.injectFeaturedNote,
receiveAnnouncementEmail: profile.receiveAnnouncementEmail,

View file

@ -25,7 +25,7 @@ export const paramDef = {
offset: { type: 'integer', default: 0 },
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'silenced', 'suspended'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
username: { type: 'string', nullable: true, default: null },
hostname: {
type: 'string',
@ -61,7 +61,7 @@ export default define(meta, paramDef, async (ps, me) => {
}
if (ps.hostname) {
query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' });
query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() });
}
switch (ps.sort) {

View file

@ -48,6 +48,10 @@ export const paramDef = {
enableRecaptcha: { type: 'boolean' },
recaptchaSiteKey: { type: 'string', nullable: true },
recaptchaSecretKey: { type: 'string', nullable: true },
sensitiveMediaDetection: { type: 'string', enum: ['none', 'all', 'local', 'remote'] },
sensitiveMediaDetectionSensitivity: { type: 'string', enum: ['medium', 'low', 'high', 'veryLow', 'veryHigh'] },
setSensitiveFlagAutomatically: { type: 'boolean' },
enableSensitiveMediaDetectionForVideos: { type: 'boolean' },
proxyAccountId: { type: 'string', format: 'misskey:id', nullable: true },
maintainerName: { type: 'string', nullable: true },
maintainerEmail: { type: 'string', nullable: true },
@ -97,6 +101,7 @@ export const paramDef = {
objectStorageSetPublicRead: { type: 'boolean' },
objectStorageS3ForcePathStyle: { type: 'boolean' },
enableIpLogging: { type: 'boolean' },
enableActiveEmailValidation: { type: 'boolean' },
},
required: [],
} as const;
@ -213,6 +218,22 @@ export default define(meta, paramDef, async (ps, me) => {
set.recaptchaSecretKey = ps.recaptchaSecretKey;
}
if (ps.sensitiveMediaDetection !== undefined) {
set.sensitiveMediaDetection = ps.sensitiveMediaDetection;
}
if (ps.sensitiveMediaDetectionSensitivity !== undefined) {
set.sensitiveMediaDetectionSensitivity = ps.sensitiveMediaDetectionSensitivity;
}
if (ps.setSensitiveFlagAutomatically !== undefined) {
set.setSensitiveFlagAutomatically = ps.setSensitiveFlagAutomatically;
}
if (ps.enableSensitiveMediaDetectionForVideos !== undefined) {
set.enableSensitiveMediaDetectionForVideos = ps.enableSensitiveMediaDetectionForVideos;
}
if (ps.proxyAccountId !== undefined) {
set.proxyAccountId = ps.proxyAccountId;
}
@ -401,6 +422,10 @@ export default define(meta, paramDef, async (ps, me) => {
set.enableIpLogging = ps.enableIpLogging;
}
if (ps.enableActiveEmailValidation !== undefined) {
set.enableActiveEmailValidation = ps.enableActiveEmailValidation;
}
await db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(Meta, {
order: {

View file

@ -2,6 +2,7 @@ import ms from 'ms';
import { addFile } from '@/services/drive/add-file.js';
import { DriveFiles } from '@/models/index.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import define from '../../../define.js';
import { apiLogger } from '../../../logger.js';
@ -35,6 +36,18 @@ export const meta = {
code: 'INVALID_FILE_NAME',
id: 'f449b209-0c60-4e51-84d5-29486263bfd4',
},
inappropriate: {
message: 'Cannot upload the file because it has been determined that it possibly contains inappropriate content.',
code: 'INAPPROPRIATE',
id: 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2',
},
noFreeSpace: {
message: 'Cannot upload the file because you have no free space of drive.',
code: 'NO_FREE_SPACE',
id: 'd08dbc37-a6a9-463a-8c47-96c32ab5f064',
},
},
} as const;
@ -87,6 +100,10 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, hea
if (e instanceof Error || typeof e === 'string') {
apiLogger.error(e);
}
if (e instanceof IdentifiableError) {
if (e.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate);
if (e.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace);
}
throw new ApiError();
} finally {
cleanup!();

View file

@ -1,8 +1,8 @@
import { publishDriveStream } from '@/services/stream.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
import { DriveFiles, DriveFolders, Users } from '@/models/index.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['drive'],

View file

@ -3,17 +3,17 @@ import * as mfm from 'mfm-js';
import { publishMainStream, publishUserEvent } from '@/services/stream.js';
import acceptAllFollowRequests from '@/services/following/requests/accept-all.js';
import { publishToFollowers } from '@/services/i/update.js';
import define from '../../define.js';
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
import { extractHashtags } from '@/misc/extract-hashtags.js';
import { updateUsertags } from '@/services/update-hashtag.js';
import { ApiError } from '../../error.js';
import { Users, DriveFiles, UserProfiles, Pages } from '@/models/index.js';
import { User } from '@/models/entities/user.js';
import { UserProfile } from '@/models/entities/user-profile.js';
import { notificationTypes } from '@/types.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { langmap } from '@/misc/langmap.js';
import { ApiError } from '../../error.js';
import define from '../../define.js';
export const meta = {
tags: ['account'],
@ -57,7 +57,7 @@ export const meta = {
message: 'Invalid Regular Expression.',
code: 'INVALID_REGEXP',
id: '0d786918-10df-41cd-8f33-8dec7d9a89a5',
}
},
},
res: {
@ -77,7 +77,8 @@ export const paramDef = {
lang: { type: 'string', enum: [null, ...Object.keys(langmap)], nullable: true },
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
fields: { type: 'array',
fields: {
type: 'array',
minItems: 0,
maxItems: 16,
items: {
@ -102,6 +103,7 @@ export const paramDef = {
injectFeaturedNote: { type: 'boolean' },
receiveAnnouncementEmail: { type: 'boolean' },
alwaysMarkNsfw: { type: 'boolean' },
autoSensitive: { type: 'boolean' },
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
pinnedPageId: { type: 'array', items: {
type: 'string', format: 'misskey:id',
@ -168,6 +170,7 @@ export default define(meta, paramDef, async (ps, _user, token) => {
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
if (ps.avatarId) {
@ -211,7 +214,7 @@ export default define(meta, paramDef, async (ps, _user, token) => {
const newDescription = profileUpdates.description === undefined ? profile.description : profileUpdates.description;
if (newName != null) {
const tokens = mfm.parsePlain(newName);
const tokens = mfm.parseSimple(newName);
emojis = emojis.concat(extractCustomEmojisFromMfm(tokens!));
}

View file

@ -9,6 +9,9 @@ export const meta = {
requireCredential: false,
allowGet: true,
cacheSec: 60,
res: {
type: 'array',
optional: false, nullable: false,

View file

@ -16,6 +16,7 @@ export default class extends Channel {
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.onNote.bind(this);
this.emitTypers = this.emitTypers.bind(this);
}
public async init(params: any) {

View file

@ -78,6 +78,7 @@ const nodeinfo2 = async () => {
enableEmail: meta.enableEmail,
enableServiceWorker: meta.enableServiceWorker,
proxyAccountName: proxyAccount ? proxyAccount.username : null,
themeColor: meta.themeColor || '#86b300',
},
};
};

View file

@ -14,9 +14,11 @@
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
(async () => {
window.onerror = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED', e);
};
window.onunhandledrejection = (e) => {
console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e);
};
@ -47,18 +49,30 @@
localStorage.setItem('localeVersion', v);
} else {
await checkUpdate();
renderError('LOCALE_FETCH_FAILED');
renderError('LOCALE_FETCH');
return;
}
}
//#endregion
//#region Script
import(`/assets/${CLIENT_ENTRY}`)
.catch(async e => {
await checkUpdate();
renderError('APP_FETCH_FAILED', e);
})
function importAppScript() {
import(`/assets/${CLIENT_ENTRY}`)
.catch(async e => {
await checkUpdate();
console.error(e);
renderError('APP_IMPORT', e);
});
}
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
if (document.readyState !== 'loading') {
importAppScript();
} else {
window.addEventListener('DOMContentLoaded', () => {
importAppScript();
});
}
//#endregion
//#region Theme
@ -102,44 +116,189 @@
document.head.appendChild(style);
}
// eslint-disable-next-line no-inner-declarations
async function addStyle(styleText) {
let css = document.createElement('style');
css.appendChild(document.createTextNode(styleText));
document.head.appendChild(css);
}
function renderError(code, details) {
let errorsElement = document.getElementById('errors');
if (!errorsElement) {
document.documentElement.innerHTML = `
<h1> An error has occurred. </h1>
<p>If the problem persists, please contact the administrator. You may also try the following options:</p>
<ul>
<li>Start <a href="/cli">the simple client</a></li>
<li>Attempt to repair in <a href="/bios">BIOS</a></li>
<li><a href="/flush">Flush preferences and cache</a></li>
</ul>
<hr>
document.body.innerHTML = `
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-alert-triangle" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 9v2m0 4v.01"></path>
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
</svg>
<h1>An error has occurred!</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">Refresh</span>
</button>
<p class="dont-worry">Don't worry, it's (probably) not your fault.</p>
<p>If the problem persists after refreshing, please contact your instance's administrator.<br>You may also try the following options:</p>
<a href="/flush">
<button class="button-small">
<span class="button-label-small">Clear preferences and cache</span>
</button>
</a>
<br>
<a href="/cli">
<button class="button-small">
<span class="button-label-small">Start the simple client</span>
</button>
</a>
<br>
<a href="/bios">
<button class="button-small">
<span class="button-label-small">Start the repair tool</span>
</button>
</a>
<br>
<div id="errors"></div>
`;
errorsElement = document.getElementById('errors');
}
const detailsElement = document.createElement('details');
detailsElement.innerHTML = `<summary><code>ERROR CODE: ${code}</code></summary>${JSON.stringify(details)}`;
detailsElement.innerHTML = `
<br>
<summary>
<code>ERROR CODE: ${code}</code>
</summary>
<code>${JSON.stringify(details)}</code>`;
errorsElement.appendChild(detailsElement);
addStyle(`
* {
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
}
#misskey_app,
#splash {
display: none !important;
}
body,
html {
background-color: #222;
color: #dfddcc;
justify-content: center;
margin: auto;
padding: 10px;
text-align: center;
}
button {
border-radius: 999px;
padding: 0px 12px 0px 12px;
border: none;
cursor: pointer;
margin-bottom: 12px;
}
.button-big {
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
line-height: 50px;
}
.button-big:hover {
background: rgb(153, 204, 0);
}
.button-small {
background: #444;
line-height: 40px;
}
.button-small:hover {
background: #555;
}
.button-label-big {
color: #222;
font-weight: bold;
font-size: 20px;
padding: 12px;
}
.button-label-small {
color: rgb(153, 204, 0);
font-size: 16px;
padding: 12px;
}
a {
color: rgb(134, 179, 0);
text-decoration: none;
}
p,
li {
font-size: 16px;
}
.dont-worry,
#msg {
font-size: 18px;
}
.icon-warning {
color: #dec340;
height: 4rem;
padding-top: 2rem;
}
h1 {
font-size: 32px;
}
code {
font-family: Fira, FiraCode, monospace;
}
details {
background: #333;
margin-bottom: 2rem;
padding: 0.5rem 1rem;
width: 40rem;
border-radius: 10px;
justify-content: center;
margin: auto;
}
summary {
cursor: pointer;
}
summary > * {
display: inline;
}
@media screen and (max-width: 500px) {
details {
width: 50%;
}
`)
}
// eslint-disable-next-line no-inner-declarations
async function checkUpdate() {
// TODO: サーバーが落ちている場合などのエラーハンドリング
const res = await fetch('/api/meta', {
method: 'POST',
cache: 'no-cache'
});
try {
const res = await fetch('/api/meta', {
method: 'POST',
cache: 'no-cache'
});
const meta = await res.json();
const meta = await res.json();
if (meta.version != v) {
localStorage.setItem('v', meta.version);
refresh();
if (meta.version != v) {
localStorage.setItem('v', meta.version);
refresh();
}
} catch (e) {
console.error(e);
renderError('UPDATE_CHECK', e);
throw e;
}
}

View file

@ -5,7 +5,7 @@ html
head
meta(charset='utf-8')
meta(name='application-name' content='Misskey')
title Misskey BIOS
title Misskey Repair Tool
style
include ../bios.css
script
@ -13,7 +13,7 @@ html
body
header
h1 Misskey BIOS #{version}
h1 Misskey Repair Tool #{version}
main
div.tabs
button#ls edit local storage

View file

@ -0,0 +1,48 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as nsfw from 'nsfwjs';
import si from 'systeminformation';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
let isSupportedCpu: undefined | boolean = undefined;
let model: nsfw.NSFWJS;
export async function detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
try {
if (isSupportedCpu === undefined) {
const cpuFlags = await getCpuFlags();
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
}
if (!isSupportedCpu) {
console.error('This CPU cannot use TensorFlow.');
return null;
}
const tf = await import('@tensorflow/tfjs-node');
if (model == null) model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
const buffer = await fs.promises.readFile(path);
const image = await tf.node.decodeImage(buffer, 3) as any;
try {
const predictions = await model.classify(image);
return predictions;
} finally {
image.dispose();
}
} catch (err) {
console.error(err);
return null;
}
}
async function getCpuFlags(): Promise<string[]> {
const str = await si.cpuFlags();
return str.split(/\s+/);
}

View file

@ -16,6 +16,7 @@ import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/i
import { genId } from '@/misc/gen-id.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { getS3 } from './s3.js';
import { InternalStorage } from './internal-storage.js';
import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js';
@ -349,9 +350,31 @@ export async function addFile({
requestIp = null,
requestHeaders = null,
}: AddFileArgs): Promise<DriveFile> {
const info = await getFileInfo(path);
let skipNsfwCheck = false;
const instance = await fetchMeta();
if (user == null) skipNsfwCheck = true;
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'local' && Users.isRemoteUser(user)) skipNsfwCheck = true;
if (user && instance.sensitiveMediaDetection === 'remote' && Users.isLocalUser(user)) skipNsfwCheck = true;
const info = await getFileInfo(path, {
skipSensitiveDetection: skipNsfwCheck,
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
0.5,
sensitiveThresholdForPorn: 0.75,
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
});
logger.info(`${JSON.stringify(info)}`);
// 現状 false positive が多すぎて実用に耐えない
//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
// throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
//}
// detect name
const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
@ -387,7 +410,7 @@ export async function addFile({
// If usage limit exceeded
if (usage + info.size > driveCapacity) {
if (Users.isLocalUser(user)) {
throw new Error('no-free-space');
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
} else {
// (アバターまたはバナーを含まず)最も古いファイルを削除する
deleteOldFile(await Users.findOneByOrFail({ id: user.id }) as IRemoteUser);
@ -441,6 +464,8 @@ export async function addFile({
file.isLink = isLink;
file.requestIp = requestIp;
file.requestHeaders = requestHeaders;
file.maybeSensitive = info.sensitive;
file.maybePorn = info.porn;
file.isSensitive = user
? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
(sensitive !== null && sensitive !== undefined)
@ -448,6 +473,9 @@ export async function addFile({
: false
: false;
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
if (url !== null) {
file.src = url;

View file

@ -34,7 +34,7 @@ export async function fetchInstanceMetadata(instance: Instance, force = false):
const [favicon, icon, themeColor, name, description] = await Promise.all([
fetchFaviconUrl(instance, dom).catch(() => null),
fetchIconUrl(instance, dom, manifest).catch(() => null),
getThemeColor(dom, manifest).catch(() => null),
getThemeColor(info, dom, manifest).catch(() => null),
getSiteName(info, dom, manifest).catch(() => null),
getDescription(info, dom, manifest).catch(() => null),
]);
@ -208,8 +208,8 @@ async function fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | nul
return null;
}
async function getThemeColor(doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') || manifest?.theme_color;
async function getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor || doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') || manifest?.theme_color;
if (themeColor) {
const color = new tinycolor(themeColor);

View file

@ -64,6 +64,7 @@ export async function pushNotification<T extends keyof pushNotificationsTypes>(u
type,
body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body,
userId,
dateTime: (new Date()).getTime(),
}), {
proxy: config.proxy,
}).catch((err: any) => {

View file

@ -1,34 +1,37 @@
import { validate as validateEmail } from 'deep-email-validator';
import { UserProfiles } from '@/models/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
export async function validateEmailForAccount(emailAddress: string): Promise<{
available: boolean;
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
}> {
const meta = await fetchMeta();
const exist = await UserProfiles.countBy({
emailVerified: true,
email: emailAddress,
});
const validated = await validateEmail({
const validated = meta.enableActiveEmailValidation ? await validateEmail({
email: emailAddress,
validateRegex: true,
validateMx: true,
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
validateDisposable: true, // 捨てアドかどうかチェック
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
});
}) : { valid: true };
const available = exist === 0 && validated.valid;
return {
available,
reason: available ? null :
exist !== 0 ? 'used' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
null,
exist !== 0 ? 'used' :
validated.reason === 'regex' ? 'format' :
validated.reason === 'disposable' ? 'disposable' :
validated.reason === 'mx' ? 'mx' :
validated.reason === 'smtp' ? 'smtp' :
null,
};
}

View file

@ -10,9 +10,11 @@ const _dirname = dirname(_filename);
describe('Get file info', () => {
it('Empty file', async (async () => {
const path = `${_dirname}/resources/emptyfile`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 0,
md5: 'd41d8cd98f00b204e9800998ecf8427e',
@ -28,9 +30,11 @@ describe('Get file info', () => {
it('Generic JPEG', async (async () => {
const path = `${_dirname}/resources/Lenna.jpg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 25360,
md5: '091b3f259662aa31e2ffef4519951168',
@ -46,9 +50,11 @@ describe('Get file info', () => {
it('Generic APNG', async (async () => {
const path = `${_dirname}/resources/anime.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 1868,
md5: '08189c607bea3b952704676bb3c979e0',
@ -64,9 +70,11 @@ describe('Get file info', () => {
it('Generic AGIF', async (async () => {
const path = `${_dirname}/resources/anime.gif`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 2248,
md5: '32c47a11555675d9267aee1a86571e7e',
@ -82,9 +90,11 @@ describe('Get file info', () => {
it('PNG with alpha', async (async () => {
const path = `${_dirname}/resources/with-alpha.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 3772,
md5: 'f73535c3e1e27508885b69b10cf6e991',
@ -100,9 +110,11 @@ describe('Get file info', () => {
it('Generic SVG', async (async () => {
const path = `${_dirname}/resources/image.svg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 505,
md5: 'b6f52b4b021e7b92cdd04509c7267965',
@ -119,9 +131,11 @@ describe('Get file info', () => {
it('SVG with XML definition', async (async () => {
// https://github.com/misskey-dev/misskey/issues/4413
const path = `${_dirname}/resources/with-xml-def.svg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 544,
md5: '4b7a346cde9ccbeb267e812567e33397',
@ -137,9 +151,11 @@ describe('Get file info', () => {
it('Dimension limit', async (async () => {
const path = `${_dirname}/resources/25000x25000.png`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 75933,
md5: '268c5dde99e17cf8fe09f1ab3f97df56',
@ -155,9 +171,11 @@ describe('Get file info', () => {
it('Rotate JPEG', async (async () => {
const path = `${_dirname}/resources/rotate.jpg`;
const info = await getFileInfo(path) as any;
const info = await getFileInfo(path, { skipSensitiveDetection: true }) as any;
delete info.warnings;
delete info.blurhash;
delete info.sensitive;
delete info.porn;
assert.deepStrictEqual(info, {
size: 12624,
md5: '68d5b2d8d1d1acbbce99203e3ec3857e',

View file

@ -13,7 +13,7 @@
"@rollup/plugin-json": "4.1.0",
"@rollup/pluginutils": "^4.2.1",
"@syuilo/aiscript": "0.11.1",
"@vitejs/plugin-vue": "3.0.0-beta.0",
"@vitejs/plugin-vue": "3.0.1",
"@vue/compiler-sfc": "3.2.37",
"abort-controller": "3.0.0",
"autobind-decorator": "2.4.0",
@ -38,7 +38,7 @@
"json5": "2.2.1",
"katex": "0.15.6",
"matter-js": "0.18.0",
"mfm-js": "0.22.1",
"mfm-js": "0.23.0-canary.1",
"misskey-js": "0.0.14",
"mocha": "10.0.0",
"ms": "2.1.3",
@ -54,7 +54,6 @@
"random-seed": "0.3.0",
"reflect-metadata": "0.1.13",
"rndstr": "1.0.0",
"rollup": "2.75.7",
"s-age": "1.1.2",
"sass": "1.53.0",
"seedrandom": "3.0.5",
@ -72,7 +71,7 @@
"uuid": "8.3.2",
"v-debounce": "0.1.2",
"vanilla-tilt": "1.7.2",
"vite": "3.0.0-beta.6",
"vite": "3.0.2",
"vue": "3.2.37",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "4.0.1",
@ -98,13 +97,14 @@
"@types/uuid": "8.3.4",
"@types/websocket": "1.0.5",
"@types/ws": "8.5.3",
"@typescript-eslint/eslint-plugin": "5.30.0",
"@typescript-eslint/parser": "5.30.0",
"@typescript-eslint/eslint-plugin": "5.30.6",
"@typescript-eslint/parser": "5.30.6",
"rollup": "2.76.0",
"cross-env": "7.0.3",
"cypress": "10.3.0",
"eslint": "8.18.0",
"eslint": "8.19.0",
"eslint-plugin-import": "2.26.0",
"eslint-plugin-vue": "9.1.1",
"eslint-plugin-vue": "9.2.0",
"start-server-and-test": "1.14.0"
}
}

View file

@ -206,17 +206,16 @@ export async function openAccountMenu(opts: {
to: `/@${ $i.username }`,
avatar: $i,
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
type: 'parent',
icon: 'fas fa-plus',
text: i18n.ts.addAccount,
action: () => {
popupMenu([{
text: i18n.ts.existingAccount,
action: () => { showSigninDialog(); },
}, {
text: i18n.ts.createAccount,
action: () => { createAccount(); },
}], ev.currentTarget ?? ev.target);
},
children: [{
text: i18n.ts.existingAccount,
action: () => { showSigninDialog(); },
}, {
text: i18n.ts.createAccount,
action: () => { createAccount(); },
}],
}, {
type: 'link',
icon: 'fas fa-users',

View file

@ -316,7 +316,7 @@ const render = () => {
plugins: [{
id: 'vLine',
beforeDraw(chart, args, options) {
if (chart.tooltip._active && chart.tooltip._active.length) {
if (chart.tooltip?._active?.length) {
const activePoint = chart.tooltip._active[0];
const ctx = chart.ctx;
const x = activePoint.element.x;

View file

@ -6,7 +6,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Prism } from 'prismjs';
import Prism from 'prismjs';
import 'prismjs/themes/prism-okaidia.css';
const props = defineProps<{

View file

@ -59,7 +59,7 @@ const isThumbnailAvailable = computed(() => {
display: flex;
background: var(--panel);
border-radius: 8px;
overflow: hidden; overflow: clip;
overflow: clip;
> .icon-sub {
position: absolute;

View file

@ -13,7 +13,7 @@
</template>
<script lang="ts" setup>
import { } from 'vue';
import { } from 'vue';
import * as Misskey from 'misskey-js';
import XDrive from './drive.vue';
import XWindow from '@/components/ui/window.vue';

View file

@ -26,7 +26,8 @@
</div>
<button class="menu _button" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button>
</nav>
<div ref="main" class="main"
<div
ref="main" class="main"
:class="{ uploading: uploadings.length > 0, fetching }"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@ -142,7 +143,7 @@ const isDragSource = ref(false);
const fetching = ref(true);
const ilFilesObserver = new IntersectionObserver(
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles()
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
);
watch(folder, () => emit('cd', folder.value));
@ -232,7 +233,7 @@ function onDrop(ev: DragEvent): any {
removeFile(file.id);
os.api('drive/files/update', {
fileId: file.id,
folderId: folder.value ? folder.value.id : null
folderId: folder.value ? folder.value.id : null,
});
}
//#endregion
@ -248,7 +249,7 @@ function onDrop(ev: DragEvent): any {
removeFolder(droppedFolder.id);
os.api('drive/folders/update', {
folderId: droppedFolder.id,
parentId: folder.value ? folder.value.id : null
parentId: folder.value ? folder.value.id : null,
}).then(() => {
// noop
}).catch(err => {
@ -256,13 +257,13 @@ function onDrop(ev: DragEvent): any {
case 'detected-circular-definition':
os.alert({
title: i18n.ts.unableToProcess,
text: i18n.ts.circularReferenceFolder
text: i18n.ts.circularReferenceFolder,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.somethingHappened
text: i18n.ts.somethingHappened,
});
}
});
@ -278,17 +279,17 @@ function urlUpload() {
os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription
placeholder: i18n.ts.uploadFromUrlDescription,
}).then(({ canceled, result: url }) => {
if (canceled || !url) return;
os.api('drive/files/upload-from-url', {
url: url,
folderId: folder.value ? folder.value.id : undefined
folderId: folder.value ? folder.value.id : undefined,
});
os.alert({
title: i18n.ts.uploadFromUrlRequested,
text: i18n.ts.uploadFromUrlMayTakeTime
text: i18n.ts.uploadFromUrlMayTakeTime,
});
});
}
@ -296,12 +297,12 @@ function urlUpload() {
function createFolder() {
os.inputText({
title: i18n.ts.createFolder,
placeholder: i18n.ts.folderName
placeholder: i18n.ts.folderName,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/folders/create', {
name: name,
parentId: folder.value ? folder.value.id : undefined
parentId: folder.value ? folder.value.id : undefined,
}).then(createdFolder => {
addFolder(createdFolder, true);
});
@ -312,12 +313,12 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
os.inputText({
title: i18n.ts.renameFolder,
placeholder: i18n.ts.inputNewFolderName,
default: folderToRename.name
default: folderToRename.name,
}).then(({ canceled, result: name }) => {
if (canceled) return;
os.api('drive/folders/update', {
folderId: folderToRename.id,
name: name
name: name,
}).then(updatedFolder => {
// FIXME:
move(updatedFolder);
@ -327,7 +328,7 @@ function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
os.api('drive/folders/delete', {
folderId: folderToDelete.id
folderId: folderToDelete.id,
}).then(() => {
//
move(folderToDelete.parentId);
@ -337,15 +338,15 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
os.alert({
type: 'error',
title: i18n.ts.unableToDelete,
text: i18n.ts.hasChildFilesOrFolders
text: i18n.ts.hasChildFilesOrFolders,
});
break;
default:
os.alert({
type: 'error',
text: i18n.ts.unableToDelete
text: i18n.ts.unableToDelete,
});
}
}
});
}
@ -411,7 +412,7 @@ function move(target?: Misskey.entities.DriveFolder) {
fetching.value = true;
os.api('drive/folders/show', {
folderId: target
folderId: target,
}).then(folderToMove => {
folder.value = folderToMove;
hierarchyFolders.value = [];
@ -510,7 +511,7 @@ async function fetch() {
const foldersPromise = os.api('drive/folders', {
folderId: folder.value ? folder.value.id : null,
limit: foldersMax + 1
limit: foldersMax + 1,
}).then(fetchedFolders => {
if (fetchedFolders.length === foldersMax + 1) {
moreFolders.value = true;
@ -522,7 +523,7 @@ async function fetch() {
const filesPromise = os.api('drive/files', {
folderId: folder.value ? folder.value.id : null,
type: props.type,
limit: filesMax + 1
limit: filesMax + 1,
}).then(fetchedFiles => {
if (fetchedFiles.length === filesMax + 1) {
moreFiles.value = true;
@ -549,7 +550,7 @@ function fetchMoreFiles() {
folderId: folder.value ? folder.value.id : null,
type: props.type,
untilId: files.value[files.value.length - 1].id,
limit: max + 1
limit: max + 1,
}).then(files => {
if (files.length === max + 1) {
moreFiles.value = true;
@ -569,30 +570,30 @@ function getMenu() {
ref: keepOriginal,
}, null, {
text: i18n.ts.addFile,
type: 'label'
type: 'label',
}, {
text: i18n.ts.upload,
icon: 'fas fa-upload',
action: () => { selectLocalFile(); }
action: () => { selectLocalFile(); },
}, {
text: i18n.ts.fromUrl,
icon: 'fas fa-link',
action: () => { urlUpload(); }
action: () => { urlUpload(); },
}, null, {
text: folder.value ? folder.value.name : i18n.ts.drive,
type: 'label'
type: 'label',
}, folder.value ? {
text: i18n.ts.renameFolder,
icon: 'fas fa-i-cursor',
action: () => { renameFolder(folder.value); }
action: () => { renameFolder(folder.value); },
} : undefined, folder.value ? {
text: i18n.ts.deleteFolder,
icon: 'fas fa-trash-alt',
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); }
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
} : undefined, {
text: i18n.ts.createFolder,
icon: 'fas fa-folder-plus',
action: () => { createFolder(); }
action: () => { createFolder(); },
}];
}
@ -662,14 +663,14 @@ onBeforeUnmount(() => {
> .path {
display: inline-block;
vertical-align: bottom;
line-height: 50px;
line-height: 42px;
white-space: nowrap;
> * {
display: inline-block;
margin: 0;
padding: 0 8px;
line-height: 50px;
line-height: 42px;
cursor: pointer;
* {

View file

@ -1,15 +1,17 @@
<template>
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
<section>
<header class="_acrylic" @click="shown = !shown">
<i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
</header>
<div v-if="shown">
<button v-for="emoji in emojis"
<div v-if="shown" class="body">
<button
v-for="emoji in emojis"
:key="emoji"
class="_button"
class="_button item"
@click="emit('chosen', emoji, $event)"
>
<MkEmoji :emoji="emoji" :normal="true"/>
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>

View file

@ -3,63 +3,67 @@
<input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @paste.stop="paste" @keyup.enter="done()">
<div ref="emojis" class="emojis">
<section class="result">
<div v-if="searchResultCustom.length > 0">
<button v-for="emoji in searchResultCustom"
<div v-if="searchResultCustom.length > 0" class="body">
<button
v-for="emoji in searchResultCustom"
:key="emoji.id"
class="_button"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
<img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
<img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
</button>
</div>
<div v-if="searchResultUnicode.length > 0">
<button v-for="emoji in searchResultUnicode"
<div v-if="searchResultUnicode.length > 0" class="body">
<button
v-for="emoji in searchResultUnicode"
:key="emoji.name"
class="_button"
class="_button item"
:title="emoji.name"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji :emoji="emoji.char"/>
<MkEmoji class="emoji" :emoji="emoji.char"/>
</button>
</div>
</section>
<div v-if="tab === 'index'" class="index">
<div v-if="tab === 'index'" class="group index">
<section v-if="showPinned">
<div>
<button v-for="emoji in pinned"
<div class="body">
<button
v-for="emoji in pinned"
:key="emoji"
class="_button"
class="_button item"
tabindex="0"
@click="chosen(emoji, $event)"
>
<MkEmoji :emoji="emoji" :normal="true"/>
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
<section>
<header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.ts.recentUsed }}</header>
<div>
<button v-for="emoji in recentlyUsedEmojis"
<div class="body">
<button
v-for="emoji in recentlyUsedEmojis"
:key="emoji"
class="_button"
class="_button item"
@click="chosen(emoji, $event)"
>
<MkEmoji :emoji="emoji" :normal="true"/>
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
</button>
</div>
</section>
</div>
<div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection>
</div>
<div>
<div v-once class="group">
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
<XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
</div>
@ -76,6 +80,7 @@
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from 'vue';
import * as Misskey from 'misskey-js';
import XSection from './emoji-picker.section.vue';
import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import Ripple from '@/components/ripple.vue';
@ -83,7 +88,6 @@ import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { deviceKind } from '@/scripts/device-kind';
import { emojiCategories, instance } from '@/instance';
import XSection from './emoji-picker.section.vue';
import { i18n } from '@/i18n';
import { defaultStore } from '@/store';
@ -266,7 +270,7 @@ watch(q, () => {
function focus() {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
search.value?.focus({
preventScroll: true
preventScroll: true,
});
}
}
@ -415,19 +419,16 @@ defineExpose({
font-size: 15px;
}
> div {
> .body {
display: grid;
grid-template-columns: var(--columns);
font-size: 30px;
> button {
> .item {
aspect-ratio: 1 / 1;
width: auto;
height: auto;
min-width: 0;
> * {
font-size: 30px;
}
}
}
}
@ -478,7 +479,7 @@ defineExpose({
display: none;
}
> div {
> .group {
&:not(.index) {
padding: 4px 0 8px 0;
border-top: solid 0.5px var(--divider);
@ -513,16 +514,18 @@ defineExpose({
}
}
> div {
> .body {
position: relative;
padding: $pad;
> button {
> .item {
position: relative;
padding: 0;
width: var(--eachSize);
height: var(--eachSize);
contain: strict;
border-radius: 4px;
font-size: 24px;
&:focus-visible {
outline: solid 2px var(--focus);
@ -538,8 +541,7 @@ defineExpose({
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
}
> * {
font-size: 24px;
> .emoji {
height: 1.25em;
vertical-align: -.25em;
pointer-events: none;

View file

@ -41,7 +41,7 @@ import { useInterval } from '@/scripts/use-interval';
const props = defineProps<{
modelValue: string | number;
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time';
type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time' | 'search';
required?: boolean;
readonly?: boolean;
disabled?: boolean;
@ -77,9 +77,9 @@ const inputEl = ref<HTMLElement>();
const prefixEl = ref<HTMLElement>();
const suffixEl = ref<HTMLElement>();
const height =
props.small ? 38 :
props.large ? 42 :
40;
props.small ? 36 :
props.large ? 40 :
38;
const focus = () => inputEl.value.focus();
const onInput = (ev: KeyboardEvent) => {

View file

@ -189,7 +189,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
height: 3px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: hidden; overflow: clip;
overflow: clip;
> .highlight {
position: absolute;
@ -198,7 +198,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
height: 100%;
background: var(--accent);
opacity: 0.5;
transition: width 0.2s cubic-bezier(0,0,0,1);
//transition: width 0.2s cubic-bezier(0,0,0,1);
}
}
@ -231,7 +231,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
cursor: grab;
background: var(--accent);
border-radius: 999px;
transition: left 0.2s cubic-bezier(0,0,0,1);
//transition: left 0.2s cubic-bezier(0,0,0,1);
&:hover {
background: var(--accentLighten);

View file

@ -63,9 +63,9 @@ const prefixEl = ref(null);
const suffixEl = ref(null);
const container = ref(null);
const height =
props.small ? 38 :
props.large ? 42 :
40;
props.small ? 36 :
props.large ? 40 :
38;
const focus = () => inputEl.value.focus();
const onInput = (ev) => {

View file

@ -50,7 +50,7 @@ function onContextmenu(ev) {
icon: 'fas fa-expand-alt',
text: i18n.ts.showInPage,
action: () => {
router.push(props.to);
router.push(props.to, 'forcePage');
},
}, null, {
icon: 'fas fa-external-link-alt',
@ -79,7 +79,7 @@ function popout() {
popout_(props.to);
}
function nav() {
function nav(ev: MouseEvent) {
if (props.behavior === 'browser') {
location.href = props.to;
return;
@ -93,6 +93,10 @@ function nav() {
}
}
router.push(props.to);
if (ev.shiftKey) {
return openWindow();
}
router.push(props.to, ev.ctrlKey ? 'forcePage' : null);
}
</script>

View file

@ -3,7 +3,7 @@
<div v-if="!showMenu" class="main" :class="ad.place">
<a :href="ad.url" target="_blank">
<img :src="ad.imageUrl">
<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle"></span></button>
<button class="_button menu" @click.prevent.stop="toggleMenu"><span class="fas fa-info-circle info-circle"></span></button>
</a>
</div>
<div v-else class="menu">
@ -135,13 +135,19 @@ export default defineComponent({
display: block;
object-fit: contain;
margin: auto;
border-radius: 5px;
}
> .menu {
position: absolute;
top: 0;
right: 0;
background: var(--panel);
top: 1px;
right: 1px;
> .info-circle {
border: 3px solid var(--panel);
border-radius: 50%;
background: var(--panel);
}
}
}

View file

@ -1,68 +1,41 @@
char2filePath<template>
<template>
<img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/>
<img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/>
<span v-else-if="char && useOsNativeEmojis">{{ char }}</span>
<span v-else>{{ emoji }}</span>
</template>
<script lang="ts">
import { computed, defineComponent, ref, watch } from 'vue';
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { CustomEmoji } from 'misskey-js/built/entities';
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import { char2filePath } from '@/scripts/twemoji-base';
import { defaultStore } from '@/store';
import { instance } from '@/instance';
export default defineComponent({
props: {
emoji: {
type: String,
required: true
},
normal: {
type: Boolean,
required: false,
default: false
},
noStyle: {
type: Boolean,
required: false,
default: false
},
customEmojis: {
required: false
},
isReaction: {
type: Boolean,
default: false
},
},
const props = defineProps<{
emoji: string;
normal?: boolean;
noStyle?: boolean;
customEmojis?: CustomEmoji[];
isReaction?: boolean;
}>();
setup(props) {
const isCustom = computed(() => props.emoji.startsWith(':'));
const char = computed(() => isCustom.value ? null : props.emoji);
const useOsNativeEmojis = computed(() => defaultStore.state.useOsNativeEmojis && !props.isReaction);
const ce = computed(() => props.customEmojis || instance.emojis || []);
const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null);
const url = computed(() => {
if (char.value) {
return char2filePath(char.value);
} else {
return defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(customEmoji.value.url)
: customEmoji.value.url;
}
});
const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value);
return {
url,
char,
alt,
customEmoji,
useOsNativeEmojis,
};
},
const isCustom = computed(() => props.emoji.startsWith(':'));
const char = computed(() => isCustom.value ? null : props.emoji);
const useOsNativeEmojis = computed(() => defaultStore.state.useOsNativeEmojis && !props.isReaction);
const ce = computed(() => props.customEmojis ?? instance.emojis ?? []);
const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null);
const url = computed(() => {
if (char.value) {
return char2filePath(char.value);
} else {
return defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(customEmoji.value.url)
: customEmoji.value.url;
}
});
const alt = computed(() => customEmoji.value ? `:${customEmoji.value.name}:` : char.value);
</script>
<style lang="scss" scoped>

View file

@ -16,9 +16,7 @@
</template>
<script lang="ts" setup>
import { useCssModule } from 'vue';
useCssModule();
import { } from 'vue';
const props = withDefaults(defineProps<{
inline?: boolean;

View file

@ -18,7 +18,7 @@
</div>
</div>
<div v-if="!narrow || hideTitle" class="tabs">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip.noDelay="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)">
<i v-if="tab.icon" class="icon" :class="tab.icon"></i>
<span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span>
</button>
@ -27,7 +27,7 @@
</template>
<div class="buttons right">
<template v-for="action in actions">
<button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
<button v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>
</div>
</div>
@ -178,6 +178,8 @@ onUnmounted(() => {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
border-bottom: solid 0.5px var(--divider);
contain: strict;
height: var(--height);
&.thin {
--height: 45px;

View file

@ -1,5 +1,6 @@
<template>
<component :is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
<component
:is="self ? 'MkA' : 'a'" ref="el" class="ieqqeuvs _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
@contextmenu.stop="() => {}"
>
<template v-if="!self">
@ -23,14 +24,7 @@ import { toUnicode as decodePunycode } from 'punycode/';
import { url as local } from '@/config';
import * as os from '@/os';
import { useTooltip } from '@/scripts/use-tooltip';
function safeURIDecode(str: string) {
try {
return decodeURIComponent(str);
} catch {
return str;
}
}
import { safeURIDecode } from '@/scripts/safe-uri-decode';
export default defineComponent({
props: {
@ -42,7 +36,7 @@ export default defineComponent({
type: String,
required: false,
default: null,
}
},
},
setup(props) {
const self = props.url.startsWith(local);

View file

@ -11,7 +11,7 @@ import { decode } from 'blurhash';
const props = withDefaults(defineProps<{
src?: string | null;
hash: string;
hash?: string;
alt?: string;
title?: string | null;
size?: number;

View file

@ -154,7 +154,7 @@ function createDoughnut(chartEl, tooltip, data) {
}
onMounted(() => {
os.apiGet('federation/stats', { limit: 15 }).then(fedStats => {
os.apiGet('federation/stats', { limit: 30 }).then(fedStats => {
createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({
name: x.host,
color: x.themeColor,

View file

@ -8,6 +8,7 @@
<script lang="ts" setup>
import { } from 'vue';
import { instanceName } from '@/config';
import { instance as Instance } from '@/instance';
const props = defineProps<{
instance?: {
@ -19,7 +20,7 @@ const props = defineProps<{
// if no instance data is given, this is for the local instance
const instance = props.instance ?? {
faviconUrl: '/favicon.ico',
faviconUrl: Instance.iconUrl || Instance.faviconUrl || '/favicon.ico',
name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
};

View file

@ -15,20 +15,6 @@
</MkA>
</template>
</div>
<div class="sub">
<button v-click-anime class="_button" @click="help">
<i class="fas fa-question-circle icon"></i>
<div class="text">{{ $ts.help }}</div>
</button>
<MkA v-click-anime to="/about" @click.passive="close()">
<i class="fas fa-info-circle icon"></i>
<div class="text">{{ $ts.instanceInfo }}</div>
</MkA>
<MkA v-click-anime to="/about-misskey" @click.passive="close()">
<img src="/static-assets/favicon.png" class="icon"/>
<div class="text">{{ $ts.aboutMisskey }}</div>
</MkA>
</div>
</div>
</MkModal>
</template>
@ -36,7 +22,7 @@
<script lang="ts" setup>
import { } from 'vue';
import MkModal from '@/components/ui/modal.vue';
import { menuDef } from '@/menu';
import { navbarItemDef } from '@/navbar';
import { instanceName } from '@/config';
import { defaultStore } from '@/store';
import { i18n } from '@/i18n';
@ -62,7 +48,7 @@ const modal = $ref<InstanceType<typeof MkModal>>();
const menu = defaultStore.state.menu;
const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
type: def.to ? 'link' : 'button',
text: i18n.ts[def.title],
icon: def.icon,
@ -74,28 +60,6 @@ const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuD
function close() {
modal.close();
}
function help(ev: MouseEvent) {
os.popupMenu([{
type: 'link',
to: '/mfm-cheat-sheet',
text: i18n.ts._mfm.cheatSheet,
icon: 'fas fa-code',
}, {
type: 'link',
to: '/scratchpad',
text: i18n.ts.scratchpad,
icon: 'fas fa-terminal',
}, null, {
text: i18n.ts.document,
icon: 'fas fa-question-circle',
action: () => {
window.open('https://misskey-hub.net/help.html', '_blank');
},
}], ev.currentTarget ?? ev.target);
close();
}
</script>
<style lang="scss" scoped>

View file

@ -76,11 +76,17 @@ export default {
<style lang="scss" module>
.wrap {
overflow: hidden; overflow: clip;
overflow: clip;
animation-play-state: running;
&:hover {
animation-play-state: paused;
}
}
.content {
display: inline-block;
white-space: nowrap;
animation-play-state: inherit;
}
.text {
display: inline-block;
@ -88,6 +94,7 @@ export default {
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
animation-play-state: inherit;
}
.paused .text {
animation-play-state: paused;

View file

@ -2,9 +2,9 @@
<div v-if="hide" class="qjewsnkg" @click="hide = false">
<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
<div class="text">
<div>
<b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
<span>{{ $ts.clickToShow }}</span>
<div class="wrapper">
<b style="display: block;"><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b>
<span style="display: block;">{{ $ts.clickToShow }}</span>
</div>
</div>
</div>
@ -37,8 +37,8 @@ let hide = $ref(true);
const url = (props.raw || defaultStore.state.loadRawImages)
? props.image.url
: defaultStore.state.disableShowingAnimatedImages
? getStaticImageUrl(props.image.thumbnailUrl)
: props.image.thumbnailUrl;
? getStaticImageUrl(props.image.thumbnailUrl)
: props.image.thumbnailUrl;
// Plugin:register_note_view_interruptor 使watch
watch(() => props.image, () => {
@ -68,15 +68,11 @@ watch(() => props.image, () => {
justify-content: center;
align-items: center;
> div {
> .wrapper {
display: table-cell;
text-align: center;
font-size: 0.8em;
color: #fff;
> * {
display: block;
}
}
}
}

View file

@ -1,22 +1,22 @@
<template>
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bgCss }">
<img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt="">
<MkA v-if="url.startsWith('/')" v-user-preview="canonical" class="akbvjaqn" :class="{ isMe }" :to="url" :style="{ background: bgCss }">
<img class="icon" :src="`/avatar/@${username}@${host}`" alt="">
<span class="main">
<span class="username">@{{ username }}</span>
<span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.mainHost">@{{ toUnicode(host) }}</span>
<span v-if="(host != localHost) || $store.state.showFullAcct" class="host">@{{ toUnicode(host) }}</span>
</span>
</MkA>
<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
<a v-else class="akbvjaqn" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }">
<span class="main">
<span class="username">@{{ username }}</span>
<span :class="$style.mainHost">@{{ toUnicode(host) }}</span>
<span class="host">@{{ toUnicode(host) }}</span>
</span>
</a>
</template>
<script lang="ts" setup>
import { toUnicode } from 'punycode';
import { useCssModule } from 'vue';
import { } from 'vue';
import tinycolor from 'tinycolor2';
import { host as localHost } from '@/config';
import { $i } from '@/account';
@ -37,12 +37,10 @@ const isMe = $i && (
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
bg.setAlpha(0.1);
const bgCss = bg.toRgbString();
useCssModule();
</script>
<style lang="scss" module>
.root {
<style lang="scss" scoped>
.akbvjaqn {
display: inline-block;
padding: 4px 8px 4px 4px;
border-radius: 999px;
@ -51,18 +49,18 @@ useCssModule();
&.isMe {
color: var(--mentionMe);
}
}
.icon {
width: 1.5em;
height: 1.5em;
object-fit: cover;
margin: 0 0.2em 0 0;
vertical-align: bottom;
border-radius: 100%;
}
> .icon {
width: 1.5em;
height: 1.5em;
object-fit: cover;
margin: 0 0.2em 0 0;
vertical-align: bottom;
border-radius: 100%;
}
.mainHost {
opacity: 0.5;
> .main > .host {
opacity: 0.5;
}
}
</style>

View file

@ -47,7 +47,7 @@ export default defineComponent({
render() {
if (this.text == null || this.text === '') return;
const ast = (this.plain ? mfm.parsePlain : mfm.parse)(this.text, { fnNameList: MFM_TAGS });
const ast = (this.plain ? mfm.parseSimple : mfm.parse)(this.text, { fnNameList: MFM_TAGS });
const validTime = (t: string | null | undefined) => {
if (t == null) return null;
@ -312,6 +312,10 @@ export default defineComponent({
})];
}
case 'plain': {
return [h('span', genEl(token.children))];
}
default: {
console.error('unrecognized ast type:', token.type);

View file

@ -26,12 +26,7 @@
<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
<span v-if="note.visibility !== 'public'" class="visibility">
<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
</span>
<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
<MkVisibility :note="note"/>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
@ -43,12 +38,9 @@
<MkUserName :user="appearNote.user"/>
</MkA>
<span v-if="appearNote.user.isBot" class="is-bot">bot</span>
<span v-if="appearNote.visibility !== 'public'" class="visibility">
<i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i>
<i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i>
<i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i>
</span>
<span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
<div class="info">
<MkVisibility :note="appearNote"/>
</div>
</div>
<div class="username"><MkAcct :user="appearNote.user"/></div>
<MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/>
@ -134,6 +126,7 @@ import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import MkVisibility from '@/components/visibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { checkWordMute } from '@/scripts/check-word-mute';
import { userPage } from '@/filters/user';
@ -388,14 +381,6 @@ if (appearNote.replyId) {
margin-right: 4px;
}
}
> .visibility {
margin-left: 8px;
}
> .localOnly {
margin-left: 8px;
}
}
}
@ -405,7 +390,7 @@ if (appearNote.replyId) {
> .article {
padding: 32px;
font-size: 1.1em;
font-size: 1.2em;
> .header {
display: flex;
@ -441,6 +426,10 @@ if (appearNote.replyId) {
border: solid 0.5px var(--divider);
border-radius: 4px;
}
> .info {
float: right;
}
}
}
}

View file

@ -9,12 +9,7 @@
<MkA class="created-at" :to="notePage(note)">
<MkTime :time="note.createdAt"/>
</MkA>
<span v-if="note.visibility !== 'public'" class="visibility">
<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
</span>
<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
<MkVisibility :note="note"/>
</div>
</header>
</template>
@ -22,6 +17,7 @@
<script lang="ts" setup>
import { } from 'vue';
import * as misskey from 'misskey-js';
import MkVisibility from '@/components/visibility.vue';
import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user';
@ -74,14 +70,6 @@ defineProps<{
flex-shrink: 0;
margin-left: auto;
font-size: 0.9em;
> .visibility {
margin-left: 8px;
}
> .localOnly {
margin-left: 8px;
}
}
}
</style>

View file

@ -27,7 +27,7 @@ const props = defineProps<{
display: flex;
margin: 0;
padding: 0;
overflow: hidden; overflow: clip;
overflow: clip;
font-size: 0.95em;
&.min-width_350px {

View file

@ -36,7 +36,7 @@ const showContent = $ref(false);
display: flex;
margin: 0;
padding: 0;
overflow: hidden; overflow: clip;
overflow: clip;
font-size: 0.95em;
&.min-width_350px {

View file

@ -28,12 +28,7 @@
<i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i>
<MkTime :time="note.createdAt"/>
</button>
<span v-if="note.visibility !== 'public'" class="visibility">
<i v-if="note.visibility === 'home'" class="fas fa-home"></i>
<i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i>
<i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i>
</span>
<span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span>
<MkVisibility :note="note"/>
</div>
</div>
<article class="article" @contextmenu.stop="onContextmenu">
@ -118,6 +113,7 @@ import XPoll from './poll.vue';
import XRenoteButton from './renote-button.vue';
import MkUrlPreview from '@/components/url-preview.vue';
import MkInstanceTicker from '@/components/instance-ticker.vue';
import MkVisibility from '@/components/visibility.vue';
import { pleaseLogin } from '@/scripts/please-login';
import { focusPrev, focusNext } from '@/scripts/focus';
import { checkWordMute } from '@/scripts/check-word-mute';
@ -297,7 +293,7 @@ function readPromo() {
position: relative;
transition: box-shadow 0.1s ease;
font-size: 1.05em;
overflow: hidden; overflow: clip;
overflow: clip;
contain: content;
//
@ -406,14 +402,6 @@ function readPromo() {
margin-right: 4px;
}
}
> .visibility {
margin-left: 8px;
}
> .localOnly {
margin-left: 8px;
}
}
}
@ -566,6 +554,13 @@ function readPromo() {
&.max-width_500px {
font-size: 0.9em;
> .article {
> .avatar {
width: 50px;
height: 50px;
}
}
}
&.max-width_450px {
@ -582,8 +577,8 @@ function readPromo() {
> .avatar {
margin: 0 10px 8px 0;
width: 50px;
height: 50px;
width: 46px;
height: 46px;
top: calc(14px + var(--stickyTop, 0px));
}
}
@ -604,8 +599,6 @@ function readPromo() {
}
&.max-width_300px {
font-size: 0.825em;
> .article {
> .avatar {
width: 44px;

View file

@ -177,13 +177,7 @@ useTooltip(reactionRef, (showing) => {
&.max-width_500px {
padding: 12px;
font-size: 0.8em;
}
&:after {
content: "";
display: block;
clear: both;
font-size: 0.85em;
}
> .head {

View file

@ -1,5 +1,5 @@
<template>
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1">
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1">
<div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div>
<article>
<header>
@ -23,12 +23,12 @@ export default defineComponent({
props: {
page: {
type: Object,
required: true
required: true,
},
},
methods: {
userName
}
userName,
},
});
</script>

View file

@ -122,7 +122,7 @@ function close() {
}
function expand() {
mainRouter.push(router.getCurrentPath());
mainRouter.push(router.getCurrentPath(), 'forcePage');
windowEl.close();
}

View file

@ -2,7 +2,7 @@
<div v-show="files.length != 0" class="skeikyzd">
<XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true">
<template #item="{element}">
<div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
<div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
<MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/>
<div v-if="element.isSensitive" class="sensitive">
<i class="fas fa-exclamation-triangle icon"></i>
@ -22,18 +22,18 @@ import * as os from '@/os';
export default defineComponent({
components: {
XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)),
MkDriveFileThumbnail
MkDriveFileThumbnail,
},
props: {
files: {
type: Array,
required: true
required: true,
},
detachMediaFn: {
type: Function,
required: false
}
required: false,
},
},
emits: ['updated', 'detach', 'changeSensitive', 'changeName'],
@ -51,8 +51,8 @@ export default defineComponent({
},
set(value) {
this.$emit('updated', value);
}
}
},
},
},
methods: {
@ -66,7 +66,7 @@ export default defineComponent({
toggleSensitive(file) {
os.api('drive/files/update', {
fileId: file.id,
isSensitive: !file.isSensitive
isSensitive: !file.isSensitive,
}).then(() => {
this.$emit('changeSensitive', file, !file.isSensitive);
});
@ -75,12 +75,12 @@ export default defineComponent({
const { canceled, result } = await os.inputText({
title: this.$ts.enterFileName,
default: file.name,
allowEmpty: false
allowEmpty: false,
});
if (canceled) return;
os.api('drive/files/update', {
fileId: file.id,
name: result
name: result,
}).then(() => {
this.$emit('changeName', file, result);
file.name = result;
@ -88,13 +88,13 @@ export default defineComponent({
},
async describe(file) {
os.popup(defineAsyncComponent(() => import("@/components/media-caption.vue")), {
os.popup(defineAsyncComponent(() => import('@/components/media-caption.vue')), {
title: this.$ts.describeFile,
input: {
placeholder: this.$ts.inputNewDescription,
default: file.comment !== null ? file.comment : "",
default: file.comment !== null ? file.comment : '',
},
image: file
image: file,
}, {
done: result => {
if (!result || result.canceled) return;
@ -105,7 +105,7 @@ export default defineComponent({
}).then(() => {
file.comment = comment;
});
}
},
}, 'closed');
},
@ -114,22 +114,22 @@ export default defineComponent({
this.menu = os.popupMenu([{
text: this.$ts.renameFile,
icon: 'fas fa-i-cursor',
action: () => { this.rename(file); }
action: () => { this.rename(file); },
}, {
text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive,
icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye',
action: () => { this.toggleSensitive(file); }
action: () => { this.toggleSensitive(file); },
}, {
text: this.$ts.describeFile,
icon: 'fas fa-i-cursor',
action: () => { this.describe(file); }
action: () => { this.describe(file); },
}, {
text: this.$ts.attachCancel,
icon: 'fas fa-times-circle',
action: () => { this.detachMedia(file.id); }
action: () => { this.detachMedia(file.id); },
}], ev.currentTarget ?? ev.target).then(() => this.menu = null);
}
}
},
},
});
</script>
@ -142,7 +142,7 @@ export default defineComponent({
display: flex;
flex-wrap: wrap;
> div {
> .file {
position: relative;
width: 64px;
height: 64px;

View file

@ -1,5 +1,6 @@
<template>
<div v-size="{ max: [310, 500] }" class="gafaadew"
<div
v-size="{ max: [310, 500] }" class="gafaadew"
:class="{ modal, _popup: modal }"
@dragover.stop="onDragover"
@dragenter="onDragenter"
@ -11,7 +12,7 @@
<button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu">
<MkAvatar :user="postAccount ?? $i" class="avatar"/>
</button>
<div>
<div class="right">
<span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span>
<span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span>
<button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility">
@ -68,6 +69,8 @@ import * as misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor';
import { length } from 'stringz';
import { toASCII } from 'punycode/';
import * as Acct from 'misskey-js/built/acct';
import { throttle } from 'throttle-debounce';
import XNoteSimple from './note-simple.vue';
import XNotePreview from './note-preview.vue';
import XPostFormAttaches from './post-form-attaches.vue';
@ -75,14 +78,12 @@ import XPollEditor from './poll-editor.vue';
import { host, url } from '@/config';
import { erase, unique } from '@/scripts/array';
import { extractMentions } from '@/scripts/extract-mentions';
import * as Acct from 'misskey-js/built/acct';
import { formatTimeString } from '@/scripts/format-time-string';
import { Autocomplete } from '@/scripts/autocomplete';
import * as os from '@/os';
import { stream } from '@/stream';
import { selectFiles } from '@/scripts/select-file';
import { defaultStore, notePostInterruptors, postFormActions } from '@/store';
import { throttle } from 'throttle-debounce';
import MkInfo from '@/components/ui/info.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
@ -181,7 +182,7 @@ const placeholder = $computed((): string => {
i18n.ts._postForm._placeholders.c,
i18n.ts._postForm._placeholders.d,
i18n.ts._postForm._placeholders.e,
i18n.ts._postForm._placeholders.f
i18n.ts._postForm._placeholders.f,
];
return xs[Math.floor(Math.random() * xs.length)];
}
@ -238,10 +239,10 @@ if (props.reply && props.reply.text != null) {
for (const x of extractMentions(ast)) {
const mention = x.host ?
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
`@${x.username}@${toASCII(x.host)}` :
(otherHost == null || otherHost === host) ?
`@${x.username}` :
`@${x.username}@${toASCII(otherHost)}`;
//
if ($i.username === x.username && (x.host == null || x.host === host)) continue;
@ -263,7 +264,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
visibility = props.reply.visibility;
if (props.reply.visibility === 'specified') {
os.api('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId)
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId),
}).then(users => {
users.forEach(pushVisibleUser);
});
@ -399,7 +400,7 @@ function setVisibility() {
if (defaultStore.state.rememberNoteVisibility) {
defaultStore.set('localOnly', localOnly);
}
}
},
}, 'closed');
}
@ -522,8 +523,8 @@ function saveDraft() {
visibility: visibility,
localOnly: localOnly,
files: files,
poll: poll
}
poll: poll,
},
};
localStorage.setItem('drafts', JSON.stringify(draftData));
@ -612,11 +613,11 @@ function showActions(ev) {
text: action.title,
action: () => {
action.handler({
text: text
text: text,
}, (key, value) => {
if (key === 'text') { text = value; }
});
}
},
})), ev.currentTarget ?? ev.target);
}
@ -726,7 +727,7 @@ onMounted(() => {
}
}
> div {
> .right {
position: absolute;
top: 0;
right: 0;
@ -924,7 +925,7 @@ onMounted(() => {
line-height: 50px;
}
> div {
> .right {
> .text-count {
line-height: 50px;
}

View file

@ -50,14 +50,14 @@ const emit = defineEmits<{
}
> .name {
font-size: 0.9em;
font-size: 1em;
}
}
> .users {
flex: 1;
min-width: 0;
font-size: 0.9em;
font-size: 0.95em;
border-left: solid 0.5px var(--divider);
padding-left: 10px;
margin-left: 10px;

View file

@ -70,10 +70,11 @@ onMounted(() => {
});
useTooltip(buttonRef, async (showing) => {
const reactions = await os.api('notes/reactions', {
const reactions = await os.apiGet('notes/reactions', {
noteId: props.note.id,
type: props.reaction,
limit: 11,
_cacheKey_: props.count,
});
const users = reactions.map(x => x.user);

View file

@ -1,5 +1,5 @@
<template>
<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div>
<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div>
</template>
<script lang="ts" setup>
@ -15,7 +15,7 @@ defineProps<{
background: var(--infoWarnBg);
color: var(--infoWarnFg);
> a {
> .link {
margin-left: 4px;
color: var(--accent);
}

View file

@ -14,7 +14,7 @@
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import XDetails from '@/components/renote.details.vue';
import XDetails from '@/components/users-tooltip.vue';
import { pleaseLogin } from '@/scripts/please-login';
import * as os from '@/os';
import { $i } from '@/account';

View file

@ -1,255 +1,234 @@
<template>
<form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit">
<template v-if="meta">
<MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required>
<template #label>{{ $ts.invitationCode }}</template>
<template #prefix><i class="fas fa-key"></i></template>
</MkInput>
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
<template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required>
<template #label>{{ $ts.invitationCode }}</template>
<template #prefix><i class="fas fa-key"></i></template>
</MkInput>
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername">
<template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<span v-if="usernameState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.usernameInvalidFormat }}</span>
<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooShort }}</span>
<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span>
</template>
</MkInput>
<MkInput v-if="instance.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail">
<template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
<template #prefix><i class="fas fa-envelope"></i></template>
<template #caption>
<span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span>
<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span>
<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span>
<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span>
<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span>
<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
</template>
</MkInput>
<MkInput v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<template #label>{{ $ts.password }}</template>
<template #prefix><i class="fas fa-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<template #label>{{ $ts.password }} ({{ $ts.retype }})</template>
<template #prefix><i class="fas fa-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span>
</template>
</MkInput>
<MkSwitch v-if="instance.tosUrl" v-model="ToSAgreement" class="_formBlock tou">
<I18n :src="$ts.agreeTo">
<template #0>
<a :href="instance.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a>
</template>
</MkInput>
<MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail">
<template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template>
<template #prefix><i class="fas fa-envelope"></i></template>
<template #caption>
<span v-if="emailState === 'wait'" style="color:#999"><i class="fas fa-spinner fa-pulse fa-fw"></i> {{ $ts.checking }}</span>
<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.available }}</span>
<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.used }}</span>
<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.format }}</span>
<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.disposable }}</span>
<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.mx }}</span>
<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts._emailUnavailable.smtp }}</span>
<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.unavailable }}</span>
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.error }}</span>
</template>
</MkInput>
<MkInput v-model="password" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password @update:modelValue="onChangePassword">
<template #label>{{ $ts.password }}</template>
<template #prefix><i class="fas fa-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="fas fa-check fa-fw"></i> {{ $ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" class="_formBlock" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:modelValue="onChangePasswordRetype">
<template #label>{{ $ts.password }} ({{ $ts.retype }})</template>
<template #prefix><i class="fas fa-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="fas fa-check fa-fw"></i> {{ $ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.passwordNotMatched }}</span>
</template>
</MkInput>
<MkSwitch v-if="meta.tosUrl" v-model="ToSAgreement" class="_formBlock tou">
<I18n :src="$ts.agreeTo">
<template #0>
<a :href="meta.tosUrl" class="_link" target="_blank">{{ $ts.tos }}</a>
</template>
</I18n>
</MkSwitch>
<MkCaptcha v-if="meta.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="meta.hcaptchaSiteKey"/>
<MkCaptcha v-if="meta.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="meta.recaptchaSiteKey"/>
<MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton>
</template>
</I18n>
</MkSwitch>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="_formBlock captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="_formBlock captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkButton class="_formBlock" type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ $ts.start }}</MkButton>
</form>
</template>
<script lang="ts">
import { defineComponent, defineAsyncComponent } from 'vue';
<script lang="ts" setup>
import { } from 'vue';
import getPasswordStrength from 'syuilo-password-strength';
import { toUnicode } from 'punycode/';
import MkButton from './ui/button.vue';
import MkCaptcha from './captcha.vue';
import MkInput from './form/input.vue';
import MkSwitch from './form/switch.vue';
import { host, url } from '@/config';
import * as config from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSwitch,
MkCaptcha: defineAsyncComponent(() => import('./captcha.vue')),
},
props: {
autoSet: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['signup'],
data() {
return {
host: toUnicode(host),
username: '',
password: '',
retypedPassword: '',
invitationCode: '',
email: '',
url,
usernameState: null,
emailState: null,
passwordStrength: '',
passwordRetypeState: null,
submitting: false,
ToSAgreement: false,
hCaptchaResponse: null,
reCaptchaResponse: null,
};
},
computed: {
meta() {
return this.$instance;
},
shouldDisableSubmitting(): boolean {
return this.submitting ||
this.meta.tosUrl && !this.ToSAgreement ||
this.meta.enableHcaptcha && !this.hCaptchaResponse ||
this.meta.enableRecaptcha && !this.reCaptchaResponse ||
this.passwordRetypeState === 'not-match';
},
shouldShowProfileUrl(): boolean {
return (this.username !== '' &&
this.usernameState !== 'invalid-format' &&
this.usernameState !== 'min-range' &&
this.usernameState !== 'max-range');
},
},
methods: {
onChangeUsername() {
if (this.username === '') {
this.usernameState = null;
return;
}
const err =
!this.username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
this.username.length < 1 ? 'min-range' :
this.username.length > 20 ? 'max-range' :
null;
if (err) {
this.usernameState = err;
return;
}
this.usernameState = 'wait';
os.api('username/available', {
username: this.username,
}).then(result => {
this.usernameState = result.available ? 'ok' : 'unavailable';
}).catch(err => {
this.usernameState = 'error';
});
},
onChangeEmail() {
if (this.email === '') {
this.emailState = null;
return;
}
this.emailState = 'wait';
os.api('email-address/available', {
emailAddress: this.email,
}).then(result => {
this.emailState = result.available ? 'ok' :
result.reason === 'used' ? 'unavailable:used' :
result.reason === 'format' ? 'unavailable:format' :
result.reason === 'disposable' ? 'unavailable:disposable' :
result.reason === 'mx' ? 'unavailable:mx' :
result.reason === 'smtp' ? 'unavailable:smtp' :
'unavailable';
}).catch(err => {
this.emailState = 'error';
});
},
onChangePassword() {
if (this.password === '') {
this.passwordStrength = '';
return;
}
const strength = getPasswordStrength(this.password);
this.passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
},
onChangePasswordRetype() {
if (this.retypedPassword === '') {
this.passwordRetypeState = null;
return;
}
this.passwordRetypeState = this.password === this.retypedPassword ? 'match' : 'not-match';
},
onSubmit() {
if (this.submitting) return;
this.submitting = true;
os.api('signup', {
username: this.username,
password: this.password,
emailAddress: this.email,
invitationCode: this.invitationCode,
'hcaptcha-response': this.hCaptchaResponse,
'g-recaptcha-response': this.reCaptchaResponse,
}).then(() => {
if (this.meta.emailRequiredForSignup) {
os.alert({
type: 'success',
title: this.$ts._signup.almostThere,
text: this.$t('_signup.emailSent', { email: this.email }),
});
this.$emit('signupEmailPending');
} else {
os.api('signin', {
username: this.username,
password: this.password,
}).then(res => {
this.$emit('signup', res);
if (this.autoSet) {
login(res.i);
}
});
}
}).catch(() => {
this.submitting = false;
this.$refs.hcaptcha?.reset?.();
this.$refs.recaptcha?.reset?.();
os.alert({
type: 'error',
text: this.$ts.somethingHappened,
});
});
},
},
const props = withDefaults(defineProps<{
autoSet?: boolean;
}>(), {
autoSet: false,
});
const emit = defineEmits<{
(ev: 'signup', user: Record<string, any>): void;
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref();
let recaptcha = $ref();
let username: string = $ref('');
let password: string = $ref('');
let retypedPassword: string = $ref('');
let invitationCode: string = $ref('');
let email = $ref('');
let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
let submitting: boolean = $ref(false);
let ToSAgreement: boolean = $ref(false);
let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null);
const shouldDisableSubmitting = $computed((): boolean => {
return submitting ||
instance.tosUrl && !ToSAgreement ||
instance.enableHcaptcha && !hCaptchaResponse ||
instance.enableRecaptcha && !reCaptchaResponse ||
passwordRetypeState === 'not-match';
});
function onChangeUsername(): void {
if (username === '') {
usernameState = null;
return;
}
{
const err =
!username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
username.length < 1 ? 'min-range' :
username.length > 20 ? 'max-range' :
null;
if (err) {
usernameState = err;
return;
}
}
usernameState = 'wait';
os.api('username/available', {
username,
}).then(result => {
usernameState = result.available ? 'ok' : 'unavailable';
}).catch(() => {
usernameState = 'error';
});
}
function onChangeEmail(): void {
if (email === '') {
emailState = null;
return;
}
emailState = 'wait';
os.api('email-address/available', {
emailAddress: email,
}).then(result => {
emailState = result.available ? 'ok' :
result.reason === 'used' ? 'unavailable:used' :
result.reason === 'format' ? 'unavailable:format' :
result.reason === 'disposable' ? 'unavailable:disposable' :
result.reason === 'mx' ? 'unavailable:mx' :
result.reason === 'smtp' ? 'unavailable:smtp' :
'unavailable';
}).catch(() => {
emailState = 'error';
});
}
function onChangePassword(): void {
if (password === '') {
passwordStrength = '';
return;
}
const strength = getPasswordStrength(password);
passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
}
function onChangePasswordRetype(): void {
if (retypedPassword === '') {
passwordRetypeState = null;
return;
}
passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
}
function onSubmit(): void {
if (submitting) return;
submitting = true;
os.api('signup', {
username,
password,
emailAddress: email,
invitationCode,
'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse,
}).then(() => {
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', { email }),
});
emit('signupEmailPending');
} else {
os.api('signin', {
username,
password,
}).then(res => {
emit('signup', res);
if (props.autoSet) {
login(res.i);
}
});
}
}).catch(() => {
submitting = false;
hcaptcha.reset?.();
recaptcha.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
});
}
</script>
<style lang="scss" scoped>

View file

@ -71,7 +71,7 @@ defineExpose({
<style lang="scss" scoped>
.meijqfqm {
position: relative;
overflow: hidden; overflow: clip;
overflow: clip;
display: grid;
place-items: center;

View file

@ -54,7 +54,7 @@ onMounted(() => {
width: min-content;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
overflow: hidden; overflow: clip;
overflow: clip;
text-align: center;
pointer-events: none;

View file

@ -1,5 +1,6 @@
<template>
<button v-if="!link" class="bghgjjyj _button"
<button
v-if="!link" class="bghgjjyj _button"
:class="{ inline, primary, gradate, danger, rounded, full }"
:type="type"
@click="$emit('click', $event)"
@ -10,7 +11,8 @@
<slot></slot>
</div>
</button>
<MkA v-else class="bghgjjyj _button"
<MkA
v-else class="bghgjjyj _button"
:class="{ inline, primary, gradate, danger, rounded, full }"
:to="to"
@mousedown="onMousedown"
@ -29,56 +31,56 @@ export default defineComponent({
props: {
type: {
type: String,
required: false
required: false,
},
primary: {
type: Boolean,
required: false,
default: false
default: false,
},
gradate: {
type: Boolean,
required: false,
default: false
default: false,
},
rounded: {
type: Boolean,
required: false,
default: false
default: true,
},
inline: {
type: Boolean,
required: false,
default: false
default: false,
},
link: {
type: Boolean,
required: false,
default: false
default: false,
},
to: {
type: String,
required: false
required: false,
},
autofocus: {
type: Boolean,
required: false,
default: false
default: false,
},
wait: {
type: Boolean,
required: false,
default: false
default: false,
},
danger: {
type: Boolean,
required: false,
default: false
default: false,
},
full: {
type: Boolean,
required: false,
default: false
default: false,
},
},
emits: ['click'],
@ -127,8 +129,8 @@ export default defineComponent({
window.setTimeout(() => {
if (this.$refs.ripples) this.$refs.ripples.removeChild(ripple);
}, 2000);
}
}
},
},
});
</script>
@ -142,13 +144,12 @@ export default defineComponent({
padding: 8px 14px;
text-align: center;
font-weight: normal;
font-size: 0.9em;
line-height: 22px;
font-size: 1em;
box-shadow: none;
text-decoration: none;
background: var(--buttonBg);
border-radius: 5px;
overflow: hidden; overflow: clip;
overflow: clip;
box-sizing: border-box;
transition: background 0.1s ease;

View file

@ -143,7 +143,8 @@ export default defineComponent({
.ukygtjoj {
position: relative;
overflow: hidden; overflow: clip;
overflow: clip;
contain: content;
&.naked {
background: transparent !important;

View file

@ -1,16 +1,16 @@
<template>
<transition :name="$store.state.animation ? 'fade' : ''" appear>
<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
<MkMenu :items="items" class="_popup _shadow" :align="'left'" @close="$emit('closed')"/>
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
</div>
</transition>
</template>
<script lang="ts" setup>
import { onMounted, onBeforeUnmount } from 'vue';
import contains from '@/scripts/contains';
import MkMenu from './menu.vue';
import { MenuItem } from './types/menu.vue';
import contains from '@/scripts/contains';
import * as os from '@/os';
const props = defineProps<{

View file

@ -0,0 +1,65 @@
<template>
<div ref="el" class="sfhdhdhr">
<MkMenu ref="menu" :items="items" :align="align" :width="width" :as-drawer="false" @close="onChildClosed"/>
</div>
</template>
<script lang="ts" setup>
import { on } from 'events';
import { nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue';
import MkMenu from './menu.vue';
import { MenuItem } from '@/types/menu';
import * as os from '@/os';
const props = defineProps<{
items: MenuItem[];
targetElement: HTMLElement;
rootElement: HTMLElement;
width?: number;
viaKeyboard?: boolean;
}>();
const emit = defineEmits<{
(ev: 'closed'): void;
(ev: 'actioned'): void;
}>();
const el = ref<HTMLElement>();
const align = 'left';
function setPosition() {
const rootRect = props.rootElement.getBoundingClientRect();
const rect = props.targetElement.getBoundingClientRect();
const left = props.targetElement.offsetWidth;
const top = (rect.top - rootRect.top) - 8;
el.value.style.left = left + 'px';
el.value.style.top = top + 'px';
}
function onChildClosed(actioned?: boolean) {
if (actioned) {
emit('actioned');
} else {
emit('closed');
}
}
onMounted(() => {
setPosition();
nextTick(() => {
setPosition();
});
});
defineExpose({
checkHit: (ev: MouseEvent) => {
return (ev.target === el.value || el.value.contains(ev.target));
},
});
</script>
<style lang="scss" scoped>
.sfhdhdhr {
position: absolute;
}
</style>

View file

@ -1,55 +1,67 @@
<template>
<div
ref="itemsEl" v-hotkey="keymap"
class="rrevdjwt"
:class="{ center: align === 'center', asDrawer }"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()"
>
<template v-for="(item, i) in items2">
<div v-if="item === null" class="divider"></div>
<span v-else-if="item.type === 'label'" class="label item">
<span>{{ item.text }}</span>
<div>
<div
ref="itemsEl" v-hotkey="keymap"
class="rrevdjwt _popup _shadow"
:class="{ center: align === 'center', asDrawer }"
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
@contextmenu.self="e => e.preventDefault()"
>
<template v-for="(item, i) in items2">
<div v-if="item === null" class="divider"></div>
<span v-else-if="item.type === 'label'" class="label item">
<span>{{ item.text }}</span>
</span>
<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</MkA>
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</a>
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
<span v-else-if="item.type === 'switch'" :tabindex="i" class="item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
</span>
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button item parent" :class="{ childShowing: childShowingItem === item }" @mouseenter="showChildren(item, $event)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<span>{{ item.text }}</span>
<span class="caret"><i class="fas fa-caret-right fa-fw"></i></span>
</button>
<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ $ts.none }}</span>
</span>
<span v-else-if="item.type === 'pending'" :tabindex="i" class="pending item">
<span><MkEllipsis/></span>
</span>
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button item" @click.passive="close()">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</MkA>
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button item" @click="close()">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</a>
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button item" :class="{ active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
<MkAvatar :user="item.user" class="avatar"/><MkUserName :user="item.user"/>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
<span v-else-if="item.type === 'switch'" :tabindex="i" class="item">
<FormSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</FormSwitch>
</span>
<button v-else :tabindex="i" class="_button item" :class="{ danger: item.danger, active: item.active }" :disabled="item.active" @click="clicked(item.action, $event)">
<i v-if="item.icon" class="fa-fw" :class="item.icon"></i>
<MkAvatar v-if="item.avatar" :user="item.avatar" class="avatar"/>
<span>{{ item.text }}</span>
<span v-if="item.indicate" class="indicator"><i class="fas fa-circle"></i></span>
</button>
</template>
<span v-if="items2.length === 0" class="none item">
<span>{{ $ts.none }}</span>
</span>
</div>
<div v-if="childMenu" class="child">
<XChild ref="child" :items="childMenu" :target-element="childTarget" :root-element="itemsEl" showing @actioned="childActioned"/>
</div>
</div>
</template>
<script lang="ts" setup>
import { nextTick, onMounted, watch } from 'vue';
import { defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, onUnmounted, Ref, ref, watch } from 'vue';
import { focusPrev, focusNext } from '@/scripts/focus';
import FormSwitch from '@/components/form/switch.vue';
import { MenuItem, InnerMenuItem, MenuPending, MenuAction } from '@/types/menu';
import * as os from '@/os';
const XChild = defineAsyncComponent(() => import('./menu.child.vue'));
const props = defineProps<{
items: MenuItem[];
@ -61,19 +73,23 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
(ev: 'close'): void;
(ev: 'close', actioned?: boolean): void;
}>();
let itemsEl = $ref<HTMLDivElement>();
let items2: InnerMenuItem[] = $ref([]);
let child = $ref<InstanceType<typeof XChild>>();
let keymap = $computed(() => ({
'up|k|shift+tab': focusUp,
'down|j|tab': focusDown,
'esc': close,
}));
let childShowingItem = $ref<MenuItem | null>();
watch(() => props.items, () => {
const items: (MenuItem | MenuPending)[] = [...props.items].filter(item => item !== undefined);
@ -93,21 +109,53 @@ watch(() => props.items, () => {
immediate: true,
});
onMounted(() => {
if (props.viaKeyboard) {
nextTick(() => {
focusNext(itemsEl.children[0], true, false);
});
let childMenu = $ref<MenuItem[] | null>();
let childTarget = $ref<HTMLElement | null>();
function closeChild() {
childMenu = null;
childShowingItem = null;
}
function childActioned() {
closeChild();
close(true);
}
function onGlobalMousedown(event: MouseEvent) {
if (childTarget && (event.target === childTarget || childTarget.contains(event.target))) return;
if (child && child.checkHit(event)) return;
closeChild();
}
let childCloseTimer: null | number = null;
function onItemMouseEnter(item) {
childCloseTimer = window.setTimeout(() => {
closeChild();
}, 300);
}
function onItemMouseLeave(item) {
if (childCloseTimer) window.clearTimeout(childCloseTimer);
}
async function showChildren(item: MenuItem, ev: MouseEvent) {
if (props.asDrawer) {
os.popupMenu(item.children, ev.currentTarget ?? ev.target);
close();
} else {
childTarget = ev.currentTarget ?? ev.target;
childMenu = item.children;
childShowingItem = item;
}
});
}
function clicked(fn: MenuAction, ev: MouseEvent) {
fn(ev);
close();
close(true);
}
function close() {
emit('close');
function close(actioned = false) {
emit('close', actioned);
}
function focusUp() {
@ -117,6 +165,20 @@ function focusUp() {
function focusDown() {
focusNext(document.activeElement);
}
onMounted(() => {
if (props.viaKeyboard) {
nextTick(() => {
focusNext(itemsEl.children[0], true, false);
});
}
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
});
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onGlobalMousedown);
});
</script>
<style lang="scss" scoped>
@ -136,11 +198,11 @@ function focusDown() {
> .item {
display: block;
position: relative;
padding: 6px 18px;
padding: 6px 16px;
width: 100%;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.85em;
font-size: 0.9em;
line-height: 20px;
text-align: left;
overflow: hidden;
@ -225,6 +287,25 @@ function focusDown() {
opacity: 0.7;
}
&.parent {
display: flex;
align-items: center;
cursor: default;
> .caret {
margin-left: auto;
}
&.childShowing {
color: var(--accent);
text-decoration: none;
&:before {
background: var(--accentedBg);
}
}
}
> i {
margin-right: 5px;
width: 20px;

View file

@ -98,7 +98,7 @@ defineExpose({
}
> .header {
$height: 58px;
$height: 46px;
$height-narrow: 42px;
display: flex;
flex-shrink: 0;
@ -138,6 +138,7 @@ defineExpose({
}
> .body {
flex: 1;
overflow: auto;
background: var(--panel);
}

View file

@ -10,7 +10,7 @@
</template>
<script lang="ts" setup>
import { nextTick, onMounted, computed, ref, watch, provide } from 'vue';
import { nextTick, onMounted, watch, provide } from 'vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { defaultStore } from '@/store';
@ -57,13 +57,13 @@ const emit = defineEmits<{
provide('modal', true);
const maxHeight = ref<number>();
const fixed = ref(false);
const transformOrigin = ref('center');
const showing = ref(true);
const content = ref<HTMLElement>();
let maxHeight = $ref<number>();
let fixed = $ref(false);
let transformOrigin = $ref('center');
let showing = $ref(true);
let content = $ref<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
const type = computed(() => {
const type = $computed(() => {
if (props.preferType === 'auto') {
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
return 'drawer';
@ -80,7 +80,7 @@ let contentClicking = false;
const close = () => {
// eslint-disable-next-line vue/no-mutating-props
if (props.src) props.src.style.pointerEvents = 'auto';
showing.value = false;
showing = false;
emit('close');
};
@ -89,8 +89,8 @@ const onBgClick = () => {
emit('click');
};
if (type.value === 'drawer') {
maxHeight.value = window.innerHeight / 1.5;
if (type === 'drawer') {
maxHeight = window.innerHeight / 1.5;
}
const keymap = {
@ -101,22 +101,21 @@ const MARGIN = 16;
const align = () => {
if (props.src == null) return;
if (type.value === 'drawer') return;
if (type.value === 'dialog') return;
if (type === 'drawer') return;
if (type === 'dialog') return;
const popover = content.value!;
if (popover == null) return;
if (content == null) return;
const srcRect = props.src.getBoundingClientRect();
const width = popover.offsetWidth;
const height = popover.offsetHeight;
const width = content!.offsetWidth;
const height = content!.offsetHeight;
let left;
let top;
const x = srcRect.left + (fixed.value ? 0 : window.pageXOffset);
const y = srcRect.top + (fixed.value ? 0 : window.pageYOffset);
const x = srcRect.left + (fixed ? 0 : window.pageXOffset);
const y = srcRect.top + (fixed ? 0 : window.pageYOffset);
if (props.anchor.x === 'center') {
left = x + (props.src.offsetWidth / 2) - (width / 2);
@ -134,7 +133,7 @@ const align = () => {
top = y + props.src.offsetHeight;
}
if (fixed.value) {
if (fixed) {
//
if (left + width > window.innerWidth) {
left = window.innerWidth - width;
@ -147,16 +146,16 @@ const align = () => {
if (top + height > (window.innerHeight - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
maxHeight = underSpace;
} else {
maxHeight.value = upperSpace;
maxHeight = upperSpace;
top = (upperSpace + MARGIN) - height;
}
} else {
top = (window.innerHeight - MARGIN) - height;
}
} else {
maxHeight.value = underSpace;
maxHeight = underSpace;
}
} else {
//
@ -171,16 +170,16 @@ const align = () => {
if (top + height - window.pageYOffset > (window.innerHeight - MARGIN)) {
if (props.noOverlap && props.anchor.x === 'center') {
if (underSpace >= (upperSpace / 3)) {
maxHeight.value = underSpace;
maxHeight = underSpace;
} else {
maxHeight.value = upperSpace;
maxHeight = upperSpace;
top = window.pageYOffset + ((upperSpace + MARGIN) - height);
}
} else {
top = (window.innerHeight - MARGIN) - height + window.pageYOffset - 1;
}
} else {
maxHeight.value = underSpace;
maxHeight = underSpace;
}
}
@ -195,29 +194,29 @@ const align = () => {
let transformOriginX = 'center';
let transformOriginY = 'center';
if (top >= srcRect.top + props.src.offsetHeight + (fixed.value ? 0 : window.pageYOffset)) {
if (top >= srcRect.top + props.src.offsetHeight + (fixed ? 0 : window.pageYOffset)) {
transformOriginY = 'top';
} else if ((top + height) <= srcRect.top + (fixed.value ? 0 : window.pageYOffset)) {
} else if ((top + height) <= srcRect.top + (fixed ? 0 : window.pageYOffset)) {
transformOriginY = 'bottom';
}
if (left >= srcRect.left + props.src.offsetWidth + (fixed.value ? 0 : window.pageXOffset)) {
if (left >= srcRect.left + props.src.offsetWidth + (fixed ? 0 : window.pageXOffset)) {
transformOriginX = 'left';
} else if ((left + width) <= srcRect.left + (fixed.value ? 0 : window.pageXOffset)) {
} else if ((left + width) <= srcRect.left + (fixed ? 0 : window.pageXOffset)) {
transformOriginX = 'right';
}
transformOrigin.value = `${transformOriginX} ${transformOriginY}`;
transformOrigin = `${transformOriginX} ${transformOriginY}`;
popover.style.left = left + 'px';
popover.style.top = top + 'px';
content.style.left = left + 'px';
content.style.top = top + 'px';
};
const onOpened = () => {
emit('opened');
//
const el = content.value!.children[0];
const el = content!.children[0];
el.addEventListener('mousedown', ev => {
contentClicking = true;
window.addEventListener('mouseup', ev => {
@ -235,7 +234,7 @@ onMounted(() => {
// eslint-disable-next-line vue/no-mutating-props
props.src.style.pointerEvents = 'none';
}
fixed.value = (type.value === 'drawer') || (getFixedContainer(props.src) != null);
fixed = (type === 'drawer') || (getFixedContainer(props.src) != null);
await nextTick();
@ -243,10 +242,9 @@ onMounted(() => {
}, { immediate: true });
nextTick(() => {
const popover = content.value;
new ResizeObserver((entries, observer) => {
align();
}).observe(popover!);
}).observe(content!);
});
});
@ -389,7 +387,7 @@ defineExpose({
left: 0;
width: 100%;
height: 100%;
overflow: hidden; overflow: clip;
overflow: clip;
> .content {
position: fixed;

View file

@ -133,8 +133,10 @@ const fetchMore = async (): Promise<void> => {
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : props.pagination.reversed ? {
sinceId: items.value[0].id,
} : {
untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
untilId: items.value[items.value.length - 1].id,
}),
}).then(res => {
for (let i = 0; i < res.length; i++) {
@ -169,8 +171,10 @@ const fetchMoreAhead = async (): Promise<void> => {
limit: SECOND_FETCH_LIMIT + 1,
...(props.pagination.offsetMode ? {
offset: offset.value,
} : props.pagination.reversed ? {
untilId: items.value[0].id,
} : {
sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id,
sinceId: items.value[items.value.length - 1].id,
}),
}).then(res => {
if (res.length > SECOND_FETCH_LIMIT) {

View file

@ -1,6 +1,6 @@
<template>
<MkModal ref="modal" v-slot="{ type, maxHeight }" :z-priority="'high'" :src="src" :transparent-bg="true" @click="modal.close()" @closed="emit('closed')">
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq _popup _shadow" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :as-drawer="type === 'drawer'" class="sfhdhdhq" :class="{ drawer: type === 'drawer' }" @close="modal.close()"/>
</MkModal>
</template>

View file

@ -30,7 +30,7 @@ export default defineComponent({
props: {
def: {
type: Array,
required: true
required: true,
},
grid: {
type: Boolean,
@ -64,7 +64,7 @@ export default defineComponent({
box-sizing: border-box;
padding: 10px 16px 10px 8px;
border-radius: 9px;
font-size: 0.95em;
font-size: 0.9em;
&:hover {
text-decoration: none;

Some files were not shown because too many files have changed in this diff Show more