Merge tag '2025.5.0' into upstream/2025.5.0
This commit is contained in:
commit
bd90a5aeaa
130 changed files with 1584 additions and 825 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import globals from 'globals';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
import globals from 'globals';
|
||||
|
||||
|
|
@ -7,6 +8,13 @@ export default [
|
|||
{
|
||||
ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
|
|
|
|||
20
packages/backend/jest.js
Normal file
20
packages/backend/jest.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env node
|
||||
import child_process from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import url from 'node:url';
|
||||
|
||||
import semver from 'semver';
|
||||
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const args = [];
|
||||
args.push(...[
|
||||
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0') ? ['--no-experimental-require-module'] : [],
|
||||
'--experimental-vm-modules',
|
||||
'--experimental-import-meta-resolve',
|
||||
path.join(__dirname, 'node_modules/jest/bin/jest.js'),
|
||||
...process.argv.slice(2),
|
||||
]);
|
||||
|
||||
child_process.spawn(process.execPath, args, { stdio: 'inherit' });
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class DeliverSuspendedSoftware1743403874305 {
|
||||
name = 'DeliverSuspendedSoftware1743403874305'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" ADD "deliverSuspendedSoftware" jsonb NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deliverSuspendedSoftware"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,25 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js";
|
||||
|
||||
export class CompositeNoteIndex1745378064470 {
|
||||
name = 'CompositeNoteIndex1745378064470';
|
||||
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined;
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
|
||||
const concurrently = isConcurrentIndexMigrationEnabled();
|
||||
|
||||
if (concurrently) {
|
||||
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
|
||||
if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
|
||||
await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
|
||||
}
|
||||
} else {
|
||||
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
|
||||
}
|
||||
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`);
|
||||
// Flush all cached Linear Scan Plans and redo statistics for composite index
|
||||
// this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly
|
||||
|
|
@ -15,7 +29,8 @@ export class CompositeNoteIndex1745378064470 {
|
|||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : '';
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8
packages/backend/migration/js/migration-config.js
Normal file
8
packages/backend/migration/js/migration-config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function isConcurrentIndexMigrationEnabled() {
|
||||
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { DataSource } from 'typeorm';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { entities } from './built/postgres.js';
|
||||
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
|
|
@ -18,4 +19,5 @@ export default new DataSource({
|
|||
},
|
||||
entities: entities,
|
||||
migrations: ['migration/*.js'],
|
||||
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,12 +22,12 @@
|
|||
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
|
||||
"lint": "pnpm typecheck && pnpm eslint",
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
|
||||
"jest": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "cross-env NODE_ENV=test node ./jest.js --clearCache",
|
||||
"test": "pnpm jest",
|
||||
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
|
||||
"test:fed": "pnpm jest:fed",
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
"@fastify/multipart": "9.0.3",
|
||||
"@fastify/static": "8.1.1",
|
||||
"@fastify/view": "10.0.2",
|
||||
"@misskey-dev/sharp-read-bmp": "1.3.0",
|
||||
"@misskey-dev/sharp-read-bmp": "1.2.0",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@nestjs/common": "11.1.0",
|
||||
"@nestjs/core": "11.1.0",
|
||||
|
|
@ -172,7 +172,8 @@
|
|||
"rxjs": "7.8.2",
|
||||
"sanitize-html": "2.16.0",
|
||||
"secure-json-parse": "3.0.2",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.33.5",
|
||||
"semver": "7.7.1",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
|
|
|
|||
|
|
@ -24,8 +24,13 @@ const $config: Provider = {
|
|||
const $db: Provider = {
|
||||
provide: DI.db,
|
||||
useFactory: async (config) => {
|
||||
const db = createPostgresDataSource(config);
|
||||
return await db.initialize();
|
||||
try {
|
||||
const db = createPostgresDataSource(config);
|
||||
return await db.initialize();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const ACHIEVEMENT_TYPES = [
|
||||
'notes1',
|
||||
'notes10',
|
||||
'notes100',
|
||||
'notes500',
|
||||
'notes1000',
|
||||
'notes5000',
|
||||
'notes10000',
|
||||
'notes20000',
|
||||
'notes30000',
|
||||
'notes40000',
|
||||
'notes50000',
|
||||
'notes60000',
|
||||
'notes70000',
|
||||
'notes80000',
|
||||
'notes90000',
|
||||
'notes100000',
|
||||
'login3',
|
||||
'login7',
|
||||
'login15',
|
||||
'login30',
|
||||
'login60',
|
||||
'login100',
|
||||
'login200',
|
||||
'login300',
|
||||
'login400',
|
||||
'login500',
|
||||
'login600',
|
||||
'login700',
|
||||
'login800',
|
||||
'login900',
|
||||
'login1000',
|
||||
'passedSinceAccountCreated1',
|
||||
'passedSinceAccountCreated2',
|
||||
'passedSinceAccountCreated3',
|
||||
'loggedInOnBirthday',
|
||||
'loggedInOnNewYearsDay',
|
||||
'noteClipped1',
|
||||
'noteFavorited1',
|
||||
'myNoteFavorited1',
|
||||
'profileFilled',
|
||||
'markedAsCat',
|
||||
'following1',
|
||||
'following10',
|
||||
'following50',
|
||||
'following100',
|
||||
'following300',
|
||||
'followers1',
|
||||
'followers10',
|
||||
'followers50',
|
||||
'followers100',
|
||||
'followers300',
|
||||
'followers500',
|
||||
'followers1000',
|
||||
'collectAchievements30',
|
||||
'viewAchievements3min',
|
||||
'iLoveMisskey',
|
||||
'foundTreasure',
|
||||
'client30min',
|
||||
'client60min',
|
||||
'noteDeletedWithin1min',
|
||||
'postedAtLateNight',
|
||||
'postedAt0min0sec',
|
||||
'selfQuote',
|
||||
'htl20npm',
|
||||
'viewInstanceChart',
|
||||
'outputHelloWorldOnScratchpad',
|
||||
'open3windows',
|
||||
'driveFolderCircularReference',
|
||||
'reactWithoutRead',
|
||||
'clickedClickHere',
|
||||
'justPlainLucky',
|
||||
'setNameToSyuilo',
|
||||
'cookieClicked',
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
'bubbleGameExplodingHead',
|
||||
'bubbleGameDoubleExplodingHead',
|
||||
] as const;
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
|
||||
@Injectable()
|
||||
export class AchievementService {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type TimelineOptions = {
|
|||
excludeReplies?: boolean;
|
||||
excludeBots?: boolean;
|
||||
excludePureRenotes: boolean;
|
||||
ignoreAuthorFromUserSuspension?: boolean;
|
||||
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||
};
|
||||
|
||||
|
|
@ -145,6 +146,23 @@ export class FanoutTimelineEndpointService {
|
|||
};
|
||||
}
|
||||
|
||||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
const noteJoined = note as MiNote & {
|
||||
renoteUser: MiUser | null;
|
||||
replyUser: MiUser | null;
|
||||
};
|
||||
if (!ps.ignoreAuthorFromUserSuspension) {
|
||||
if (note.user!.isSuspended) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
}
|
||||
|
||||
const redisTimeline: MiNote[] = [];
|
||||
let readFromRedis = 0;
|
||||
let lastSuccessfulRate = 1; // rateをキャッシュする?
|
||||
|
|
|
|||
|
|
@ -43,29 +43,36 @@ export class QueryService {
|
|||
) {
|
||||
}
|
||||
|
||||
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
|
||||
public makePaginationQuery<T extends ObjectLiteral>(
|
||||
q: SelectQueryBuilder<T>,
|
||||
sinceId?: string | null,
|
||||
untilId?: string | null,
|
||||
sinceDate?: number | null,
|
||||
untilDate?: number | null,
|
||||
targetColumn = 'id',
|
||||
): SelectQueryBuilder<T> {
|
||||
if (sinceId && untilId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
|
||||
} else if (untilId) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
|
@ -286,4 +293,26 @@ export class QueryService {
|
|||
.andWhere(instanceSuspension('renoteUser'));
|
||||
}
|
||||
}
|
||||
|
||||
// Requirements: user replyUser renoteUser must be joined
|
||||
@bindThis
|
||||
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
|
||||
if (excludeAuthor) {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.orWhere(`user.id = ${user}.id`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere(brakets('replyUser'))
|
||||
.andWhere(brakets('renoteUser'));
|
||||
} else {
|
||||
const brakets = (user: string) => new Brackets(qb => qb
|
||||
.where(`note.${user}Id IS NULL`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(brakets('replyUser'))
|
||||
.andWhere(brakets('renoteUser'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ export class SearchService {
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
@ -368,11 +369,17 @@ export class SearchService {
|
|||
])
|
||||
: [new Set<string>(), new Set<string>()];
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('note');
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.innerJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import { URL, domainToASCII } from 'node:url';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import psl from 'psl';
|
||||
import semver from 'semver';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
||||
import { MiInstance } from '@/models/Instance.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
|
|
@ -186,4 +188,20 @@ export class UtilityService {
|
|||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
|
||||
if (software.softwareName == null) return undefined;
|
||||
if (software.softwareVersion == null) {
|
||||
// software version is null; suspend iff versionRange is *
|
||||
return this.meta.deliverSuspendedSoftware.find(x =>
|
||||
x.software === software.softwareName
|
||||
&& x.versionRange.trim() === '*');
|
||||
} else {
|
||||
const softwareVersion = software.softwareVersion;
|
||||
return this.meta.deliverSuspendedSoftware.find(x =>
|
||||
x.software === software.softwareName
|
||||
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export class InstanceEntityService {
|
|||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
): Promise<Packed<'FederationInstance'>> {
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
const softwareSuspended = this.utilityService.isDeliverSuspendedSoftware(instance);
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
|
|
@ -41,8 +42,8 @@ export class InstanceEntityService {
|
|||
followingCount: instance.followingCount,
|
||||
followersCount: instance.followersCount,
|
||||
isNotResponding: instance.isNotResponding,
|
||||
isSuspended: instance.suspensionState !== 'none',
|
||||
suspensionState: instance.suspensionState,
|
||||
isSuspended: instance.suspensionState !== 'none' || Boolean(softwareSuspended),
|
||||
suspensionState: instance.suspensionState === 'none' && softwareSuspended ? 'softwareSuspended' : instance.suspensionState,
|
||||
isBlocked: this.utilityService.isBlockedHost(this.meta.blockedHosts, instance.host),
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
|
|||
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
|
||||
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
|
||||
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
|
||||
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
|
@ -78,6 +79,8 @@ export const refs = {
|
|||
User: packedUserSchema,
|
||||
|
||||
UserList: packedUserListSchema,
|
||||
Achievement: packedAchievementSchema,
|
||||
AchievementName: packedAchievementNameSchema,
|
||||
Ad: packedAdSchema,
|
||||
Announcement: packedAnnouncementSchema,
|
||||
App: packedAppSchema,
|
||||
|
|
|
|||
|
|
@ -764,4 +764,14 @@ export class MiMeta {
|
|||
default: false,
|
||||
})
|
||||
public enableProxyAccount: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public deliverSuspendedSoftware: SoftwareSuspension[];
|
||||
}
|
||||
|
||||
export type SoftwareSuspension = {
|
||||
software: string,
|
||||
versionRange: string,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ import { MiUser } from './User.js';
|
|||
import { MiChannel } from './Channel.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
// Note: When you create a new index for existing column of this table,
|
||||
// it might be better to index concurrently under isConcurrentIndexMigrationEnabled flag
|
||||
// by editing generated migration file since this table is very large,
|
||||
// and it will make a long lock to create index in most cases.
|
||||
// Please note that `CREATE INDEX CONCURRENTLY` is not supported in transaction,
|
||||
// so you need to set `transaction = false` in migration if isConcurrentIndexMigrationEnabled() is true.
|
||||
// Please refer 1745378064470-composite-note-index.js for example.
|
||||
// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail
|
||||
// because it will always run CREATE INDEX in transaction based on decorators.
|
||||
// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
|
||||
@Index(['userId', 'id'])
|
||||
@Entity('note')
|
||||
export class MiNote {
|
||||
|
|
@ -247,7 +257,6 @@ export class MiNote {
|
|||
comment: '[Denormalized]',
|
||||
})
|
||||
public renoteUserHost: string | null;
|
||||
//#endregion
|
||||
|
||||
constructor(data: Partial<MiNote>) {
|
||||
if (data == null) return;
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ export class MiUserProfile {
|
|||
default: [],
|
||||
})
|
||||
public achievements: {
|
||||
name: string;
|
||||
name: typeof ACHIEVEMENT_TYPES[number];
|
||||
unlockedAt: number;
|
||||
}[];
|
||||
|
||||
|
|
@ -320,3 +320,84 @@ export class MiUserProfile {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ACHIEVEMENT_TYPES = [
|
||||
'notes1',
|
||||
'notes10',
|
||||
'notes100',
|
||||
'notes500',
|
||||
'notes1000',
|
||||
'notes5000',
|
||||
'notes10000',
|
||||
'notes20000',
|
||||
'notes30000',
|
||||
'notes40000',
|
||||
'notes50000',
|
||||
'notes60000',
|
||||
'notes70000',
|
||||
'notes80000',
|
||||
'notes90000',
|
||||
'notes100000',
|
||||
'login3',
|
||||
'login7',
|
||||
'login15',
|
||||
'login30',
|
||||
'login60',
|
||||
'login100',
|
||||
'login200',
|
||||
'login300',
|
||||
'login400',
|
||||
'login500',
|
||||
'login600',
|
||||
'login700',
|
||||
'login800',
|
||||
'login900',
|
||||
'login1000',
|
||||
'passedSinceAccountCreated1',
|
||||
'passedSinceAccountCreated2',
|
||||
'passedSinceAccountCreated3',
|
||||
'loggedInOnBirthday',
|
||||
'loggedInOnNewYearsDay',
|
||||
'noteClipped1',
|
||||
'noteFavorited1',
|
||||
'myNoteFavorited1',
|
||||
'profileFilled',
|
||||
'markedAsCat',
|
||||
'following1',
|
||||
'following10',
|
||||
'following50',
|
||||
'following100',
|
||||
'following300',
|
||||
'followers1',
|
||||
'followers10',
|
||||
'followers50',
|
||||
'followers100',
|
||||
'followers300',
|
||||
'followers500',
|
||||
'followers1000',
|
||||
'collectAchievements30',
|
||||
'viewAchievements3min',
|
||||
'iLoveMisskey',
|
||||
'foundTreasure',
|
||||
'client30min',
|
||||
'client60min',
|
||||
'noteDeletedWithin1min',
|
||||
'postedAtLateNight',
|
||||
'postedAt0min0sec',
|
||||
'selfQuote',
|
||||
'htl20npm',
|
||||
'viewInstanceChart',
|
||||
'outputHelloWorldOnScratchpad',
|
||||
'open3windows',
|
||||
'driveFolderCircularReference',
|
||||
'reactWithoutRead',
|
||||
'clickedClickHere',
|
||||
'justPlainLucky',
|
||||
'setNameToSyuilo',
|
||||
'cookieClicked',
|
||||
'brainDiver',
|
||||
'smashTestNotificationButton',
|
||||
'tutorialCompleted',
|
||||
'bubbleGameExplodingHead',
|
||||
'bubbleGameDoubleExplodingHead',
|
||||
] as const;
|
||||
|
|
|
|||
25
packages/backend/src/models/json-schema/achievement.ts
Normal file
25
packages/backend/src/models/json-schema/achievement.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
|
||||
export const packedAchievementNameSchema = {
|
||||
type: 'string',
|
||||
enum: ACHIEVEMENT_TYPES,
|
||||
optional: false,
|
||||
} as const;
|
||||
|
||||
export const packedAchievementSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
ref: 'AchievementName',
|
||||
},
|
||||
unlockedAt: {
|
||||
type: 'number',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -48,7 +48,7 @@ export const packedFederationInstanceSchema = {
|
|||
suspensionState: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
|
||||
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding', 'softwareSuspended'],
|
||||
},
|
||||
isBlocked: {
|
||||
type: 'boolean',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
|
||||
import { notificationTypes, userExportableEntities } from '@/types.js';
|
||||
|
||||
const baseSchema = {
|
||||
|
|
@ -312,9 +311,7 @@ export const packedNotificationSchema = {
|
|||
enum: ['achievementEarned'],
|
||||
},
|
||||
achievement: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ACHIEVEMENT_TYPES,
|
||||
ref: 'AchievementName',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -703,18 +703,7 @@ export const packedMeDetailedOnlySchema = {
|
|||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
unlockedAt: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
ref: 'Achievement',
|
||||
},
|
||||
},
|
||||
loggedInDays: {
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ export class DeliverProcessorService {
|
|||
return 'skip (suspended)';
|
||||
}
|
||||
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
// suspend server by software
|
||||
if (i != null && this.utilityService.isDeliverSuspendedSoftware(i)) {
|
||||
return 'skip (software suspended)';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
|
||||
|
||||
|
|
@ -79,10 +88,6 @@ export class DeliverProcessorService {
|
|||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
if (i.isNotResponding) {
|
||||
|
|
|
|||
|
|
@ -605,6 +605,24 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
deliverSuspendedSoftware: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
software: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
versionRange: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -767,6 +785,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null,
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
enableProxyAccount: instance.enableProxyAccount,
|
||||
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,6 +214,17 @@ export const paramDef = {
|
|||
type: 'boolean',
|
||||
nullable: false,
|
||||
},
|
||||
deliverSuspendedSoftware: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
software: { type: 'string' },
|
||||
versionRange: { type: 'string' },
|
||||
},
|
||||
required: ['software', 'versionRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
@ -754,6 +765,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.federation = ps.federation;
|
||||
}
|
||||
|
||||
if (ps.deliverSuspendedSoftware !== undefined) {
|
||||
set.deliverSuspendedSoftware = ps.deliverSuspendedSoftware;
|
||||
}
|
||||
|
||||
if (Array.isArray(ps.federationHosts)) {
|
||||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId)
|
||||
const query = this.queryService
|
||||
.makePaginationQuery(
|
||||
this.channelFollowingsRepository.createQueryBuilder(),
|
||||
ps.sinceId,
|
||||
ps.untilId,
|
||||
null,
|
||||
null,
|
||||
'followeeId',
|
||||
)
|
||||
.andWhere({ followerId: me.id });
|
||||
|
||||
const followings = await query
|
||||
|
|
|
|||
|
|
@ -138,6 +138,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -91,9 +91,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
// this.queryService.generateSuspendedUserQueryForNote(query); // To avoid problems with removing notes, ignoring suspended user for now
|
||||
if (me) {
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,11 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
|
||||
import { AchievementService } from '@/core/AchievementService.js';
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
|
||||
|
||||
export const meta = {
|
||||
requireCredential: true,
|
||||
prohibitMoved: true,
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,15 +14,7 @@ export const meta = {
|
|||
res: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
},
|
||||
unlockedAt: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
ref: 'Achievement',
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe, false)) return false;
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
useDbFallback: true,
|
||||
ignoreAuthorFromMute: true,
|
||||
ignoreAuthorFromInstanceBlock: true,
|
||||
ignoreAuthorFromUserSuspension: true,
|
||||
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
|
||||
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
|
||||
excludePureRenotes: !ps.withRenotes,
|
||||
|
|
@ -218,6 +219,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query, true);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query, true);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -105,10 +105,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
|
||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||
.andWhere('reaction.userId = :userId', { userId: ps.userId })
|
||||
.leftJoinAndSelect('reaction.note', 'note');
|
||||
.leftJoinAndSelect('reaction.note', 'note')
|
||||
.leftJoinAndSelect('note.user', 'user')
|
||||
.leftJoinAndSelect('note.reply', 'reply')
|
||||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const reactions = (await query
|
||||
.limit(ps.limit)
|
||||
|
|
|
|||
|
|
@ -10,15 +10,15 @@ cd packages/backend/test-federation
|
|||
First, you need to start servers by executing following commands:
|
||||
```sh
|
||||
bash ./setup.sh
|
||||
docker compose up --scale tester=0
|
||||
NODE_VERSION=22 docker compose up --scale tester=0
|
||||
```
|
||||
|
||||
Then you can run all tests by a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester
|
||||
NODE_VERSION=22 docker compose run --no-deps --rm tester
|
||||
```
|
||||
|
||||
For testing a specific file, run a following command:
|
||||
```sh
|
||||
docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
|
||||
NODE_VERSION=22 docker compose run --no-deps --rm tester -- pnpm -F backend test:fed packages/backend/test-federation/test/user.test.ts
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ services:
|
|||
retries: 20
|
||||
|
||||
misskey:
|
||||
image: node:20
|
||||
image: node:${NODE_VERSION}
|
||||
env_file:
|
||||
- ./.config/docker.env
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ services:
|
|||
"
|
||||
|
||||
tester:
|
||||
image: node:20
|
||||
image: node:${NODE_VERSION}
|
||||
depends_on:
|
||||
a.test:
|
||||
condition: service_healthy
|
||||
|
|
@ -50,6 +50,10 @@ services:
|
|||
source: ../jest.config.fed.cjs
|
||||
target: /misskey/packages/backend/jest.config.fed.cjs
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../jest.js
|
||||
target: /misskey/packages/backend/jest.js
|
||||
read_only: true
|
||||
- type: bind
|
||||
source: ../../misskey-js/built
|
||||
target: /misskey/packages/misskey-js/built
|
||||
|
|
@ -85,7 +89,7 @@ services:
|
|||
command: pnpm -F backend test:fed
|
||||
|
||||
daemon:
|
||||
image: node:20
|
||||
image: node:${NODE_VERSION}
|
||||
depends_on:
|
||||
redis.test:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -909,7 +909,7 @@ describe('クリップ', () => {
|
|||
assert.deepStrictEqual(res.map(x => x.id), [aliceNote.id]);
|
||||
});
|
||||
|
||||
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートはhideされて返ってくる)', async () => {
|
||||
test('はPublicなクリップなら認証なしでも取得できる。(非公開ノートは含まれない)', async () => {
|
||||
const publicClip = await create({ isPublic: true });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceNote.id });
|
||||
await addNote({ clipId: publicClip.id, noteId: aliceHomeNote.id });
|
||||
|
|
@ -919,8 +919,6 @@ describe('クリップ', () => {
|
|||
const res = await notes({ clipId: publicClip.id }, { user: undefined });
|
||||
const expects = [
|
||||
aliceNote, aliceHomeNote,
|
||||
// 認証なしだと非公開ノートは結果には含むけどhideされる。
|
||||
hiddenNote(aliceFollowersNote), hiddenNote(aliceSpecifiedNote),
|
||||
];
|
||||
assert.deepStrictEqual(
|
||||
res.sort(compareBy(s => s.id)).map(x => x.id),
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ describe('UserEntityService', () => {
|
|||
});
|
||||
|
||||
test('MeDetailed', async() => {
|
||||
const achievements = [{ name: 'achievement', unlockedAt: new Date().getTime() }];
|
||||
const achievements = [{ name: 'iLoveMisskey' as const, unlockedAt: new Date().getTime() }];
|
||||
const me = await createUser({}, {
|
||||
birthday: '2000-01-01',
|
||||
achievements: achievements,
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"vite": "6.3.3",
|
||||
"vite": "6.3.4",
|
||||
"vue": "3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div>
|
||||
<div class="_fullinfo">
|
||||
<img :src="notFoundImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFoundDescription }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -14,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { inject, computed } from 'vue';
|
||||
import { DEFAULT_NOT_FOUND_IMAGE_URL } from '@@/js/const.js';
|
||||
import { DI } from '@/di.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const serverMetadata = inject(DI.serverMetadata)!;
|
||||
|
||||
const notFoundImageUrl = computed(() => serverMetadata.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -286,13 +286,6 @@ rt {
|
|||
._fullinfo {
|
||||
padding: 64px 32px;
|
||||
text-align: center;
|
||||
|
||||
> img {
|
||||
vertical-align: bottom;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
._link {
|
||||
|
|
|
|||
|
|
@ -178,10 +178,6 @@ export const ROLE_POLICIES = [
|
|||
'chatAvailability',
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_SERVER_ERROR_IMAGE_URL = '/client-assets/status/error.png';
|
||||
export const DEFAULT_NOT_FOUND_IMAGE_URL = '/client-assets/status/missingpage.webp';
|
||||
export const DEFAULT_INFO_IMAGE_URL = '/client-assets/status/nothinghere.png';
|
||||
|
||||
export const MFM_TAGS = ['tada', 'jelly', 'twitch', 'shake', 'spin', 'jump', 'bounce', 'flip', 'x2', 'x3', 'x4', 'scale', 'position', 'fg', 'bg', 'border', 'font', 'blur', 'rainbow', 'sparkle', 'rotate', 'ruby', 'unixtime', 'crop', 'fade', 'followmouse'];
|
||||
export const MFM_PARAMS: Record<typeof MFM_TAGS[number], string[]> = {
|
||||
tada: ['speed=', 'delay='],
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@
|
|||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.3.3",
|
||||
"vite": "6.3.4",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next",
|
||||
"wanakana": "5.3.1"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick">
|
||||
<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="$emit('closed')" @click="onBgClick">
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<div :class="$style.header">
|
||||
<span :class="$style.icon">
|
||||
|
|
@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span :class="$style.title">{{ announcement.title }}</span>
|
||||
</div>
|
||||
<div :class="$style.text"><Mfm :text="announcement.text" :isBlock="true" /></div>
|
||||
<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton>
|
||||
<div ref="bottomEl"></div>
|
||||
<div :class="$style.footer">
|
||||
<MkButton
|
||||
primary
|
||||
full
|
||||
:disabled="!hasReachedBottom"
|
||||
@click="ok"
|
||||
>{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, useTemplateRef } from 'vue';
|
||||
import { onMounted, ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
|
@ -32,12 +40,12 @@ import { i18n } from '@/i18n.js';
|
|||
import { $i } from '@/i.js';
|
||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
announcement: Misskey.entities.Announcement;
|
||||
}>(), {
|
||||
});
|
||||
}>();
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const bottomEl = useTemplateRef('bottomEl');
|
||||
const modal = useTemplateRef('modal');
|
||||
|
||||
async function ok() {
|
||||
|
|
@ -72,7 +80,34 @@ function onBgClick() {
|
|||
});
|
||||
}
|
||||
|
||||
const hasReachedBottom = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (bottomEl.value && rootEl.value) {
|
||||
const bottomElRect = bottomEl.value.getBoundingClientRect();
|
||||
const rootElRect = rootEl.value.getBoundingClientRect();
|
||||
if (
|
||||
bottomElRect.top >= rootElRect.top &&
|
||||
bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分)
|
||||
) {
|
||||
hasReachedBottom.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
hasReachedBottom.value = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: rootEl.value,
|
||||
rootMargin: '0px 0px -75px 0px',
|
||||
});
|
||||
|
||||
observer.observe(bottomEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -80,9 +115,12 @@ onMounted(() => {
|
|||
.root {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
padding: 32px 32px 0;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: var(--MI-radius);
|
||||
|
|
@ -103,4 +141,14 @@ onMounted(() => {
|
|||
.text {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: -32px;
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
|
||||
margin: 0 -32px;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
|
||||
|
|
@ -23,7 +18,6 @@ import type { Paging } from '@/components/MkPagination.vue';
|
|||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
|
|
|
|||
|
|
@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<div v-if="!initializing && history.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noHistory }}</div>
|
||||
</div>
|
||||
<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
|
||||
<MkLoading v-if="initializing"/>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div
|
||||
v-else-if="!input && !select"
|
||||
:class="[$style.icon, {
|
||||
[$style.type_success]: type === 'success',
|
||||
[$style.type_error]: type === 'error',
|
||||
[$style.type_warning]: type === 'warning',
|
||||
[$style.type_info]: type === 'info',
|
||||
}]"
|
||||
:class="[$style.icon]"
|
||||
>
|
||||
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
|
||||
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
|
||||
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
|
||||
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
|
||||
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
|
||||
<MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
|
||||
<MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
|
||||
<MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
|
||||
<MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
|
||||
<MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
|
||||
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
|
||||
</div>
|
||||
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
|
||||
|
|
@ -203,22 +198,6 @@ function onInputKeydown(evt: KeyboardEvent) {
|
|||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.type_info {
|
||||
color: #55c4dd;
|
||||
}
|
||||
|
||||
.type_success {
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
|
||||
.type_error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
.type_warning {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
:enterFromClass="prefer.s.animation ? $style.transition_toggle_enterFrom : ''"
|
||||
:leaveToClass="prefer.s.animation ? $style.transition_toggle_leaveTo : ''"
|
||||
@enter="enter"
|
||||
@afterEnter="afterEnter"
|
||||
@leave="leave"
|
||||
@afterLeave="afterLeave"
|
||||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
|
|
@ -86,6 +90,42 @@ const bgSame = ref(false);
|
|||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
|
||||
//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す)
|
||||
function enter(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = `${Math.min(elementHeight, props.maxHeight ?? Infinity)}px`;
|
||||
}
|
||||
|
||||
function afterEnter(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
el.style.height = '';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
function toggle() {
|
||||
if (!opened.value) {
|
||||
openedAtLeastOnce.value = true;
|
||||
|
|
@ -108,17 +148,27 @@ onMounted(() => {
|
|||
.transition_toggle_enterActive,
|
||||
.transition_toggle_leaveActive {
|
||||
overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない
|
||||
transition: opacity 0.3s, height 0.3s !important;
|
||||
transition: opacity 0.3s, height 0.3s;
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
.transition_toggle_enterFrom,
|
||||
.transition_toggle_leaveTo {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.root {
|
||||
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
|
||||
}
|
||||
}
|
||||
|
||||
.transition_toggle_enterFrom,
|
||||
.transition_toggle_leaveTo {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: block;
|
||||
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -62,10 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
<MkResult v-else type="empty"/>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
|
@ -83,7 +80,6 @@ import XFile from './MkFormDialog.file.vue';
|
|||
import type { Form } from '@/utility/form.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { instanceName as localInstanceName } from '@@/js/config.js';
|
||||
import type { CSSProperties } from 'vue';
|
||||
import { instance as localInstance } from '@/instance.js';
|
||||
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
|
||||
|
||||
|
|
@ -61,19 +61,9 @@ $height: 2ex;
|
|||
border-radius: var(--MI-radius-xs) 0 0 var(--MI-radius-xs);
|
||||
overflow: clip;
|
||||
color: #fff;
|
||||
text-shadow: /* .866 ≈ sin(60deg) */
|
||||
1px 0 1px #000,
|
||||
.866px .5px 1px #000,
|
||||
.5px .866px 1px #000,
|
||||
0 1px 1px #000,
|
||||
-.5px .866px 1px #000,
|
||||
-.866px .5px 1px #000,
|
||||
-1px 0 1px #000,
|
||||
-.866px -.5px 1px #000,
|
||||
-.5px -.866px 1px #000,
|
||||
0 -1px 1px #000,
|
||||
.5px -.866px 1px #000,
|
||||
.866px -.5px 1px #000;
|
||||
|
||||
// text-shadowは重いから使うな
|
||||
|
||||
mask-image: linear-gradient(90deg,
|
||||
rgb(0,0,0),
|
||||
rgb(0,0,0) calc(100% - 16px),
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination" :disableAutoLoad="disableAutoLoad">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noNotes }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
|
||||
|
||||
<template #default="{ items: notes }">
|
||||
<div :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]">
|
||||
|
|
@ -35,7 +30,6 @@ import type { Paging } from '@/components/MkPagination.vue';
|
|||
import DynamicNote from '@/components/DynamicNote.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: Paging;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroupHeart]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
||||
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
|
||||
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<div
|
||||
|
|
@ -206,7 +205,6 @@ import { userPage } from '@/filters/user.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkPullToRefresh :refresher="() => reload()">
|
||||
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reload()">
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noNotifications }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noNotifications"/></template>
|
||||
|
||||
<template #default="{ items: notifications }">
|
||||
<component
|
||||
|
|
@ -30,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</component>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</MkPullToRefresh>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
@ -42,7 +37,6 @@ import XNotification from '@/components/MkNotification.vue';
|
|||
import DynamicNote from '@/components/DynamicNote.vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
|
|
@ -103,18 +97,38 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_x_move,
|
||||
.transition_x_enterActive,
|
||||
.transition_x_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
|
||||
.transition_x_move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.transition_x_enterFrom,
|
||||
|
||||
.transition_x_enterActive {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
&.item,
|
||||
.item {
|
||||
/* Skip Note Rendering有効時、TransitionGroupで通知を追加するときに一瞬がくっとなる問題を抑制する */
|
||||
content-visibility: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
.transition_x_leaveActive {
|
||||
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
|
||||
}
|
||||
|
||||
.transition_x_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateY(max(-64px, -100%));
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
.transition_x_enterFrom {
|
||||
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.transition_x_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.transition_x_leaveActive {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.notifications {
|
||||
|
|
|
|||
|
|
@ -16,12 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkError v-else-if="error" @retry="init()"/>
|
||||
|
||||
<div v-else-if="empty" key="_empty_">
|
||||
<slot name="empty">
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</slot>
|
||||
<slot name="empty"><MkResult type="empty"/></slot>
|
||||
</div>
|
||||
|
||||
<div v-else ref="rootEl" class="_gaps">
|
||||
|
|
@ -88,7 +83,6 @@ function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): M
|
|||
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div ref="rootEl">
|
||||
<div v-if="isPullStart" :class="$style.frame" :style="`--frame-min-height: ${pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR))}px;`">
|
||||
<div ref="rootEl" :class="isPulling ? $style.isPulling : null">
|
||||
<!-- 小数が含まれるとレンダリングが高頻度になりすぎパフォーマンスが悪化するためround -->
|
||||
<div v-if="isPulling" :class="$style.frame" :style="`--frame-min-height: ${Math.round(pullDistance / (PULL_BRAKE_BASE + (pullDistance / PULL_BRAKE_FACTOR)))}px;`">
|
||||
<div :class="$style.frameContent">
|
||||
<MkLoading v-if="isRefreshing" :class="$style.loader" :em="true"/>
|
||||
<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPullEnd }]"></i>
|
||||
<i v-else class="ti ti-arrow-bar-to-down" :class="[$style.icon, { [$style.refresh]: isPulledEnough }]"></i>
|
||||
<div :class="$style.text">
|
||||
<template v-if="isPullEnd">{{ i18n.ts.releaseToRefresh }}</template>
|
||||
<template v-if="isPulledEnough">{{ i18n.ts.releaseToRefresh }}</template>
|
||||
<template v-else-if="isRefreshing">{{ i18n.ts.refreshing }}</template>
|
||||
<template v-else>{{ i18n.ts.pullDownToRefresh }}</template>
|
||||
</div>
|
||||
|
|
@ -29,24 +30,21 @@ import { isHorizontalSwipeSwiping } from '@/utility/touch.js';
|
|||
|
||||
const SCROLL_STOP = 10;
|
||||
const MAX_PULL_DISTANCE = Infinity;
|
||||
const FIRE_THRESHOLD = 230;
|
||||
const FIRE_THRESHOLD = 200;
|
||||
const RELEASE_TRANSITION_DURATION = 200;
|
||||
const PULL_BRAKE_BASE = 1.5;
|
||||
const PULL_BRAKE_FACTOR = 170;
|
||||
|
||||
const isPullStart = ref(false);
|
||||
const isPullEnd = ref(false);
|
||||
const isPulling = ref(false);
|
||||
const isPulledEnough = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const pullDistance = ref(0);
|
||||
|
||||
let supportPointerDesktop = false;
|
||||
let startScreenY: number | null = null;
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
let scrollEl: HTMLElement | null = null;
|
||||
|
||||
let disabled = false;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
refresher: () => Promise<void>;
|
||||
}>(), {
|
||||
|
|
@ -57,19 +55,72 @@ const emit = defineEmits<{
|
|||
(ev: 'refresh'): void;
|
||||
}>();
|
||||
|
||||
function getScreenY(event) {
|
||||
if (supportPointerDesktop) {
|
||||
function getScreenY(event: TouchEvent | MouseEvent | PointerEvent): number {
|
||||
if (event.touches && event.touches[0] && event.touches[0].screenY != null) {
|
||||
return event.touches[0].screenY;
|
||||
} else {
|
||||
return event.screenY;
|
||||
}
|
||||
return event.touches[0].screenY;
|
||||
}
|
||||
|
||||
function moveStart(event) {
|
||||
if (!isPullStart.value && !isRefreshing.value && !disabled) {
|
||||
isPullStart.value = true;
|
||||
startScreenY = getScreenY(event);
|
||||
pullDistance.value = 0;
|
||||
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
|
||||
function lockDownScroll() {
|
||||
if (scrollEl == null) return;
|
||||
scrollEl.style.touchAction = 'pan-x pan-down pinch-zoom';
|
||||
scrollEl.style.overscrollBehavior = 'none';
|
||||
}
|
||||
|
||||
function unlockDownScroll() {
|
||||
if (scrollEl == null) return;
|
||||
scrollEl.style.touchAction = 'auto';
|
||||
scrollEl.style.overscrollBehavior = 'contain';
|
||||
}
|
||||
|
||||
function moveStartByMouse(event: MouseEvent) {
|
||||
if (event.button !== 1) return;
|
||||
if (isRefreshing.value) return;
|
||||
|
||||
const scrollPos = scrollEl!.scrollTop;
|
||||
if (scrollPos !== 0) {
|
||||
unlockDownScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
lockDownScroll();
|
||||
|
||||
event.preventDefault(); // 中クリックによるスクロール、テキスト選択などを防ぐ
|
||||
|
||||
isPulling.value = true;
|
||||
startScreenY = getScreenY(event);
|
||||
pullDistance.value = 0;
|
||||
|
||||
window.addEventListener('mousemove', moving, { passive: true });
|
||||
window.addEventListener('mouseup', () => {
|
||||
window.removeEventListener('mousemove', moving);
|
||||
onPullRelease();
|
||||
}, { passive: true, once: true });
|
||||
}
|
||||
|
||||
function moveStartByTouch(event: TouchEvent) {
|
||||
if (isRefreshing.value) return;
|
||||
|
||||
const scrollPos = scrollEl!.scrollTop;
|
||||
if (scrollPos !== 0) {
|
||||
unlockDownScroll();
|
||||
return;
|
||||
}
|
||||
|
||||
lockDownScroll();
|
||||
|
||||
isPulling.value = true;
|
||||
startScreenY = getScreenY(event);
|
||||
pullDistance.value = 0;
|
||||
|
||||
window.addEventListener('touchmove', moving, { passive: true });
|
||||
window.addEventListener('touchend', () => {
|
||||
window.removeEventListener('touchmove', moving);
|
||||
onPullRelease();
|
||||
}, { passive: true, once: true });
|
||||
}
|
||||
|
||||
function moveBySystem(to: number): Promise<void> {
|
||||
|
|
@ -108,31 +159,36 @@ async function closeContent() {
|
|||
}
|
||||
}
|
||||
|
||||
function moveEnd() {
|
||||
if (isPullStart.value && !isRefreshing.value) {
|
||||
startScreenY = null;
|
||||
if (isPullEnd.value) {
|
||||
isPullEnd.value = false;
|
||||
isRefreshing.value = true;
|
||||
fixOverContent().then(() => {
|
||||
emit('refresh');
|
||||
props.refresher().then(() => {
|
||||
refreshFinished();
|
||||
});
|
||||
function onPullRelease() {
|
||||
startScreenY = null;
|
||||
if (isPulledEnough.value) {
|
||||
isPulledEnough.value = false;
|
||||
isRefreshing.value = true;
|
||||
fixOverContent().then(() => {
|
||||
emit('refresh');
|
||||
props.refresher().then(() => {
|
||||
refreshFinished();
|
||||
});
|
||||
} else {
|
||||
closeContent().then(() => isPullStart.value = false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
closeContent().then(() => isPulling.value = false);
|
||||
}
|
||||
}
|
||||
|
||||
function moving(event: TouchEvent | PointerEvent) {
|
||||
if (!isPullStart.value || isRefreshing.value || disabled) return;
|
||||
function toggleScrollLockOnTouchEnd() {
|
||||
const scrollPos = scrollEl!.scrollTop;
|
||||
if (scrollPos === 0) {
|
||||
lockDownScroll();
|
||||
} else {
|
||||
unlockDownScroll();
|
||||
}
|
||||
}
|
||||
|
||||
if ((scrollEl?.scrollTop ?? 0) > (supportPointerDesktop ? SCROLL_STOP : SCROLL_STOP + pullDistance.value) || isHorizontalSwipeSwiping.value) {
|
||||
function moving(event: MouseEvent | TouchEvent) {
|
||||
if ((scrollEl?.scrollTop ?? 0) > SCROLL_STOP + pullDistance.value || isHorizontalSwipeSwiping.value) {
|
||||
pullDistance.value = 0;
|
||||
isPullEnd.value = false;
|
||||
moveEnd();
|
||||
isPulledEnough.value = false;
|
||||
onPullRelease();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,15 +200,7 @@ function moving(event: TouchEvent | PointerEvent) {
|
|||
const moveHeight = moveScreenY - startScreenY!;
|
||||
pullDistance.value = Math.min(Math.max(moveHeight, 0), MAX_PULL_DISTANCE);
|
||||
|
||||
if (pullDistance.value > 0) {
|
||||
if (event.cancelable) event.preventDefault();
|
||||
}
|
||||
|
||||
if (pullDistance.value > SCROLL_STOP) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
isPullEnd.value = pullDistance.value >= FIRE_THRESHOLD;
|
||||
isPulledEnough.value = pullDistance.value >= FIRE_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -162,65 +210,33 @@ function moving(event: TouchEvent | PointerEvent) {
|
|||
*/
|
||||
function refreshFinished() {
|
||||
closeContent().then(() => {
|
||||
isPullStart.value = false;
|
||||
isPulling.value = false;
|
||||
isRefreshing.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function setDisabled(value) {
|
||||
disabled = value;
|
||||
}
|
||||
|
||||
function onScrollContainerScroll() {
|
||||
const scrollPos = scrollEl!.scrollTop;
|
||||
|
||||
// When at the top of the page, disable vertical overscroll so passive touch listeners can take over.
|
||||
if (scrollPos === 0) {
|
||||
scrollEl!.style.touchAction = 'pan-x pan-down pinch-zoom';
|
||||
registerEventListenersForReadyToPull();
|
||||
} else {
|
||||
scrollEl!.style.touchAction = 'auto';
|
||||
unregisterEventListenersForReadyToPull();
|
||||
}
|
||||
}
|
||||
|
||||
function registerEventListenersForReadyToPull() {
|
||||
if (rootEl.value == null) return;
|
||||
rootEl.value.addEventListener('touchstart', moveStart, { passive: true });
|
||||
rootEl.value.addEventListener('touchmove', moving, { passive: false }); // passive: falseにしないとpreventDefaultが使えない
|
||||
}
|
||||
|
||||
function unregisterEventListenersForReadyToPull() {
|
||||
if (rootEl.value == null) return;
|
||||
rootEl.value.removeEventListener('touchstart', moveStart);
|
||||
rootEl.value.removeEventListener('touchmove', moving);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (rootEl.value == null) return;
|
||||
|
||||
scrollEl = getScrollContainer(rootEl.value);
|
||||
if (scrollEl == null) return;
|
||||
|
||||
scrollEl.addEventListener('scroll', onScrollContainerScroll, { passive: true });
|
||||
|
||||
rootEl.value.addEventListener('touchend', moveEnd, { passive: true });
|
||||
|
||||
registerEventListenersForReadyToPull();
|
||||
lockDownScroll();
|
||||
rootEl.value.addEventListener('mousedown', moveStartByMouse, { passive: false }); // preventDefaultするため
|
||||
rootEl.value.addEventListener('touchstart', moveStartByTouch, { passive: true });
|
||||
rootEl.value.addEventListener('touchend', toggleScrollLockOnTouchEnd, { passive: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollEl) scrollEl.removeEventListener('scroll', onScrollContainerScroll);
|
||||
|
||||
unregisterEventListenersForReadyToPull();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
setDisabled,
|
||||
unlockDownScroll();
|
||||
if (rootEl.value) rootEl.value.removeEventListener('mousedown', moveStartByMouse);
|
||||
if (rootEl.value) rootEl.value.removeEventListener('touchstart', moveStartByTouch);
|
||||
if (rootEl.value) rootEl.value.removeEventListener('touchend', toggleScrollLockOnTouchEnd);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.isPulling {
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
|
|
@ -242,7 +258,6 @@ defineExpose({
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
|
||||
> .icon, > .loader {
|
||||
margin: 6px 0;
|
||||
|
|
@ -258,6 +273,7 @@ defineExpose({
|
|||
|
||||
> .text {
|
||||
margin: 5px 0;
|
||||
font-size: 90%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -54,12 +54,12 @@ const MIN_SWIPE_DISTANCE = 20;
|
|||
// スワイプ時の動作を発火する最小の距離
|
||||
const SWIPE_DISTANCE_THRESHOLD = 70;
|
||||
|
||||
// スワイプを中断するY方向の移動距離
|
||||
const SWIPE_ABORT_Y_THRESHOLD = 75;
|
||||
|
||||
// スワイプできる最大の距離
|
||||
const MAX_SWIPE_DISTANCE = 120;
|
||||
|
||||
// スワイプ方向を判定する角度の許容範囲(度数)
|
||||
const SWIPE_DIRECTION_ANGLE_THRESHOLD = 50;
|
||||
|
||||
// ▲ しきい値 ▲ //
|
||||
|
||||
let startScreenX: number | null = null;
|
||||
|
|
@ -71,6 +71,7 @@ const pullDistance = ref(0);
|
|||
const isSwipingForClass = ref(false);
|
||||
let swipeAborted = false;
|
||||
const isUserHome = props.page === 'user' && tabModel.value === 'home';
|
||||
let swipeDirectionLocked: 'horizontal' | 'vertical' | null = null;
|
||||
|
||||
function touchStart(event: TouchEvent) {
|
||||
if (!prefer.r.enableHorizontalSwipe.value) return;
|
||||
|
|
@ -81,6 +82,7 @@ function touchStart(event: TouchEvent) {
|
|||
|
||||
startScreenX = event.touches[0].screenX;
|
||||
startScreenY = event.touches[0].screenY;
|
||||
swipeDirectionLocked = null; // スワイプ方向をリセット
|
||||
}
|
||||
|
||||
function touchMove(event: TouchEvent) {
|
||||
|
|
@ -97,15 +99,24 @@ function touchMove(event: TouchEvent) {
|
|||
let distanceX = event.touches[0].screenX - startScreenX;
|
||||
let distanceY = event.touches[0].screenY - startScreenY;
|
||||
|
||||
if (Math.abs(distanceY) > SWIPE_ABORT_Y_THRESHOLD) {
|
||||
swipeAborted = true;
|
||||
// スワイプ方向をロック
|
||||
if (!swipeDirectionLocked) {
|
||||
const angle = Math.abs(Math.atan2(distanceY, distanceX) * (180 / Math.PI));
|
||||
if (angle > 90 - SWIPE_DIRECTION_ANGLE_THRESHOLD && angle < 90 + SWIPE_DIRECTION_ANGLE_THRESHOLD) {
|
||||
swipeDirectionLocked = 'vertical';
|
||||
} else {
|
||||
swipeDirectionLocked = 'horizontal';
|
||||
}
|
||||
}
|
||||
|
||||
// 縦方向のスワイプの場合は中断
|
||||
if (swipeDirectionLocked === 'vertical') {
|
||||
swipeAborted = true;
|
||||
pullDistance.value = 0;
|
||||
isSwiping.value = false;
|
||||
window.setTimeout(() => {
|
||||
isSwipingForClass.value = false;
|
||||
}, 400);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -166,6 +177,8 @@ function touchEnd(event: TouchEvent) {
|
|||
window.setTimeout(() => {
|
||||
isSwipingForClass.value = false;
|
||||
}, 400);
|
||||
|
||||
swipeDirectionLocked = null; // スワイプ方向をリセット
|
||||
}
|
||||
|
||||
/** 横スワイプに関与する可能性のある要素を調べる */
|
||||
|
|
@ -192,7 +205,7 @@ watch(tabModel, (newTab, oldTab) => {
|
|||
const newIndex = props.tabs.findIndex(tab => tab.key === newTab);
|
||||
const oldIndex = props.tabs.findIndex(tab => tab.key === oldTab);
|
||||
|
||||
if (oldIndex >= 0 && newIndex && oldIndex < newIndex) {
|
||||
if (oldIndex >= 0 && newIndex >= 0 && oldIndex < newIndex) {
|
||||
transitionName.value = 'swipeAnimationLeft';
|
||||
} else {
|
||||
transitionName.value = 'swipeAnimationRight';
|
||||
|
|
|
|||
|
|
@ -4,14 +4,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkPullToRefresh ref="prComponent" :refresher="() => reloadTimeline()">
|
||||
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)" @status="prComponent?.setDisabled($event)">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noNotes }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<component :is="prefer.s.enablePullToRefresh ? MkPullToRefresh : 'div'" :refresher="() => reloadTimeline()">
|
||||
<MkPagination v-if="paginationQuery" ref="pagingComponent" :pagination="paginationQuery" @queue="emit('queue', $event)">
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
|
||||
|
||||
<template #default="{ items: notes }">
|
||||
<component
|
||||
|
|
@ -21,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveActiveClass="$style.transition_x_leaveActive"
|
||||
:enterFromClass="$style.transition_x_enterFrom"
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
:moveClass=" $style.transition_x_move"
|
||||
:moveClass="$style.transition_x_move"
|
||||
tag="div"
|
||||
>
|
||||
<template v-for="(note, i) in notes" :key="note.id">
|
||||
|
|
@ -36,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</component>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</MkPullToRefresh>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
|
@ -53,7 +48,6 @@ import { prefer } from '@/preferences.js';
|
|||
import DynamicNote from '@/components/DynamicNote.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src: BasicTimelineType | 'mentions' | 'directs' | 'list' | 'antenna' | 'channel' | 'role';
|
||||
|
|
@ -96,7 +90,6 @@ type TimelineQueryType = {
|
|||
roleId?: string
|
||||
};
|
||||
|
||||
const prComponent = useTemplateRef('prComponent');
|
||||
const pagingComponent = useTemplateRef('pagingComponent');
|
||||
|
||||
let tlNotesCount = 0;
|
||||
|
|
@ -333,18 +326,38 @@ defineExpose({
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_x_move,
|
||||
.transition_x_enterActive,
|
||||
.transition_x_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,.5,.5,1), transform 0.3s cubic-bezier(0,.5,.5,1) !important;
|
||||
.transition_x_move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
.transition_x_enterFrom,
|
||||
|
||||
.transition_x_enterActive {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
|
||||
&.note,
|
||||
.note {
|
||||
/* Skip Note Rendering有効時、TransitionGroupでnoteを追加するときに一瞬がくっとなる問題を抑制する */
|
||||
content-visibility: visible !important;
|
||||
}
|
||||
}
|
||||
|
||||
.transition_x_leaveActive {
|
||||
transition: height 0.2s cubic-bezier(0,.5,.5,1), opacity 0.2s cubic-bezier(0,.5,.5,1);
|
||||
}
|
||||
|
||||
.transition_x_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateY(max(-64px, -100%));
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
.transition_x_leaveTo {
|
||||
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.transition_x_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.transition_x_leaveActive {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.reverse {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<MkPagination :pagination="pagination" :displayLimit="50">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.root">
|
||||
|
|
@ -25,7 +20,6 @@ import type { Paging } from '@/components/MkPagination.vue';
|
|||
import MkUserInfo from '@/components/MkUserInfo.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
appear @afterLeave="emit('closed')"
|
||||
>
|
||||
<div v-if="showing" :class="$style.root" class="_popup _shadow" :style="{ zIndex, top: top + 'px', left: left + 'px' }" @mouseover="() => { emit('mouseover'); }" @mouseleave="() => { emit('mouseleave'); }">
|
||||
<div v-if="user != null">
|
||||
<MkError v-if="error" @retry="fetchUser()"/>
|
||||
<div v-else-if="user != null">
|
||||
<div :class="$style.banner" :style="user.bannerUrl ? { backgroundImage: `url(${prefer.s.disableShowingAnimatedImages ? getStaticImageUrl(user.bannerUrl) : user.bannerUrl})` } : ''">
|
||||
<span v-if="$i && $i.id != user.id && user.isFollowed && user.isFollowing" :class="$style.followed">{{ i18n.ts.mutuals }}</span>
|
||||
<span v-else-if="$i && $i.id != user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span>
|
||||
|
|
@ -99,6 +100,7 @@ const zIndex = os.claimZIndex('middle');
|
|||
const user = ref<Misskey.entities.UserDetailed | null>(null);
|
||||
const top = ref(0);
|
||||
const left = ref(0);
|
||||
const error = ref(false);
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
if (user.value == null) return;
|
||||
|
|
@ -106,19 +108,27 @@ function showMenu(ev: MouseEvent) {
|
|||
os.popupMenu(menu, ev.currentTarget ?? ev.target).finally(cleanup);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
async function fetchUser() {
|
||||
if (typeof props.q === 'object') {
|
||||
user.value = props.q;
|
||||
error.value = false;
|
||||
} else {
|
||||
const query = props.q.startsWith('@') ?
|
||||
const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ?
|
||||
Misskey.acct.parse(props.q.substring(1)) :
|
||||
{ userId: props.q };
|
||||
|
||||
misskeyApi('users/show', query).then(res => {
|
||||
if (!props.showing) return;
|
||||
user.value = res;
|
||||
error.value = false;
|
||||
}, () => {
|
||||
error.value = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchUser();
|
||||
|
||||
const rect = props.source.getBoundingClientRect();
|
||||
const x = Math.max(1, ((rect.left + (props.source.offsetWidth / 2)) - (300 / 2)) + window.scrollX);
|
||||
|
|
|
|||
|
|
@ -4,20 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
|
||||
<p :class="$style.text"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.somethingHappened }}</p>
|
||||
<MkButton :class="$style.button" @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton>
|
||||
</div>
|
||||
</Transition>
|
||||
<MkResult type="error">
|
||||
<MkButton :class="$style.button" rounded @click="() => emit('retry')">{{ i18n.ts.retry }}</MkButton>
|
||||
</MkResult>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { serverErrorImageUrl } from '@/instance.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'retry'): void;
|
||||
|
|
@ -25,25 +19,7 @@ const emit = defineEmits<{
|
|||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.img {
|
||||
vertical-align: bottom;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--MI-radius-md);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkResult from './MkResult.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkResult,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkResult v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
type: 'empty',
|
||||
text: 'Lorem Ipsum',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkResult>;
|
||||
export const emptyWithNoText = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
text: undefined,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkResult>;
|
||||
export const notFound = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
type: 'notFound',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkResult>;
|
||||
export const errorType = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
type: 'error',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkResult>;
|
||||
53
packages/frontend/src/components/global/MkResult.vue
Normal file
53
packages/frontend/src/components/global/MkResult.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
|
||||
<div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps">
|
||||
<img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
|
||||
<MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/>
|
||||
<img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>
|
||||
<MkSystemIcon v-else-if="type === 'notFound'" type="question" :class="$style.icon"/>
|
||||
<img v-if="type === 'error' && instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
|
||||
<MkSystemIcon v-else-if="type === 'error'" type="error" :class="$style.icon"/>
|
||||
|
||||
<div style="opacity: 0.7;">{{ props.text ?? (type === 'empty' ? i18n.ts.nothing : type === 'notFound' ? i18n.ts.notFound : type === 'error' ? i18n.ts.somethingHappened : null) }}</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from 'vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'empty' | 'notFound' | 'error';
|
||||
text?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.img {
|
||||
vertical-align: bottom;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 65px;
|
||||
height: 65px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
109
packages/frontend/src/components/global/MkSystemIcon.vue
Normal file
109
packages/frontend/src/components/global/MkSystemIcon.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<svg v-if="type === 'info'" :class="[$style.icon, $style.info]" viewBox="0 0 160 160">
|
||||
<path d="M80,108L80,72" style="--l:37;" :class="[$style.line, $style.anim]"/>
|
||||
<path d="M80,52L80,52" :class="[$style.line, $style.fade]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'question'" :class="[$style.icon, $style.question]" viewBox="0 0 160 160">
|
||||
<path d="M80,92L79.991,84C88.799,83.98 96,76.962 96,68C96,59.038 88.953,52 79.991,52C71.03,52 64,59.038 64,68" style="--l:85;" :class="[$style.line, $style.anim]"/>
|
||||
<path d="M80,108L80,108" :class="[$style.line, $style.fade]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'success'" :class="[$style.icon, $style.success]" viewBox="0 0 160 160">
|
||||
<path d="M62,80L74,92L98,68" style="--l:50;" :class="[$style.line, $style.anim]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'warn'" :class="[$style.icon, $style.warn]" viewBox="0 0 160 160">
|
||||
<path d="M80,64L80,88" style="--l:27;" :class="[$style.line, $style.anim]"/>
|
||||
<path d="M80,108L80,108" :class="[$style.line, $style.fade]"/>
|
||||
<path d="M92,28L144,116C148.709,124.65 144.083,135.82 136,136L24,136C15.917,135.82 11.291,124.65 16,116L68,28C73.498,19.945 86.771,19.945 92,28Z" style="--l:390;" :class="[$style.line, $style.anim]"/>
|
||||
</svg>
|
||||
<svg v-else-if="type === 'error'" :class="[$style.icon, $style.error]" viewBox="0 0 160 160">
|
||||
<path d="M63,63L96,96" style="--l:47;--duration:0.3s;" :class="[$style.line, $style.anim]"/>
|
||||
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.anim]"/>
|
||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.anim]"/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {} from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'info' | 'question' | 'success' | 'warn' | 'error';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.icon {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
|
||||
&.info {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
&.question {
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
|
||||
&.warn {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 8px;
|
||||
}
|
||||
|
||||
.fill {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.anim {
|
||||
stroke-dasharray: var(--l);
|
||||
stroke-dashoffset: var(--l);
|
||||
animation: line-animation var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
.fade {
|
||||
opacity: 0;
|
||||
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes line-animation {
|
||||
0% {
|
||||
stroke-dashoffset: var(--l);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
|
||||
<div :class="$style.body">
|
||||
<MkSwiper v-if="swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
|
||||
<MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs" :page="props.page">
|
||||
<slot></slot>
|
||||
</MkSwiper>
|
||||
<slot v-else></slot>
|
||||
|
|
@ -25,6 +25,7 @@ import type { PageHeaderProps } from './MkPageHeader.vue';
|
|||
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
|
||||
import MkSwiper from '@/components/MkSwiper.vue';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<PageHeaderProps & {
|
||||
reversed?: boolean;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import MkAd from './global/MkAd.vue';
|
|||
import MkPageHeader from './global/MkPageHeader.vue';
|
||||
import MkStickyContainer from './global/MkStickyContainer.vue';
|
||||
import MkLazy from './global/MkLazy.vue';
|
||||
import MkResult from './global/MkResult.vue';
|
||||
import MkSystemIcon from './global/MkSystemIcon.vue';
|
||||
import PageWithHeader from './global/PageWithHeader.vue';
|
||||
import PageWithAnimBg from './global/PageWithAnimBg.vue';
|
||||
import SearchMarker from './global/SearchMarker.vue';
|
||||
|
|
@ -61,6 +63,8 @@ export const components = {
|
|||
MkPageHeader: MkPageHeader,
|
||||
MkStickyContainer: MkStickyContainer,
|
||||
MkLazy: MkLazy,
|
||||
MkResult: MkResult,
|
||||
MkSystemIcon: MkSystemIcon,
|
||||
PageWithHeader: PageWithHeader,
|
||||
PageWithAnimBg: PageWithAnimBg,
|
||||
SearchMarker: SearchMarker,
|
||||
|
|
@ -92,6 +96,8 @@ declare module '@vue/runtime-core' {
|
|||
MkPageHeader: typeof MkPageHeader;
|
||||
MkStickyContainer: typeof MkStickyContainer;
|
||||
MkLazy: typeof MkLazy;
|
||||
MkResult: typeof MkResult;
|
||||
MkSystemIcon: typeof MkSystemIcon;
|
||||
PageWithHeader: typeof PageWithHeader;
|
||||
PageWithAnimBg: typeof PageWithAnimBg;
|
||||
SearchMarker: typeof SearchMarker;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { computed, reactive } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { DEFAULT_INFO_IMAGE_URL, DEFAULT_NOT_FOUND_IMAGE_URL, DEFAULT_SERVER_ERROR_IMAGE_URL } from '@@/js/const.js';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
|
|
@ -30,12 +29,6 @@ if (providedAt > cachedAt) {
|
|||
|
||||
export const instance: Misskey.entities.MetaDetailed = reactive(cachedMeta ?? {});
|
||||
|
||||
export const serverErrorImageUrl = computed(() => instance.serverErrorImageUrl ?? DEFAULT_SERVER_ERROR_IMAGE_URL);
|
||||
|
||||
export const infoImageUrl = computed(() => instance.infoImageUrl ?? DEFAULT_INFO_IMAGE_URL);
|
||||
|
||||
export const notFoundImageUrl = computed(() => instance.notFoundImageUrl ?? DEFAULT_NOT_FOUND_IMAGE_URL);
|
||||
|
||||
export const isEnabledUrlPreview = computed(() => instance.enableUrlPreview ?? true);
|
||||
|
||||
export async function fetchInstance(force = false): Promise<Misskey.entities.MetaDetailed> {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkLoading v-if="!loaded"/>
|
||||
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
|
||||
<div v-show="loaded" :class="$style.root">
|
||||
<img :src="serverErrorImageUrl" draggable="false" :class="$style.img"/>
|
||||
<img v-if="instance.serverErrorImageUrl" :src="instance.serverErrorImageUrl" draggable="false" :class="$style.img"/>
|
||||
<div class="_gaps">
|
||||
<div><b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.pageLoadError }}</b></div>
|
||||
<div v-if="meta && (version === meta.version)">{{ i18n.ts.pageLoadErrorDescription }}</div>
|
||||
|
|
@ -36,7 +36,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { serverErrorImageUrl } from '@/instance.js';
|
||||
import { instance } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
error?: Error;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkTl :events="timeline" :displayLimit="50" style="margin-top: var(--MI-margin);">
|
||||
<template #left="{ event }">
|
||||
<div>
|
||||
<MkAvatar :user="event.user" style="width: 24px; height: 24px;"/>
|
||||
<MkAvatar :user="event.user" style="width: 26px; height: 26px;"/>
|
||||
</div>
|
||||
</template>
|
||||
<template #right="{ event, timestamp, delta }">
|
||||
|
|
|
|||
|
|
@ -24,12 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
|
||||
|
||||
<MkPagination :pagination="usersPagination" :displayLimit="50">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
|
|
@ -70,7 +65,6 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
|
|
|||
|
|
@ -262,6 +262,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
|
||||
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-list"></i></template>
|
||||
<template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.metadataRoot" class="_gaps_s">
|
||||
<MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo>
|
||||
<div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem">
|
||||
<button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button>
|
||||
<div :class="$style.dragItemForm">
|
||||
<FormSplit :minWidth="200">
|
||||
<MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName">
|
||||
</MkInput>
|
||||
<MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version">
|
||||
</MkInput>
|
||||
</FormSplit>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
|
|
@ -420,10 +445,12 @@ const urlPreviewForm = useForm({
|
|||
const federationForm = useForm({
|
||||
federation: meta.federation,
|
||||
federationHosts: meta.federationHosts.join('\n'),
|
||||
deliverSuspendedSoftware: meta.deliverSuspendedSoftware,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
federation: state.federation,
|
||||
federationHosts: state.federationHosts.split('\n'),
|
||||
deliverSuspendedSoftware: state.deliverSuspendedSoftware,
|
||||
});
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
|
@ -470,4 +497,53 @@ definePage(() => ({
|
|||
font-size: 0.85em;
|
||||
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
|
||||
}
|
||||
|
||||
.metadataRoot {
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.fieldDragItem {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
align-items: flex-end;
|
||||
border-radius: 6px;
|
||||
|
||||
/* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */
|
||||
@container (max-width: 452px) {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.dragItemHandle {
|
||||
cursor: grab;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0 8px 0 0;
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.dragItemRemove {
|
||||
@extend .dragItemHandle;
|
||||
|
||||
color: #ff2a2a;
|
||||
opacity: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover, &:focus {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.dragItemForm {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,9 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
<div v-if="!fetching && invitations.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noInvitations }}</div>
|
||||
</div>
|
||||
<MkResult v-if="!fetching && invitations.length == 0" type="empty" :text="i18n.ts._chat.noInvitations"/>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="memberships.length > 0" class="_gaps_s">
|
||||
<XRoom v-for="membership in memberships" :key="membership.id" :room="membership.room!"/>
|
||||
</div>
|
||||
<div v-if="!fetching && memberships.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noRooms }}</div>
|
||||
</div>
|
||||
<MkResult v-if="!fetching && memberships.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="rooms.length > 0" class="_gaps_s">
|
||||
<XRoom v-for="room in rooms" :key="room.id" :room="room"/>
|
||||
</div>
|
||||
<div v-if="!fetching && rooms.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noRooms }}</div>
|
||||
</div>
|
||||
<MkResult v-if="!fetching && rooms.length == 0" type="empty" :text="i18n.ts._chat.noRooms"/>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<XMessage :message="message" :user="message.fromUser" :isSearchResult="true"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
<MkResult v-else type="notFound"/>
|
||||
</MkFoldableSection>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -38,7 +35,6 @@ import * as Misskey from 'misskey-js';
|
|||
import XMessage from './XMessage.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
|
|
|
|||
66
packages/frontend/src/pages/debug.vue
Normal file
66
packages/frontend/src/pages/debug.vue
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<PageWithHeader>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 600px;">
|
||||
<div class="_gaps_m">
|
||||
<MkResult v-if="resultType === 'empty'" type="empty"/>
|
||||
<MkResult v-if="resultType === 'notFound'" type="notFound"/>
|
||||
<MkResult v-if="resultType === 'error'" type="error"/>
|
||||
<MkSelect
|
||||
v-model="resultType" :items="[
|
||||
{ label: 'empty', value: 'empty' },
|
||||
{ label: 'notFound', value: 'notFound' },
|
||||
{ label: 'error', value: 'error' },
|
||||
]"
|
||||
></MkSelect>
|
||||
|
||||
<MkSystemIcon v-if="iconType === 'info'" type="info" style="width: 60px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'question'" type="question" style="width: 60px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 60px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 60px;"/>
|
||||
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 60px;"/>
|
||||
<MkSelect
|
||||
v-model="iconType" :items="[
|
||||
{ label: 'info', value: 'info' },
|
||||
{ label: 'question', value: 'question' },
|
||||
{ label: 'success', value: 'success' },
|
||||
{ label: 'warn', value: 'warn' },
|
||||
{ label: 'error', value: 'error' },
|
||||
]"
|
||||
></MkSelect>
|
||||
|
||||
<div class="_buttons">
|
||||
<MkButton @click="os.alert({ type: 'error', title: 'Error', text: 'error' })">Error</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'warning', title: 'Warning', text: 'warning' })">Warning</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'info', title: 'Info', text: 'info' })">Info</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'success', title: 'Success', text: 'success' })">Success</MkButton>
|
||||
<MkButton @click="os.alert({ type: 'question', title: 'Question', text: 'question' })">Question</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const resultType = ref('empty');
|
||||
const iconType = ref('info');
|
||||
|
||||
definePage(() => ({
|
||||
title: 'DEBUG ROOM',
|
||||
icon: 'ti ti-help-circle',
|
||||
}));
|
||||
</script>
|
||||
|
|
@ -68,10 +68,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkKeyValue>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
<MkResult v-else type="empty"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -82,7 +79,6 @@ import MkInfo from '@/components/MkInfo.vue';
|
|||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader>
|
||||
<div class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noNotes }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noNotes"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<MkDateSeparatedList v-slot="{ item }" :items="items" :direction="'down'" :noGap="false" :ad="false">
|
||||
|
|
@ -30,7 +25,6 @@ import DynamicNote from '@/components/DynamicNote.vue';
|
|||
import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'i/favorites' as const,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :swipable="true">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||
<MkPagination ref="paginationComponent" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noFollowRequests }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noFollowRequests"/></template>
|
||||
<template #default="{items}">
|
||||
<div class="mk-follow-requests _gaps">
|
||||
<div v-for="req in items" :key="req.id" class="user _panel">
|
||||
|
|
@ -48,7 +43,6 @@ import { userPage, acct } from '@/filters/user.js';
|
|||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { $i } from '@/i.js';
|
||||
|
||||
const paginationComponent = useTemplateRef('paginationComponent');
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton inline :disabled="!instance" danger @click="deleteAllFiles">{{ i18n.ts.deleteAllFiles }}</MkButton>
|
||||
<MkButton inline :disabled="!instance" danger @click="severAllFollowRelations">{{ i18n.ts.severAllFollowRelations }}</MkButton>
|
||||
</div>
|
||||
<MkSwitch v-model="isSuspended" :disabled="!instance" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch>
|
||||
<MkSwitch v-model="isSuspended" :disabled="!instance || suspensionState == 'softwareSuspended'" @update:modelValue="toggleSuspended">{{ i18n.ts._delivery.stop }}</MkSwitch>
|
||||
<MkInfo v-if="isBaseBlocked" warn>{{ i18n.ts.blockedByBase }}</MkInfo>
|
||||
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance || isBaseBlocked" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
|
||||
<MkInfo v-if="isBaseSilenced" warn>{{ i18n.ts.silencedByBase }}</MkInfo>
|
||||
|
|
@ -207,7 +207,7 @@ const tab = ref('overview');
|
|||
const chartSrc = ref<ChartSrc>('instance-requests');
|
||||
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
|
||||
const instance = ref<Misskey.entities.FederationInstance | null>(null);
|
||||
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
|
||||
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding' | 'softwareSuspended'>('none');
|
||||
const isSuspended = ref(false);
|
||||
const isBlocked = ref(false);
|
||||
const isSilenced = ref(false);
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<PageWithHeader>
|
||||
<div v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" class="_spacer" style="--MI_SPACER-w: 1200px;">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
|
||||
<div :class="$style.text">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
{{ i18n.ts.nothing }}
|
||||
</div>
|
||||
</div>
|
||||
<MkResult type="empty"/>
|
||||
</div>
|
||||
<div v-else class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||
<div class="_gaps_m" style="text-align: center;">
|
||||
|
|
@ -43,7 +37,7 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkInviteCode from '@/components/MkInviteCode.vue';
|
||||
import { definePage } from '@/page.js';
|
||||
import { serverErrorImageUrl, instance } from '@/instance.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { $i } from '@/i.js';
|
||||
|
||||
const pagingComponent = useTemplateRef('pagingComponent');
|
||||
|
|
@ -96,23 +90,3 @@ definePage(() => ({
|
|||
icon: 'ti ti-user-plus',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.img {
|
||||
vertical-align: bottom;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--MI-radius-md);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
|
||||
<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
|
||||
<p :class="$style.text">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
{{ i18n.ts.nothing }}
|
||||
</p>
|
||||
</div>
|
||||
<MkResult type="error"/>
|
||||
</div>
|
||||
<div v-else-if="list" class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div v-if="list" class="members _margin">
|
||||
|
|
@ -42,7 +36,6 @@ import { i18n } from '@/i18n.js';
|
|||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { definePage } from '@/page.js';
|
||||
import { serverErrorImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
listId: string;
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div>
|
||||
<div v-if="antennas.length === 0" class="empty">
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkResult v-if="antennas.length === 0" type="empty"/>
|
||||
|
||||
<MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
|
||||
|
|
@ -32,7 +27,6 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { antennasCache } from '@/cache.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const antennas = computed(() => antennasCache.value.value ?? []);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,12 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div class="_gaps">
|
||||
<div v-if="items.length === 0" class="empty">
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkResult v-if="items.length === 0" type="empty"/>
|
||||
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.createList }}</MkButton>
|
||||
|
||||
|
|
@ -35,7 +30,6 @@ import * as os from '@/os.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { userListsCache } from '@/cache.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
|
||||
const $i = ensureSignin();
|
||||
|
|
|
|||
|
|
@ -4,11 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="_fullinfo">
|
||||
<img :src="notFoundImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFoundDescription }}</div>
|
||||
</div>
|
||||
<div style="align-content: center; height: 100cqh;">
|
||||
<MkResult type="notFound" :text="i18n.ts.notFoundDescription"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -17,7 +14,6 @@ import { computed } from 'vue';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page.js';
|
||||
import { pleaseLogin } from '@/utility/please-login.js';
|
||||
import { notFoundImageUrl } from '@/instance.js';
|
||||
|
||||
const props = defineProps<{
|
||||
showLoginPopup?: boolean;
|
||||
|
|
|
|||
|
|
@ -6,30 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<PageWithHeader v-model:tab="tab" :displayBackButton="true" :tabs="headerTabs">
|
||||
<div v-if="error != null" class="_spacer" style="--MI_SPACER-w: 1200px;">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" draggable="false"/>
|
||||
<p :class="$style.text">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
<MkResult type="error" :text="error"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'users'" class="_spacer" style="--MI_SPACER-w: 1200px;">
|
||||
<div class="_gaps_s">
|
||||
<div v-if="role">{{ role.description }}</div>
|
||||
<MkUserList v-if="visible" :pagination="users" :extractor="(item) => item.user"/>
|
||||
<div v-else-if="!visible" class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="tab === 'timeline'" class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<MkTimeline v-if="visible" ref="timeline" src="role" :role="props.roleId"/>
|
||||
<div v-else-if="!visible" class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
<MkResult v-else-if="!visible" type="empty" :text="i18n.ts.nothing"/>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</template>
|
||||
|
|
@ -37,13 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import MkUserList from '@/components/MkUserList.vue';
|
||||
import { definePage } from '@/page.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkTimeline from '@/components/MkTimeline.vue';
|
||||
import { serverErrorImageUrl, infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
roleId: string;
|
||||
|
|
@ -97,23 +83,3 @@ definePage(() => ({
|
|||
icon: 'ti ti-badge',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.img {
|
||||
vertical-align: bottom;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: var(--MI-radius-md);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,12 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div class="_gaps_m">
|
||||
<FormPagination ref="list" :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty"/></template>
|
||||
<template #default="{items}">
|
||||
<div class="_gaps">
|
||||
<MkFolder v-for="token in items" :key="token.id" :defaultOpen="true">
|
||||
|
|
@ -63,7 +58,6 @@ import { definePage } from '@/page.js';
|
|||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const list = ref<InstanceType<typeof FormPagination>>();
|
||||
|
||||
|
|
|
|||
|
|
@ -69,12 +69,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label><SearchLabel>{{ i18n.ts.mutedUsers }} ({{ i18n.ts.renote }})</SearchLabel></template>
|
||||
|
||||
<MkPagination :pagination="renoteMutingPagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
|
|
@ -105,12 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.mutedUsers }}</template>
|
||||
|
||||
<MkPagination :pagination="mutingPagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
|
|
@ -143,12 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.blockedUsers }}</template>
|
||||
|
||||
<MkPagination :pagination="blockingPagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.noUsers }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty><MkResult type="empty" :text="i18n.ts.noUsers"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
|
|
@ -186,7 +171,7 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePage } from '@/page.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { instance, infoImageUrl } from '@/instance.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ensureSignin } from '@/i.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
|
|
|||
|
|
@ -608,6 +608,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['swipe', 'pull', 'refresh']">
|
||||
<MkPreferenceContainer k="enablePullToRefresh">
|
||||
<MkSwitch v-model="enablePullToRefresh">
|
||||
<template #label><SearchLabel>{{ i18n.ts._settings.enablePullToRefresh }}</SearchLabel></template>
|
||||
<template #caption><SearchKeyword>{{ i18n.ts._settings.enablePullToRefresh_description }}</SearchKeyword></template>
|
||||
</MkSwitch>
|
||||
</MkPreferenceContainer>
|
||||
</SearchMarker>
|
||||
|
||||
<SearchMarker :keywords="['keep', 'screen', 'display', 'on']">
|
||||
<MkPreferenceContainer k="keepScreenOn">
|
||||
<MkSwitch v-model="keepScreenOn">
|
||||
|
|
@ -1008,6 +1017,7 @@ const animatedMfm = prefer.model('animatedMfm');
|
|||
const disableShowingAnimatedImages = prefer.model('disableShowingAnimatedImages');
|
||||
const keepScreenOn = prefer.model('keepScreenOn');
|
||||
const enableHorizontalSwipe = prefer.model('enableHorizontalSwipe');
|
||||
const enablePullToRefresh = prefer.model('enablePullToRefresh');
|
||||
const useNativeUiForVideoAudioPlayer = prefer.model('useNativeUiForVideoAudioPlayer');
|
||||
const contextMenu = prefer.model('contextMenu');
|
||||
const menuStyle = prefer.model('menuStyle');
|
||||
|
|
@ -1097,6 +1107,8 @@ watch([
|
|||
fontSize,
|
||||
useSystemFont,
|
||||
makeEveryTextElementsSelectable,
|
||||
enableHorizontalSwipe,
|
||||
enablePullToRefresh,
|
||||
], async () => {
|
||||
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue