Merge branch 'misskey-dev:develop' into develop
This commit is contained in:
commit
60695ed78b
@ -18,13 +18,17 @@
|
|||||||
- Feat: アイコンデコレーション機能
|
- Feat: アイコンデコレーション機能
|
||||||
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
|
- Enhance: すでにフォローしたすべての人の返信をTLに追加できるように
|
||||||
|
|
||||||
## Client
|
### Client
|
||||||
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
|
- Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました
|
||||||
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
|
- 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください
|
||||||
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
|
https://misskey-hub.net/docs/advanced/publish-on-your-website.html
|
||||||
|
- Fix: 投稿フォームでのユーザー変更がプレビューに反映されない問題を修正
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
|
- Enhance: RedisへのTLのキャッシュをオフにできるように
|
||||||
- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正
|
- Fix: リストTLに自分のフォロワー限定投稿が含まれない問題を修正
|
||||||
|
- Fix: ローカルタイムラインに投稿者自身の投稿への返信が含まれない問題を修正
|
||||||
|
- Fix: 自分のフォローしているユーザーの自分のフォローしていないユーザーの visibility: followers な投稿への返信がストリーミングで流れてくる問題を修正
|
||||||
|
|
||||||
## 2023.10.2
|
## 2023.10.2
|
||||||
|
|
||||||
|
6
locales/index.d.ts
vendored
6
locales/index.d.ts
vendored
@ -1147,6 +1147,11 @@ export interface Locale {
|
|||||||
"privacyPolicyUrl": string;
|
"privacyPolicyUrl": string;
|
||||||
"tosAndPrivacyPolicy": string;
|
"tosAndPrivacyPolicy": string;
|
||||||
"avatarDecorations": string;
|
"avatarDecorations": string;
|
||||||
|
"attach": string;
|
||||||
|
"detach": string;
|
||||||
|
"angle": string;
|
||||||
|
"flip": string;
|
||||||
|
"showAvatarDecorations": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
@ -1185,6 +1190,7 @@ export interface Locale {
|
|||||||
"manifestJsonOverride": string;
|
"manifestJsonOverride": string;
|
||||||
"shortName": string;
|
"shortName": string;
|
||||||
"shortNameDescription": string;
|
"shortNameDescription": string;
|
||||||
|
"fanoutTimelineDescription": string;
|
||||||
};
|
};
|
||||||
"_accountMigration": {
|
"_accountMigration": {
|
||||||
"moveFrom": string;
|
"moveFrom": string;
|
||||||
|
@ -1144,6 +1144,11 @@ privacyPolicy: "プライバシーポリシー"
|
|||||||
privacyPolicyUrl: "プライバシーポリシーURL"
|
privacyPolicyUrl: "プライバシーポリシーURL"
|
||||||
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
||||||
avatarDecorations: "アイコンデコレーション"
|
avatarDecorations: "アイコンデコレーション"
|
||||||
|
attach: "付ける"
|
||||||
|
detach: "外す"
|
||||||
|
angle: "角度"
|
||||||
|
flip: "反転"
|
||||||
|
showAvatarDecorations: "アイコンのデコレーションを表示"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
@ -1183,6 +1188,7 @@ _serverSettings:
|
|||||||
manifestJsonOverride: "manifest.jsonのオーバーライド"
|
manifestJsonOverride: "manifest.jsonのオーバーライド"
|
||||||
shortName: "略称"
|
shortName: "略称"
|
||||||
shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。"
|
shortNameDescription: "サーバーの正式名称が長い場合に、代わりに表示することのできる略称や通称。"
|
||||||
|
fanoutTimelineDescription: "有効にすると、各種タイムラインを取得する際のパフォーマンスが大幅に向上し、データベースへの負荷を軽減することが可能です。ただし、Redisのメモリ使用量は増加します。サーバーのメモリ容量が少ない場合、または動作が不安定な場合は無効にすることができます。"
|
||||||
|
|
||||||
_accountMigration:
|
_accountMigration:
|
||||||
moveFrom: "別のアカウントからこのアカウントに移行"
|
moveFrom: "別のアカウントからこのアカウントに移行"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2023.11.0-beta.1",
|
"version": "2023.11.0-beta.3",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@ -22,6 +22,7 @@
|
|||||||
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
"start:test": "cd packages/backend && cross-env NODE_ENV=test node ./built/boot/entry.js",
|
||||||
"init": "pnpm migrate",
|
"init": "pnpm migrate",
|
||||||
"migrate": "cd packages/backend && pnpm migrate",
|
"migrate": "cd packages/backend && pnpm migrate",
|
||||||
|
"revert": "cd packages/backend && pnpm revert",
|
||||||
"check:connect": "cd packages/backend && pnpm check:connect",
|
"check:connect": "cd packages/backend && pnpm check:connect",
|
||||||
"migrateandstart": "pnpm migrate && pnpm start",
|
"migrateandstart": "pnpm migrate && pnpm start",
|
||||||
"watch": "pnpm dev",
|
"watch": "pnpm dev",
|
||||||
@ -55,7 +56,7 @@
|
|||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "13.3.2",
|
"cypress": "13.3.2",
|
||||||
"eslint": "8.51.0",
|
"eslint": "8.52.0",
|
||||||
"start-server-and-test": "2.0.1"
|
"start-server-and-test": "2.0.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AvatarDecoration21697941908548 {
|
||||||
|
name = 'AvatarDecoration21697941908548'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" jsonb NOT NULL DEFAULT '[]'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarDecorations"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "user" ADD "avatarDecorations" character varying(512) array NOT NULL DEFAULT '{}'`);
|
||||||
|
}
|
||||||
|
}
|
16
packages/backend/migration/1698041201306-enable-ftt.js
Normal file
16
packages/backend/migration/1698041201306-enable-ftt.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class EnableFtt1698041201306 {
|
||||||
|
name = 'EnableFtt1698041201306'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "enableFanoutTimeline" boolean NOT NULL DEFAULT true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableFanoutTimeline"`);
|
||||||
|
}
|
||||||
|
}
|
@ -10,6 +10,7 @@
|
|||||||
"start": "node ./built/index.js",
|
"start": "node ./built/index.js",
|
||||||
"start:test": "NODE_ENV=test node ./built/index.js",
|
"start:test": "NODE_ENV=test node ./built/index.js",
|
||||||
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
|
||||||
|
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
|
||||||
"check:connect": "node ./check_connect.js",
|
"check:connect": "node ./check_connect.js",
|
||||||
"build": "swc src -d built -D",
|
"build": "swc src -d built -D",
|
||||||
"watch:swc": "swc src -d built -D -w",
|
"watch:swc": "swc src -d built -D -w",
|
||||||
@ -76,9 +77,9 @@
|
|||||||
"@nestjs/testing": "10.2.7",
|
"@nestjs/testing": "10.2.7",
|
||||||
"@peertube/http-signature": "1.7.0",
|
"@peertube/http-signature": "1.7.0",
|
||||||
"@simplewebauthn/server": "8.3.2",
|
"@simplewebauthn/server": "8.3.2",
|
||||||
"@sinonjs/fake-timers": "11.2.1",
|
"@sinonjs/fake-timers": "11.2.2",
|
||||||
"@swc/cli": "0.1.62",
|
"@swc/cli": "0.1.62",
|
||||||
"@swc/core": "1.3.93",
|
"@swc/core": "1.3.94",
|
||||||
"accepts": "1.3.8",
|
"accepts": "1.3.8",
|
||||||
"ajv": "8.12.0",
|
"ajv": "8.12.0",
|
||||||
"archiver": "6.0.1",
|
"archiver": "6.0.1",
|
||||||
@ -124,7 +125,7 @@
|
|||||||
"nanoid": "5.0.2",
|
"nanoid": "5.0.2",
|
||||||
"nested-property": "4.0.0",
|
"nested-property": "4.0.0",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"nodemailer": "6.9.6",
|
"nodemailer": "6.9.7",
|
||||||
"nsfwjs": "2.4.2",
|
"nsfwjs": "2.4.2",
|
||||||
"oauth": "0.10.0",
|
"oauth": "0.10.0",
|
||||||
"oauth2orize": "1.12.0",
|
"oauth2orize": "1.12.0",
|
||||||
@ -142,7 +143,7 @@
|
|||||||
"qrcode": "1.5.3",
|
"qrcode": "1.5.3",
|
||||||
"random-seed": "0.3.0",
|
"random-seed": "0.3.0",
|
||||||
"ratelimiter": "3.4.1",
|
"ratelimiter": "3.4.1",
|
||||||
"re2": "1.20.3",
|
"re2": "1.20.4",
|
||||||
"redis-lock": "0.1.4",
|
"redis-lock": "0.1.4",
|
||||||
"reflect-metadata": "0.1.13",
|
"reflect-metadata": "0.1.13",
|
||||||
"rename": "1.0.4",
|
"rename": "1.0.4",
|
||||||
@ -155,7 +156,7 @@
|
|||||||
"strict-event-emitter-types": "2.0.0",
|
"strict-event-emitter-types": "2.0.0",
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"summaly": "github:misskey-dev/summaly",
|
"summaly": "github:misskey-dev/summaly",
|
||||||
"systeminformation": "5.21.12",
|
"systeminformation": "5.21.13",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.1",
|
"tmp": "0.2.1",
|
||||||
"tsc-alias": "1.8.8",
|
"tsc-alias": "1.8.8",
|
||||||
@ -216,7 +217,7 @@
|
|||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"aws-sdk-client-mock": "3.0.0",
|
"aws-sdk-client-mock": "3.0.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"eslint": "8.51.0",
|
"eslint": "8.52.0",
|
||||||
"eslint-plugin-import": "2.28.1",
|
"eslint-plugin-import": "2.28.1",
|
||||||
"execa": "8.0.1",
|
"execa": "8.0.1",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
|
@ -77,7 +77,13 @@ export interface MainEventTypes {
|
|||||||
unreadAntenna: MiAntenna;
|
unreadAntenna: MiAntenna;
|
||||||
readAllAnnouncements: undefined;
|
readAllAnnouncements: undefined;
|
||||||
myTokenRegenerated: undefined;
|
myTokenRegenerated: undefined;
|
||||||
signin: MiSignin;
|
signin: {
|
||||||
|
id: MiSignin['id'];
|
||||||
|
createdAt: string;
|
||||||
|
ip: string;
|
||||||
|
headers: Record<string, any>;
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
registryUpdated: {
|
registryUpdated: {
|
||||||
scope?: string[];
|
scope?: string[];
|
||||||
key: string;
|
key: string;
|
||||||
|
@ -56,6 +56,7 @@ import { SearchService } from '@/core/SearchService.js';
|
|||||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
import { UserBlockingService } from '@/core/UserBlockingService.js';
|
||||||
|
|
||||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||||
|
|
||||||
@ -216,6 +217,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
private activeUsersChart: ActiveUsersChart,
|
private activeUsersChart: ActiveUsersChart,
|
||||||
private instanceChart: InstanceChart,
|
private instanceChart: InstanceChart,
|
||||||
private utilityService: UtilityService,
|
private utilityService: UtilityService,
|
||||||
|
private userBlockingService: UserBlockingService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
@ -292,6 +294,18 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check blocking
|
||||||
|
if (data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)) {
|
||||||
|
if (data.renote.userHost === null) {
|
||||||
|
if (data.renote.userId !== user.id) {
|
||||||
|
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
|
||||||
|
if (blocked) {
|
||||||
|
throw new Error('blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 返信対象がpublicではないならhomeにする
|
// 返信対象がpublicではないならhomeにする
|
||||||
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
|
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
|
||||||
data.visibility = 'home';
|
data.visibility = 'home';
|
||||||
@ -825,6 +839,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||||||
@bindThis
|
@bindThis
|
||||||
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
private async pushToTl(note: MiNote, user: { id: MiUser['id']; host: MiUser['host']; }) {
|
||||||
const meta = await this.metaService.fetch();
|
const meta = await this.metaService.fetch();
|
||||||
|
if (!meta.enableFanoutTimeline) return;
|
||||||
|
|
||||||
const r = this.redisForTimelines.pipeline();
|
const r = this.redisForTimelines.pipeline();
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ export class QueryService {
|
|||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): SelectQueryBuilder<T> {
|
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
|
||||||
if (sinceId && untilId) {
|
if (sinceId && untilId) {
|
||||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||||
|
@ -7,10 +7,12 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import type { } from '@/models/Blocking.js';
|
import type { } from '@/models/Blocking.js';
|
||||||
import type { MiSignin } from '@/models/Signin.js';
|
import type { MiSignin } from '@/models/Signin.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SigninEntityService {
|
export class SigninEntityService {
|
||||||
constructor(
|
constructor(
|
||||||
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -18,7 +20,13 @@ export class SigninEntityService {
|
|||||||
public async pack(
|
public async pack(
|
||||||
src: MiSignin,
|
src: MiSignin,
|
||||||
) {
|
) {
|
||||||
return src;
|
return {
|
||||||
|
id: src.id,
|
||||||
|
createdAt: this.idService.parse(src.id).date.toISOString(),
|
||||||
|
ip: src.ip,
|
||||||
|
headers: src.headers,
|
||||||
|
success: src.success,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -338,9 +338,11 @@ export class UserEntityService implements OnModuleInit {
|
|||||||
host: user.host,
|
host: user.host,
|
||||||
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
avatarUrl: user.avatarUrl ?? this.getIdenticonUrl(user),
|
||||||
avatarBlurhash: user.avatarBlurhash,
|
avatarBlurhash: user.avatarBlurhash,
|
||||||
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => decorations.filter(decoration => user.avatarDecorations.includes(decoration.id)).map(decoration => ({
|
avatarDecorations: user.avatarDecorations.length > 0 ? this.avatarDecorationService.getAll().then(decorations => user.avatarDecorations.filter(ud => decorations.some(d => d.id === ud.id)).map(ud => ({
|
||||||
id: decoration.id,
|
id: ud.id,
|
||||||
url: decoration.url,
|
angle: ud.angle || undefined,
|
||||||
|
flipH: ud.flipH || undefined,
|
||||||
|
url: decorations.find(d => d.id === ud.id)!.url,
|
||||||
}))) : [],
|
}))) : [],
|
||||||
isBot: user.isBot,
|
isBot: user.isBot,
|
||||||
isCat: user.isCat,
|
isCat: user.isCat,
|
||||||
|
@ -489,6 +489,11 @@ export class MiMeta {
|
|||||||
})
|
})
|
||||||
public preservedUsernames: string[];
|
public preservedUsernames: string[];
|
||||||
|
|
||||||
|
@Column('boolean', {
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
|
public enableFanoutTimeline: boolean;
|
||||||
|
|
||||||
@Column('integer', {
|
@Column('integer', {
|
||||||
default: 300,
|
default: 300,
|
||||||
})
|
})
|
||||||
|
@ -138,10 +138,14 @@ export class MiUser {
|
|||||||
})
|
})
|
||||||
public bannerBlurhash: string | null;
|
public bannerBlurhash: string | null;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('jsonb', {
|
||||||
length: 512, array: true, default: '{}',
|
default: [],
|
||||||
})
|
})
|
||||||
public avatarDecorations: string[];
|
public avatarDecorations: {
|
||||||
|
id: string;
|
||||||
|
angle: number;
|
||||||
|
flipH: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
@Index()
|
@Index()
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
|
@ -54,6 +54,14 @@ export const packedUserLiteSchema = {
|
|||||||
format: 'url',
|
format: 'url',
|
||||||
nullable: false, optional: false,
|
nullable: false, optional: false,
|
||||||
},
|
},
|
||||||
|
angle: {
|
||||||
|
type: 'number',
|
||||||
|
nullable: false, optional: true,
|
||||||
|
},
|
||||||
|
flipH: {
|
||||||
|
type: 'boolean',
|
||||||
|
nullable: false, optional: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -106,11 +106,11 @@ export const meta = {
|
|||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
silencedHosts: {
|
silencedHosts: {
|
||||||
type: "array",
|
type: 'array',
|
||||||
optional: true,
|
optional: true,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
items: {
|
items: {
|
||||||
type: "string",
|
type: 'string',
|
||||||
optional: false,
|
optional: false,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
},
|
},
|
||||||
@ -291,6 +291,10 @@ export const meta = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
enableFanoutTimeline: {
|
||||||
|
type: 'boolean',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
},
|
||||||
perLocalUserUserTimelineCacheMax: {
|
perLocalUserUserTimelineCacheMax: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
@ -419,6 +423,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
enableIdenticonGeneration: instance.enableIdenticonGeneration,
|
||||||
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
policies: { ...DEFAULT_POLICIES, ...instance.policies },
|
||||||
manifestJsonOverride: instance.manifestJsonOverride,
|
manifestJsonOverride: instance.manifestJsonOverride,
|
||||||
|
enableFanoutTimeline: instance.enableFanoutTimeline,
|
||||||
perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
|
perLocalUserUserTimelineCacheMax: instance.perLocalUserUserTimelineCacheMax,
|
||||||
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
|
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
|
||||||
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
|
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
|
||||||
|
@ -120,6 +120,7 @@ export const paramDef = {
|
|||||||
serverRules: { type: 'array', items: { type: 'string' } },
|
serverRules: { type: 'array', items: { type: 'string' } },
|
||||||
preservedUsernames: { type: 'array', items: { type: 'string' } },
|
preservedUsernames: { type: 'array', items: { type: 'string' } },
|
||||||
manifestJsonOverride: { type: 'string' },
|
manifestJsonOverride: { type: 'string' },
|
||||||
|
enableFanoutTimeline: { type: 'boolean' },
|
||||||
perLocalUserUserTimelineCacheMax: { type: 'integer' },
|
perLocalUserUserTimelineCacheMax: { type: 'integer' },
|
||||||
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
|
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
|
||||||
perUserHomeTimelineCacheMax: { type: 'integer' },
|
perUserHomeTimelineCacheMax: { type: 'integer' },
|
||||||
@ -480,6 +481,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
set.manifestJsonOverride = ps.manifestJsonOverride;
|
set.manifestJsonOverride = ps.manifestJsonOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.enableFanoutTimeline !== undefined) {
|
||||||
|
set.enableFanoutTimeline = ps.enableFanoutTimeline;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
|
if (ps.perLocalUserUserTimelineCacheMax !== undefined) {
|
||||||
set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
|
set.perLocalUserUserTimelineCacheMax = ps.perLocalUserUserTimelineCacheMax;
|
||||||
}
|
}
|
||||||
|
@ -133,7 +133,13 @@ export const paramDef = {
|
|||||||
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
|
lang: { type: 'string', enum: [null, ...Object.keys(langmap)] as string[], nullable: true },
|
||||||
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
|
avatarId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||||
avatarDecorations: { type: 'array', maxItems: 1, items: {
|
avatarDecorations: { type: 'array', maxItems: 1, items: {
|
||||||
type: 'string',
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', format: 'misskey:id' },
|
||||||
|
angle: { type: 'number', nullable: true, maximum: 0.5, minimum: -0.5 },
|
||||||
|
flipH: { type: 'boolean', nullable: true },
|
||||||
|
},
|
||||||
|
required: ['id'],
|
||||||
} },
|
} },
|
||||||
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
|
bannerId: { type: 'string', format: 'misskey:id', nullable: true },
|
||||||
fields: {
|
fields: {
|
||||||
@ -309,7 +315,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
|
|
||||||
updates.avatarDecorations = ps.avatarDecorations.filter(id => decorationIds.includes(id));
|
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
|
||||||
|
id: d.id,
|
||||||
|
angle: d.angle ?? 0,
|
||||||
|
flipH: d.flipH ?? false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ps.pinnedPageId) {
|
if (ps.pinnedPageId) {
|
||||||
|
@ -17,6 +17,8 @@ import { CacheService } from '@/core/CacheService.js';
|
|||||||
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
@ -75,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
private funoutTimelineService: FunoutTimelineService,
|
private funoutTimelineService: FunoutTimelineService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
@ -85,163 +88,200 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
throw new ApiError(meta.errors.stlDisabled);
|
throw new ApiError(meta.errors.stlDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const serverSettings = await this.metaService.fetch();
|
||||||
userIdsWhoMeMuting,
|
|
||||||
userIdsWhoMeMutingRenotes,
|
|
||||||
userIdsWhoBlockingMe,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.renoteMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let noteIds: string[];
|
if (serverSettings.enableFanoutTimeline) {
|
||||||
let shouldFallbackToDb = false;
|
const [
|
||||||
|
userIdsWhoMeMuting,
|
||||||
|
userIdsWhoMeMutingRenotes,
|
||||||
|
userIdsWhoBlockingMe,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.renoteMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (ps.withFiles) {
|
let noteIds: string[];
|
||||||
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
|
let shouldFallbackToDb = false;
|
||||||
`homeTimelineWithFiles:${me.id}`,
|
|
||||||
'localTimelineWithFiles',
|
|
||||||
], untilId, sinceId);
|
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
|
||||||
} else if (ps.withReplies) {
|
|
||||||
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([
|
|
||||||
`homeTimeline:${me.id}`,
|
|
||||||
'localTimeline',
|
|
||||||
'localTimelineWithReplies',
|
|
||||||
], untilId, sinceId);
|
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
|
|
||||||
} else {
|
|
||||||
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
|
|
||||||
`homeTimeline:${me.id}`,
|
|
||||||
'localTimeline',
|
|
||||||
], untilId, sinceId);
|
|
||||||
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
|
||||||
shouldFallbackToDb = htlNoteIds.length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
|
||||||
|
|
||||||
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
|
|
||||||
|
|
||||||
if (!shouldFallbackToDb) {
|
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
|
||||||
|
|
||||||
let timeline = await query.getMany();
|
|
||||||
|
|
||||||
timeline = timeline.filter(note => {
|
|
||||||
if (note.userId === me.id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
|
||||||
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
|
|
||||||
if (note.renoteId) {
|
|
||||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
|
||||||
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
|
||||||
if (ps.withRenotes === false) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: フィルタした結果件数が足りなかった場合の対応
|
|
||||||
|
|
||||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
|
||||||
|
|
||||||
process.nextTick(() => {
|
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
} else { // fallback to db
|
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
|
||||||
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
|
||||||
.andWhere(new Brackets(qb => {
|
|
||||||
if (followees.length > 0) {
|
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
|
||||||
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
|
||||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
|
||||||
} else {
|
|
||||||
qb.where('note.userId = :meId', { meId: me.id });
|
|
||||||
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
|
||||||
|
|
||||||
if (!ps.withReplies) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
|
||||||
.orWhere(new Brackets(qb => {
|
|
||||||
qb // 返信だけど投稿者自身への返信
|
|
||||||
.where('note.replyId IS NOT NULL')
|
|
||||||
.andWhere('note.replyUserId = note.userId');
|
|
||||||
}));
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
|
||||||
|
|
||||||
if (ps.includeMyRenotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.includeRenotedMyNotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.includeLocalRenotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.withFiles) {
|
if (ps.withFiles) {
|
||||||
query.andWhere('note.fileIds != \'{}\'');
|
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
|
||||||
|
`homeTimelineWithFiles:${me.id}`,
|
||||||
|
'localTimelineWithFiles',
|
||||||
|
], untilId, sinceId);
|
||||||
|
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
||||||
|
} else if (ps.withReplies) {
|
||||||
|
const [htlNoteIds, ltlNoteIds, ltlReplyNoteIds] = await this.funoutTimelineService.getMulti([
|
||||||
|
`homeTimeline:${me.id}`,
|
||||||
|
'localTimeline',
|
||||||
|
'localTimelineWithReplies',
|
||||||
|
], untilId, sinceId);
|
||||||
|
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds, ...ltlReplyNoteIds]));
|
||||||
|
} else {
|
||||||
|
const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
|
||||||
|
`homeTimeline:${me.id}`,
|
||||||
|
'localTimeline',
|
||||||
|
], untilId, sinceId);
|
||||||
|
noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
||||||
|
shouldFallbackToDb = htlNoteIds.length === 0;
|
||||||
}
|
}
|
||||||
//#endregion
|
|
||||||
|
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
|
|
||||||
process.nextTick(() => {
|
shouldFallbackToDb = shouldFallbackToDb || (noteIds.length === 0);
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
if (!shouldFallbackToDb) {
|
||||||
|
const query = this.notesRepository.createQueryBuilder('note')
|
||||||
|
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.leftJoinAndSelect('note.channel', 'channel');
|
||||||
|
|
||||||
|
let timeline = await query.getMany();
|
||||||
|
|
||||||
|
timeline = timeline.filter(note => {
|
||||||
|
if (note.userId === me.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||||
|
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||||
|
if (note.renoteId) {
|
||||||
|
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||||
|
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
||||||
|
if (ps.withRenotes === false) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: フィルタした結果件数が足りなかった場合の対応
|
||||||
|
|
||||||
|
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
} else { // fallback to db
|
||||||
|
return await this.getFromDb({
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit: ps.limit,
|
||||||
|
includeMyRenotes: ps.includeMyRenotes,
|
||||||
|
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||||
|
includeLocalRenotes: ps.includeLocalRenotes,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
}, me);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return await this.getFromDb({
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit: ps.limit,
|
||||||
|
includeMyRenotes: ps.includeMyRenotes,
|
||||||
|
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||||
|
includeLocalRenotes: ps.includeLocalRenotes,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
}, me);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFromDb(ps: {
|
||||||
|
untilId: string | null,
|
||||||
|
sinceId: string | null,
|
||||||
|
limit: number,
|
||||||
|
includeMyRenotes: boolean,
|
||||||
|
includeRenotedMyNotes: boolean,
|
||||||
|
includeLocalRenotes: boolean,
|
||||||
|
withFiles: boolean,
|
||||||
|
withReplies: boolean,
|
||||||
|
}, me: MiLocalUser) {
|
||||||
|
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||||
|
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
.andWhere(new Brackets(qb => {
|
||||||
|
if (followees.length > 0) {
|
||||||
|
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||||
|
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||||
|
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||||
|
} else {
|
||||||
|
qb.where('note.userId = :meId', { meId: me.id });
|
||||||
|
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
if (!ps.withReplies) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.where('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere(new Brackets(qb => {
|
||||||
|
qb // 返信だけど投稿者自身への返信
|
||||||
|
.where('note.replyId IS NOT NULL')
|
||||||
|
.andWhere('note.replyUserId = note.userId');
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
|
if (ps.includeMyRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.includeRenotedMyNotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.includeLocalRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.withFiles) {
|
||||||
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
const timeline = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
|
|||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
@ -69,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private funoutTimelineService: FunoutTimelineService,
|
private funoutTimelineService: FunoutTimelineService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
@ -79,112 +82,140 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
throw new ApiError(meta.errors.ltlDisabled);
|
throw new ApiError(meta.errors.ltlDisabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [
|
const serverSettings = await this.metaService.fetch();
|
||||||
userIdsWhoMeMuting,
|
|
||||||
userIdsWhoMeMutingRenotes,
|
|
||||||
userIdsWhoBlockingMe,
|
|
||||||
] = me ? await Promise.all([
|
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.renoteMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
|
||||||
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
|
|
||||||
|
|
||||||
let noteIds: string[];
|
if (serverSettings.enableFanoutTimeline) {
|
||||||
|
const [
|
||||||
|
userIdsWhoMeMuting,
|
||||||
|
userIdsWhoMeMutingRenotes,
|
||||||
|
userIdsWhoBlockingMe,
|
||||||
|
] = me ? await Promise.all([
|
||||||
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.renoteMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
|
]) : [new Set<string>(), new Set<string>(), new Set<string>()];
|
||||||
|
|
||||||
if (ps.withFiles) {
|
let noteIds: string[];
|
||||||
noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
|
|
||||||
} else {
|
|
||||||
const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([
|
|
||||||
'localTimeline',
|
|
||||||
'localTimelineWithReplies',
|
|
||||||
], untilId, sinceId);
|
|
||||||
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
|
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
|
||||||
|
|
||||||
if (noteIds.length > 0) {
|
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
|
||||||
|
|
||||||
let timeline = await query.getMany();
|
|
||||||
|
|
||||||
timeline = timeline.filter(note => {
|
|
||||||
if (me && (note.userId === me.id)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!ps.withReplies && note.replyId && (me == null || note.replyUserId !== me.id)) return false;
|
|
||||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
|
||||||
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
|
||||||
if (note.renoteId) {
|
|
||||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
|
||||||
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
|
||||||
if (ps.withRenotes === false) return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: フィルタした結果件数が足りなかった場合の対応
|
|
||||||
|
|
||||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
|
||||||
|
|
||||||
process.nextTick(() => {
|
|
||||||
if (me) {
|
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
} else { // fallback to db
|
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
|
||||||
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
|
||||||
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
|
||||||
if (me) this.queryService.generateMutedUserQuery(query, me);
|
|
||||||
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
|
||||||
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
|
||||||
|
|
||||||
if (ps.withFiles) {
|
if (ps.withFiles) {
|
||||||
query.andWhere('note.fileIds != \'{}\'');
|
noteIds = await this.funoutTimelineService.get('localTimelineWithFiles', untilId, sinceId);
|
||||||
|
} else {
|
||||||
|
const [nonReplyNoteIds, replyNoteIds] = await this.funoutTimelineService.getMulti([
|
||||||
|
'localTimeline',
|
||||||
|
'localTimelineWithReplies',
|
||||||
|
], untilId, sinceId);
|
||||||
|
noteIds = Array.from(new Set([...nonReplyNoteIds, ...replyNoteIds]));
|
||||||
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ps.withReplies) {
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb
|
if (noteIds.length > 0) {
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
const query = this.notesRepository.createQueryBuilder('note')
|
||||||
.orWhere(new Brackets(qb => {
|
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||||
qb // 返信だけど投稿者自身への返信
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
.where('note.replyId IS NOT NULL')
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
.andWhere('note.replyUserId = note.userId');
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
}));
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
}));
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.leftJoinAndSelect('note.channel', 'channel');
|
||||||
|
|
||||||
|
let timeline = await query.getMany();
|
||||||
|
|
||||||
|
timeline = timeline.filter(note => {
|
||||||
|
if (me && (note.userId === me.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!ps.withReplies && note.replyId && note.replyUserId !== note.userId && (me == null || note.replyUserId !== me.id)) return false;
|
||||||
|
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||||
|
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||||
|
if (note.renoteId) {
|
||||||
|
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||||
|
if (me && isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
||||||
|
if (ps.withRenotes === false) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: フィルタした結果件数が足りなかった場合の対応
|
||||||
|
|
||||||
|
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
if (me) {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
} else { // fallback to db
|
||||||
|
return await this.getFromDb({
|
||||||
|
untilId,
|
||||||
|
sinceId,
|
||||||
|
limit: ps.limit,
|
||||||
|
withFiles: ps.withFiles,
|
||||||
|
withReplies: ps.withReplies,
|
||||||
|
}, me);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
return await this.getFromDb({
|
||||||
|
untilId,
|
||||||
process.nextTick(() => {
|
sinceId,
|
||||||
if (me) {
|
limit: ps.limit,
|
||||||
this.activeUsersChart.read(me);
|
withFiles: ps.withFiles,
|
||||||
}
|
withReplies: ps.withReplies,
|
||||||
});
|
}, me);
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFromDb(ps: {
|
||||||
|
sinceId: string | null,
|
||||||
|
untilId: string | null,
|
||||||
|
limit: number,
|
||||||
|
withFiles: boolean,
|
||||||
|
withReplies: boolean,
|
||||||
|
}, me: MiLocalUser | null) {
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
|
||||||
|
ps.sinceId, ps.untilId)
|
||||||
|
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)')
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
if (me) this.queryService.generateMutedUserQuery(query, me);
|
||||||
|
if (me) this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
|
if (ps.withFiles) {
|
||||||
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ps.withReplies) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.where('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere(new Brackets(qb => {
|
||||||
|
qb // 返信だけど投稿者自身への返信
|
||||||
|
.where('note.replyId IS NOT NULL')
|
||||||
|
.andWhere('note.replyUserId = note.userId');
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeline = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
if (me) {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js';
|
|||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
|
||||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||||
|
import { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['notes'],
|
tags: ['notes'],
|
||||||
@ -63,144 +65,171 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
private funoutTimelineService: FunoutTimelineService,
|
private funoutTimelineService: FunoutTimelineService,
|
||||||
private userFollowingService: UserFollowingService,
|
private userFollowingService: UserFollowingService,
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||||
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
|
||||||
|
|
||||||
const [
|
const serverSettings = await this.metaService.fetch();
|
||||||
followings,
|
|
||||||
userIdsWhoMeMuting,
|
|
||||||
userIdsWhoMeMutingRenotes,
|
|
||||||
userIdsWhoBlockingMe,
|
|
||||||
] = await Promise.all([
|
|
||||||
this.cacheService.userFollowingsCache.fetch(me.id),
|
|
||||||
this.cacheService.userMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.renoteMutingsCache.fetch(me.id),
|
|
||||||
this.cacheService.userBlockedCache.fetch(me.id),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
|
if (serverSettings.enableFanoutTimeline) {
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
const [
|
||||||
|
followings,
|
||||||
|
userIdsWhoMeMuting,
|
||||||
|
userIdsWhoMeMutingRenotes,
|
||||||
|
userIdsWhoBlockingMe,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.cacheService.userFollowingsCache.fetch(me.id),
|
||||||
|
this.cacheService.userMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.renoteMutingsCache.fetch(me.id),
|
||||||
|
this.cacheService.userBlockedCache.fetch(me.id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (noteIds.length > 0) {
|
let noteIds = await this.funoutTimelineService.get(ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`, untilId, sinceId);
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
|
||||||
.leftJoinAndSelect('note.channel', 'channel');
|
|
||||||
|
|
||||||
let timeline = await query.getMany();
|
if (noteIds.length > 0) {
|
||||||
|
const query = this.notesRepository.createQueryBuilder('note')
|
||||||
|
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||||
|
.leftJoinAndSelect('note.channel', 'channel');
|
||||||
|
|
||||||
timeline = timeline.filter(note => {
|
let timeline = await query.getMany();
|
||||||
if (note.userId === me.id) {
|
|
||||||
return true;
|
timeline = timeline.filter(note => {
|
||||||
}
|
if (note.userId === me.id) {
|
||||||
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
return true;
|
||||||
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
|
}
|
||||||
if (note.renoteId) {
|
if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||||
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
if (isUserRelated(note, userIdsWhoMeMuting)) return false;
|
||||||
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
if (note.renoteId) {
|
||||||
if (ps.withRenotes === false) return false;
|
if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
|
||||||
|
if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
|
||||||
|
if (ps.withRenotes === false) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (note.reply && note.reply.visibility === 'followers') {
|
||||||
|
if (!Object.hasOwn(followings, note.reply.userId)) return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (note.reply && note.reply.visibility === 'followers') {
|
|
||||||
if (!Object.hasOwn(followings, note.reply.userId)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: フィルタした結果件数が足りなかった場合の対応
|
// TODO: フィルタした結果件数が足りなかった場合の対応
|
||||||
|
|
||||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||||
|
|
||||||
process.nextTick(() => {
|
process.nextTick(() => {
|
||||||
this.activeUsersChart.read(me);
|
this.activeUsersChart.read(me);
|
||||||
});
|
});
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
} else { // fallback to db
|
} else { // fallback to db
|
||||||
const followees = await this.userFollowingService.getFollowees(me.id);
|
return await this.getFromDb({
|
||||||
|
untilId,
|
||||||
//#region Construct query
|
sinceId,
|
||||||
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
limit: ps.limit,
|
||||||
.andWhere('note.channelId IS NULL')
|
includeMyRenotes: ps.includeMyRenotes,
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||||
.leftJoinAndSelect('note.reply', 'reply')
|
includeLocalRenotes: ps.includeLocalRenotes,
|
||||||
.leftJoinAndSelect('note.renote', 'renote')
|
withFiles: ps.withFiles,
|
||||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
}, me);
|
||||||
.leftJoinAndSelect('renote.user', 'renoteUser');
|
|
||||||
|
|
||||||
if (followees.length > 0) {
|
|
||||||
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
|
||||||
|
|
||||||
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
|
||||||
} else {
|
|
||||||
query.andWhere('note.userId = :meId', { meId: me.id });
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
query.andWhere(new Brackets(qb => {
|
return await this.getFromDb({
|
||||||
qb
|
untilId,
|
||||||
.where('note.replyId IS NULL') // 返信ではない
|
sinceId,
|
||||||
.orWhere(new Brackets(qb => {
|
limit: ps.limit,
|
||||||
qb // 返信だけど投稿者自身への返信
|
includeMyRenotes: ps.includeMyRenotes,
|
||||||
.where('note.replyId IS NOT NULL')
|
includeRenotedMyNotes: ps.includeRenotedMyNotes,
|
||||||
.andWhere('note.replyUserId = note.userId');
|
includeLocalRenotes: ps.includeLocalRenotes,
|
||||||
}));
|
withFiles: ps.withFiles,
|
||||||
}));
|
}, me);
|
||||||
|
|
||||||
this.queryService.generateVisibilityQuery(query, me);
|
|
||||||
this.queryService.generateMutedUserQuery(query, me);
|
|
||||||
this.queryService.generateBlockedUserQuery(query, me);
|
|
||||||
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
|
||||||
|
|
||||||
if (ps.includeMyRenotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.userId != :meId', { meId: me.id });
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.includeRenotedMyNotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.includeLocalRenotes === false) {
|
|
||||||
query.andWhere(new Brackets(qb => {
|
|
||||||
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
|
||||||
qb.orWhere('note.renoteId IS NULL');
|
|
||||||
qb.orWhere('note.text IS NOT NULL');
|
|
||||||
qb.orWhere('note.fileIds != \'{}\'');
|
|
||||||
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ps.withFiles) {
|
|
||||||
query.andWhere('note.fileIds != \'{}\'');
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
const timeline = await query.limit(ps.limit).getMany();
|
|
||||||
|
|
||||||
process.nextTick(() => {
|
|
||||||
this.activeUsersChart.read(me);
|
|
||||||
});
|
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; }, me: MiLocalUser) {
|
||||||
|
const followees = await this.userFollowingService.getFollowees(me.id);
|
||||||
|
|
||||||
|
//#region Construct query
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
|
||||||
|
.andWhere('note.channelId IS NULL')
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
if (followees.length > 0) {
|
||||||
|
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
|
||||||
|
|
||||||
|
query.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
|
||||||
|
} else {
|
||||||
|
query.andWhere('note.userId = :meId', { meId: me.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb
|
||||||
|
.where('note.replyId IS NULL') // 返信ではない
|
||||||
|
.orWhere(new Brackets(qb => {
|
||||||
|
qb // 返信だけど投稿者自身への返信
|
||||||
|
.where('note.replyId IS NOT NULL')
|
||||||
|
.andWhere('note.replyUserId = note.userId');
|
||||||
|
}));
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
this.queryService.generateMutedUserQuery(query, me);
|
||||||
|
this.queryService.generateBlockedUserQuery(query, me);
|
||||||
|
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
|
||||||
|
|
||||||
|
if (ps.includeMyRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.userId != :meId', { meId: me.id });
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.includeRenotedMyNotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.includeLocalRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.renoteUserHost IS NOT NULL');
|
||||||
|
qb.orWhere('note.renoteId IS NULL');
|
||||||
|
qb.orWhere('note.text IS NOT NULL');
|
||||||
|
qb.orWhere('note.fileIds != \'{}\'');
|
||||||
|
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.withFiles) {
|
||||||
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
const timeline = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
|
process.nextTick(() => {
|
||||||
|
this.activeUsersChart.read(me);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,11 +59,15 @@ class HomeTimelineChannel extends Channel {
|
|||||||
if (!note.visibleUserIds!.includes(this.user!.id)) return;
|
if (!note.visibleUserIds!.includes(this.user!.id)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 関係ない返信は除外
|
if (note.reply) {
|
||||||
if (note.reply && !this.following[note.userId]?.withReplies) {
|
|
||||||
const reply = note.reply;
|
const reply = note.reply;
|
||||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
if (this.following[note.userId]?.withReplies) {
|
||||||
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||||
|
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||||
|
} else {
|
||||||
|
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||||
|
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||||
|
@ -73,11 +73,15 @@ class HybridTimelineChannel extends Channel {
|
|||||||
// Ignore notes from instances the user has muted
|
// Ignore notes from instances the user has muted
|
||||||
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
|
if (isInstanceMuted(note, new Set<string>(this.userProfile!.mutedInstances))) return;
|
||||||
|
|
||||||
// 関係ない返信は除外
|
if (note.reply) {
|
||||||
if (note.reply && !this.following[note.userId]?.withReplies && !this.withReplies) {
|
|
||||||
const reply = note.reply;
|
const reply = note.reply;
|
||||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) {
|
||||||
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||||
|
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||||
|
} else {
|
||||||
|
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||||
|
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
if (note.renote && note.text == null && (note.fileIds == null || note.fileIds.length === 0) && !this.withRenotes) return;
|
||||||
|
@ -90,11 +90,15 @@ class UserListChannel extends Channel {
|
|||||||
if (!note.visibleUserIds!.includes(this.user!.id)) return;
|
if (!note.visibleUserIds!.includes(this.user!.id)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 関係ない返信は除外
|
if (note.reply) {
|
||||||
if (note.reply && !this.membershipsMap[note.userId]?.withReplies) {
|
|
||||||
const reply = note.reply;
|
const reply = note.reply;
|
||||||
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
if (this.membershipsMap[note.userId]?.withReplies) {
|
||||||
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
|
||||||
|
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
|
||||||
|
} else {
|
||||||
|
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
|
||||||
|
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
|
||||||
|
@ -159,6 +159,10 @@ describe('Streaming', () => {
|
|||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
test('フォローしているユーザーのフォローしていないユーザーの visibility: followers な投稿への返信が流れない', async () => {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
test('フォローしていないユーザーの投稿は流れない', async () => {
|
test('フォローしていないユーザーの投稿は流れない', async () => {
|
||||||
const fired = await waitFire(
|
const fired = await waitFire(
|
||||||
kyoko, 'homeTimeline', // kyoko:home
|
kyoko, 'homeTimeline', // kyoko:home
|
||||||
|
@ -526,6 +526,20 @@ describe('Timelines', () => {
|
|||||||
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
|
assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.concurrent('他人のその人自身への返信が含まれる', async () => {
|
||||||
|
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||||
|
|
||||||
|
const bobNote1 = await post(bob, { text: 'hi' });
|
||||||
|
const bobNote2 = await post(bob, { text: 'hi', replyId: bobNote1.id });
|
||||||
|
|
||||||
|
await waitForPushToTl();
|
||||||
|
|
||||||
|
const res = await api('/notes/local-timeline', { limit: 100 }, alice);
|
||||||
|
|
||||||
|
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), true);
|
||||||
|
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
|
||||||
|
});
|
||||||
|
|
||||||
test.concurrent('チャンネル投稿が含まれない', async () => {
|
test.concurrent('チャンネル投稿が含まれない', async () => {
|
||||||
const [alice, bob] = await Promise.all([signup(), signup()]);
|
const [alice, bob] = await Promise.all([signup(), signup()]);
|
||||||
|
|
||||||
|
@ -93,6 +93,7 @@ describe('ActivityPub', () => {
|
|||||||
const metaInitial = {
|
const metaInitial = {
|
||||||
cacheRemoteFiles: true,
|
cacheRemoteFiles: true,
|
||||||
cacheRemoteSensitiveFiles: true,
|
cacheRemoteSensitiveFiles: true,
|
||||||
|
enableFanoutTimeline: true,
|
||||||
perUserHomeTimelineCacheMax: 100,
|
perUserHomeTimelineCacheMax: 100,
|
||||||
perLocalUserUserTimelineCacheMax: 100,
|
perLocalUserUserTimelineCacheMax: 100,
|
||||||
perRemoteUserUserTimelineCacheMax: 100,
|
perRemoteUserUserTimelineCacheMax: 100,
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
"@tabler/icons-webfont": "2.37.0",
|
"@tabler/icons-webfont": "2.37.0",
|
||||||
"@vitejs/plugin-vue": "4.4.0",
|
"@vitejs/plugin-vue": "4.4.0",
|
||||||
"@vue-macros/reactivity-transform": "0.3.23",
|
"@vue-macros/reactivity-transform": "0.3.23",
|
||||||
"@vue/compiler-sfc": "3.3.5",
|
"@vue/compiler-sfc": "3.3.6",
|
||||||
"astring": "1.8.6",
|
"astring": "1.8.6",
|
||||||
"autosize": "6.0.1",
|
"autosize": "6.0.1",
|
||||||
"broadcast-channel": "5.5.0",
|
"broadcast-channel": "5.5.0",
|
||||||
@ -73,7 +73,7 @@
|
|||||||
"v-code-diff": "1.7.1",
|
"v-code-diff": "1.7.1",
|
||||||
"vanilla-tilt": "1.8.1",
|
"vanilla-tilt": "1.8.1",
|
||||||
"vite": "4.5.0",
|
"vite": "4.5.0",
|
||||||
"vue": "3.3.5",
|
"vue": "3.3.6",
|
||||||
"vue-prism-editor": "2.0.0-alpha.2",
|
"vue-prism-editor": "2.0.0-alpha.2",
|
||||||
"vuedraggable": "next"
|
"vuedraggable": "next"
|
||||||
},
|
},
|
||||||
@ -112,11 +112,11 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"@vitest/coverage-v8": "0.34.6",
|
"@vitest/coverage-v8": "0.34.6",
|
||||||
"@vue/runtime-core": "3.3.5",
|
"@vue/runtime-core": "3.3.6",
|
||||||
"acorn": "8.10.0",
|
"acorn": "8.10.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "13.3.2",
|
"cypress": "13.3.2",
|
||||||
"eslint": "8.51.0",
|
"eslint": "8.52.0",
|
||||||
"eslint-plugin-import": "2.28.1",
|
"eslint-plugin-import": "2.28.1",
|
||||||
"eslint-plugin-vue": "9.17.0",
|
"eslint-plugin-vue": "9.17.0",
|
||||||
"fast-glob": "3.3.1",
|
"fast-glob": "3.3.1",
|
||||||
|
@ -175,7 +175,7 @@ export async function common(createVue: () => App<Element>) {
|
|||||||
defaultStore.set('darkMode', isDeviceDarkmode());
|
defaultStore.set('darkMode', isDeviceDarkmode());
|
||||||
}
|
}
|
||||||
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addListener(mql => {
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
|
||||||
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
if (ColdDeviceStorage.get('syncDeviceDarkMode')) {
|
||||||
defaultStore.set('darkMode', mql.matches);
|
defaultStore.set('darkMode', mql.matches);
|
||||||
}
|
}
|
||||||
|
@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root">
|
<div :class="$style.root">
|
||||||
<MkAvatar :class="$style.avatar" :user="$i" link preview/>
|
<MkAvatar :class="$style.avatar" :user="user" link preview/>
|
||||||
<div :class="$style.main">
|
<div :class="$style.main">
|
||||||
<div :class="$style.header">
|
<div :class="$style.header">
|
||||||
<MkUserName :user="$i" :nowrap="true"/>
|
<MkUserName :user="user" :nowrap="true"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<Mfm :text="text.trim()" :author="$i" :i="$i"/>
|
<Mfm :text="text.trim()" :author="user" :i="user"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -21,10 +21,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { } from 'vue';
|
import { } from 'vue';
|
||||||
import { $i } from '@/account.js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
text: string;
|
text: string;
|
||||||
|
user: Misskey.entities.User;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
|
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :user="postAccount ?? $i"/>
|
||||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||||
</div>
|
</div>
|
||||||
<footer :class="$style.footer">
|
<footer :class="$style.footer">
|
||||||
|
@ -34,6 +34,7 @@ const props = withDefaults(defineProps<{
|
|||||||
textConverter?: (value: number) => string,
|
textConverter?: (value: number) => string,
|
||||||
showTicks?: boolean;
|
showTicks?: boolean;
|
||||||
easing?: boolean;
|
easing?: boolean;
|
||||||
|
continuousUpdate?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
step: 1,
|
step: 1,
|
||||||
textConverter: (v) => v.toString(),
|
textConverter: (v) => v.toString(),
|
||||||
@ -123,6 +124,10 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
|
|||||||
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
|
const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX;
|
||||||
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
|
const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth / 2));
|
||||||
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
|
rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth)));
|
||||||
|
|
||||||
|
if (props.continuousUpdate) {
|
||||||
|
emit('update:modelValue', finalValue.value);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let beforeValue = finalValue.value;
|
let beforeValue = finalValue.value;
|
||||||
|
@ -23,7 +23,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img v-if="decoration || user.avatarDecorations.length > 0" :class="[$style.decoration]" :src="decoration ?? user.avatarDecorations[0].url" alt="">
|
<img
|
||||||
|
v-if="showDecoration && (decoration || user.avatarDecorations.length > 0)"
|
||||||
|
:class="[$style.decoration]"
|
||||||
|
:src="decoration?.url ?? user.avatarDecorations[0].url"
|
||||||
|
:style="{
|
||||||
|
rotate: getDecorationAngle(),
|
||||||
|
scale: getDecorationScale(),
|
||||||
|
}"
|
||||||
|
alt=""
|
||||||
|
>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -48,18 +57,28 @@ const props = withDefaults(defineProps<{
|
|||||||
link?: boolean;
|
link?: boolean;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
indicator?: boolean;
|
indicator?: boolean;
|
||||||
decoration?: string;
|
decoration?: {
|
||||||
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
|
flipV?: boolean;
|
||||||
|
};
|
||||||
|
forceShowDecoration?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
target: null,
|
target: null,
|
||||||
link: false,
|
link: false,
|
||||||
preview: false,
|
preview: false,
|
||||||
indicator: false,
|
indicator: false,
|
||||||
|
decoration: undefined,
|
||||||
|
forceShowDecoration: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'click', v: MouseEvent): void;
|
(ev: 'click', v: MouseEvent): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showDecoration = props.forceShowDecoration || defaultStore.state.showAvatarDecorations;
|
||||||
|
|
||||||
const bound = $computed(() => props.link
|
const bound = $computed(() => props.link
|
||||||
? { to: userPage(props.user), target: props.target }
|
? { to: userPage(props.user), target: props.target }
|
||||||
: {});
|
: {});
|
||||||
@ -73,6 +92,30 @@ function onClick(ev: MouseEvent): void {
|
|||||||
emit('click', ev);
|
emit('click', ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDecorationAngle() {
|
||||||
|
let angle;
|
||||||
|
if (props.decoration) {
|
||||||
|
angle = props.decoration.angle ?? 0;
|
||||||
|
} else if (props.user.avatarDecorations.length > 0) {
|
||||||
|
angle = props.user.avatarDecorations[0].angle ?? 0;
|
||||||
|
} else {
|
||||||
|
angle = 0;
|
||||||
|
}
|
||||||
|
return angle === 0 ? undefined : `${angle * 360}deg`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDecorationScale() {
|
||||||
|
let scaleX;
|
||||||
|
if (props.decoration) {
|
||||||
|
scaleX = props.decoration.flipH ? -1 : 1;
|
||||||
|
} else if (props.user.avatarDecorations.length > 0) {
|
||||||
|
scaleX = props.user.avatarDecorations[0].flipH ? -1 : 1;
|
||||||
|
} else {
|
||||||
|
scaleX = 1;
|
||||||
|
}
|
||||||
|
return scaleX === 1 ? undefined : `${scaleX} 1`;
|
||||||
|
}
|
||||||
|
|
||||||
let color = $ref<string | undefined>();
|
let color = $ref<string | undefined>();
|
||||||
|
|
||||||
watch(() => props.user.avatarBlurhash, () => {
|
watch(() => props.user.avatarBlurhash, () => {
|
||||||
|
@ -87,9 +87,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
<FormSection>
|
<FormSection>
|
||||||
<template #label>Timeline caching</template>
|
<template #label>Misskey® Fan-out Timeline Technology™ (FTT)</template>
|
||||||
|
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
|
<MkSwitch v-model="enableFanoutTimeline">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
|
||||||
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
|
<MkInput v-model="perLocalUserUserTimelineCacheMax" type="number">
|
||||||
<template #label>perLocalUserUserTimelineCacheMax</template>
|
<template #label>perLocalUserUserTimelineCacheMax</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
@ -165,6 +170,7 @@ let cacheRemoteSensitiveFiles: boolean = $ref(false);
|
|||||||
let enableServiceWorker: boolean = $ref(false);
|
let enableServiceWorker: boolean = $ref(false);
|
||||||
let swPublicKey: any = $ref(null);
|
let swPublicKey: any = $ref(null);
|
||||||
let swPrivateKey: any = $ref(null);
|
let swPrivateKey: any = $ref(null);
|
||||||
|
let enableFanoutTimeline: boolean = $ref(false);
|
||||||
let perLocalUserUserTimelineCacheMax: number = $ref(0);
|
let perLocalUserUserTimelineCacheMax: number = $ref(0);
|
||||||
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
|
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
|
||||||
let perUserHomeTimelineCacheMax: number = $ref(0);
|
let perUserHomeTimelineCacheMax: number = $ref(0);
|
||||||
@ -185,6 +191,7 @@ async function init(): Promise<void> {
|
|||||||
enableServiceWorker = meta.enableServiceWorker;
|
enableServiceWorker = meta.enableServiceWorker;
|
||||||
swPublicKey = meta.swPublickey;
|
swPublicKey = meta.swPublickey;
|
||||||
swPrivateKey = meta.swPrivateKey;
|
swPrivateKey = meta.swPrivateKey;
|
||||||
|
enableFanoutTimeline = meta.enableFanoutTimeline;
|
||||||
perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax;
|
perLocalUserUserTimelineCacheMax = meta.perLocalUserUserTimelineCacheMax;
|
||||||
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
|
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
|
||||||
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
|
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
|
||||||
@ -206,6 +213,7 @@ async function save(): void {
|
|||||||
enableServiceWorker,
|
enableServiceWorker,
|
||||||
swPublicKey,
|
swPublicKey,
|
||||||
swPrivateKey,
|
swPrivateKey,
|
||||||
|
enableFanoutTimeline,
|
||||||
perLocalUserUserTimelineCacheMax,
|
perLocalUserUserTimelineCacheMax,
|
||||||
perRemoteUserUserTimelineCacheMax,
|
perRemoteUserUserTimelineCacheMax,
|
||||||
perUserHomeTimelineCacheMax,
|
perUserHomeTimelineCacheMax,
|
||||||
|
@ -94,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
import { ref, computed, onActivated, onDeactivated, nextTick } from 'vue';
|
||||||
import MkLoading from '@/components/global/MkLoading.vue';
|
import MkLoading from '@/components/global/MkLoading.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import FormSection from '@/components/form/section.vue';
|
import FormSection from '@/components/form/section.vue';
|
||||||
@ -120,9 +120,8 @@ const errorKV = ref<{
|
|||||||
description: '',
|
description: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const url = ref<string | null>(null);
|
||||||
const url = urlParams.get('url');
|
const hash = ref<string | null>(null);
|
||||||
const hash = urlParams.get('hash');
|
|
||||||
|
|
||||||
const data = ref<{
|
const data = ref<{
|
||||||
type: 'plugin' | 'theme';
|
type: 'plugin' | 'theme';
|
||||||
@ -152,7 +151,7 @@ function goToMisskey(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetch() {
|
async function fetch() {
|
||||||
if (!url || !hash) {
|
if (!url.value || !hash.value) {
|
||||||
errorKV.value = {
|
errorKV.value = {
|
||||||
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
|
title: i18n.ts._externalResourceInstaller._errors._invalidParams.title,
|
||||||
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
|
description: i18n.ts._externalResourceInstaller._errors._invalidParams.description,
|
||||||
@ -161,8 +160,8 @@ async function fetch() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await os.api('fetch-external-resources', {
|
const res = await os.api('fetch-external-resources', {
|
||||||
url,
|
url: url.value,
|
||||||
hash,
|
hash: hash.value,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
switch (err.id) {
|
switch (err.id) {
|
||||||
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
|
case 'bb774091-7a15-4a70-9dc5-6ac8cf125856':
|
||||||
@ -240,7 +239,7 @@ async function fetch() {
|
|||||||
description: i18n.ts._theme.alreadyInstalled,
|
description: i18n.ts._theme.alreadyInstalled,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
errorKV.value = {
|
errorKV.value = {
|
||||||
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
|
title: i18n.ts._externalResourceInstaller._errors._themeParseFailed.title,
|
||||||
@ -297,10 +296,17 @@ async function install() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onActivated(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
url.value = urlParams.get('url');
|
||||||
|
hash.value = urlParams.get('hash');
|
||||||
fetch();
|
fetch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
uiPhase.value = 'fetching';
|
||||||
|
});
|
||||||
|
|
||||||
const headerActions = computed(() => []);
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
const headerTabs = computed(() => []);
|
const headerTabs = computed(() => []);
|
||||||
|
@ -30,8 +30,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
<MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch>
|
||||||
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
|
<MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch>
|
||||||
<MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch>
|
<MkSwitch v-model="defaultWithReplies">{{ i18n.ts.withRepliesByDefaultForNewlyFollowed }}</MkSwitch>
|
||||||
<MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
|
|
||||||
<MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
|
|
||||||
<MkFolder>
|
<MkFolder>
|
||||||
<template #label>{{ i18n.ts.pinnedList }}</template>
|
<template #label>{{ i18n.ts.pinnedList }}</template>
|
||||||
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
|
<!-- 複数ピン止め管理できるようにしたいけどめんどいので一旦ひとつのみ -->
|
||||||
@ -119,6 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch>
|
<MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch>
|
||||||
<MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch>
|
<MkSwitch v-model="highlightSensitiveMedia">{{ i18n.ts.highlightSensitiveMedia }}</MkSwitch>
|
||||||
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
|
<MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch>
|
||||||
|
<MkSwitch v-model="showAvatarDecorations">{{ i18n.ts.showAvatarDecorations }}</MkSwitch>
|
||||||
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
<MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch>
|
||||||
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
<MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch>
|
||||||
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
<MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch>
|
||||||
@ -203,7 +202,7 @@ import { unisonReload } from '@/scripts/unison-reload.js';
|
|||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { globalEvents } from '@/events';
|
import { globalEvents } from '@/events.js';
|
||||||
import { claimAchievement } from '@/scripts/achievements.js';
|
import { claimAchievement } from '@/scripts/achievements.js';
|
||||||
|
|
||||||
const lang = ref(miLocalStorage.getItem('lang'));
|
const lang = ref(miLocalStorage.getItem('lang'));
|
||||||
@ -248,6 +247,7 @@ const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker'))
|
|||||||
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
|
const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll'));
|
||||||
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
|
const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu'));
|
||||||
const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
|
const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars'));
|
||||||
|
const showAvatarDecorations = computed(defaultStore.makeGetterSetter('showAvatarDecorations'));
|
||||||
const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
|
const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance'));
|
||||||
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
|
const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition'));
|
||||||
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis'));
|
||||||
@ -334,15 +334,6 @@ async function setPinnedList() {
|
|||||||
defaultStore.set('pinnedUserLists', [list]);
|
defaultStore.set('pinnedUserLists', [list]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRepliesAll(withReplies: boolean) {
|
|
||||||
const { canceled } = os.confirm({
|
|
||||||
type: 'warning',
|
|
||||||
text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll,
|
|
||||||
});
|
|
||||||
if (canceled) return;
|
|
||||||
await os.api('following/update-all', { withReplies });
|
|
||||||
}
|
|
||||||
|
|
||||||
function removePinnedList() {
|
function removePinnedList() {
|
||||||
defaultStore.set('pinnedUserLists', []);
|
defaultStore.set('pinnedUserLists', []);
|
||||||
}
|
}
|
||||||
|
@ -73,6 +73,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<FormSection>
|
<FormSection>
|
||||||
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
|
<FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkButton danger @click="updateRepliesAll(true)"><i class="ti ti-messages"></i> {{ i18n.ts.showRepliesToOthersInTimelineAll }}</MkButton>
|
||||||
|
<MkButton danger @click="updateRepliesAll(false)"><i class="ti ti-messages-off"></i> {{ i18n.ts.hideRepliesToOthersInTimelineAll }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -138,6 +145,15 @@ async function reloadAsk() {
|
|||||||
unisonReload();
|
unisonReload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateRepliesAll(withReplies: boolean) {
|
||||||
|
const { canceled } = os.confirm({
|
||||||
|
type: 'warning',
|
||||||
|
text: withReplies ? i18n.ts.confirmShowRepliesAll : i18n.ts.confirmHideRepliesAll,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
await os.api('following/update-all', { withReplies });
|
||||||
|
}
|
||||||
|
|
||||||
watch([
|
watch([
|
||||||
enableCondensedLineForAcct,
|
enableCondensedLineForAcct,
|
||||||
], async () => {
|
], async () => {
|
||||||
|
@ -0,0 +1,114 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkModalWindow
|
||||||
|
ref="dialog"
|
||||||
|
:width="400"
|
||||||
|
:height="450"
|
||||||
|
@close="cancel"
|
||||||
|
@closed="emit('closed')"
|
||||||
|
>
|
||||||
|
<template #header>{{ i18n.ts.avatarDecorations }}</template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<MkSpacer :marginMin="20" :marginMax="28">
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div :class="$style.name">{{ decoration.name }}</div>
|
||||||
|
<MkAvatar style="width: 64px; height: 64px; margin-bottom: 20px;" :user="$i" :decoration="{ url: decoration.url, angle, flipH }" forceShowDecoration/>
|
||||||
|
</div>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<MkRange v-model="angle" continuousUpdate :min="-0.5" :max="0.5" :step="0.025" :textConverter="(v) => `${Math.floor(v * 360)}°`">
|
||||||
|
<template #label>{{ i18n.ts.angle }}</template>
|
||||||
|
</MkRange>
|
||||||
|
<MkSwitch v-model="flipH">
|
||||||
|
<template #label>{{ i18n.ts.flip }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</div>
|
||||||
|
</MkSpacer>
|
||||||
|
|
||||||
|
<div :class="$style.footer" class="_buttonsCenter">
|
||||||
|
<MkButton v-if="using" primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.update }}</MkButton>
|
||||||
|
<MkButton v-if="using" rounded @click="detach"><i class="ti ti-x"></i> {{ i18n.ts.detach }}</MkButton>
|
||||||
|
<MkButton v-else primary rounded @click="attach"><i class="ti ti-check"></i> {{ i18n.ts.attach }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</MkModalWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { shallowRef, ref, computed } from 'vue';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
|
import MkRange from '@/components/MkRange.vue';
|
||||||
|
import { $i } from '@/account.js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
decoration: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(ev: 'closed'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||||
|
const using = computed(() => $i.avatarDecorations.some(x => x.id === props.decoration.id));
|
||||||
|
const angle = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).angle ?? 0 : 0);
|
||||||
|
const flipH = ref(using.value ? $i.avatarDecorations.find(x => x.id === props.decoration.id).flipH ?? false : false);
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach() {
|
||||||
|
const decoration = {
|
||||||
|
id: props.decoration.id,
|
||||||
|
angle: angle.value,
|
||||||
|
flipH: flipH.value,
|
||||||
|
};
|
||||||
|
await os.apiWithDialog('i/update', {
|
||||||
|
avatarDecorations: [decoration],
|
||||||
|
});
|
||||||
|
$i.avatarDecorations = [decoration];
|
||||||
|
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach() {
|
||||||
|
await os.apiWithDialog('i/update', {
|
||||||
|
avatarDecorations: [],
|
||||||
|
});
|
||||||
|
$i.avatarDecorations = [];
|
||||||
|
|
||||||
|
dialog.value.close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.name {
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: solid 0.5px var(--divider);
|
||||||
|
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||||
|
backdrop-filter: var(--blur, blur(15px));
|
||||||
|
}
|
||||||
|
</style>
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
|
<div :class="$style.avatarAndBanner" :style="{ backgroundImage: $i.bannerUrl ? `url(${ $i.bannerUrl })` : null }">
|
||||||
<div :class="$style.avatarContainer">
|
<div :class="$style.avatarContainer">
|
||||||
<MkAvatar :class="$style.avatar" :user="$i" @click="changeAvatar"/>
|
<MkAvatar :class="$style.avatar" :user="$i" forceShowDecoration @click="changeAvatar"/>
|
||||||
<MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
|
<MkButton primary rounded @click="changeAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
|
<MkButton primary rounded :class="$style.bannerEdit" @click="changeBanner">{{ i18n.ts._profile.changeBanner }}</MkButton>
|
||||||
@ -92,10 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
v-for="avatarDecoration in avatarDecorations"
|
v-for="avatarDecoration in avatarDecorations"
|
||||||
:key="avatarDecoration.id"
|
:key="avatarDecoration.id"
|
||||||
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
|
:class="[$style.avatarDecoration, { [$style.avatarDecorationActive]: $i.avatarDecorations.some(x => x.id === avatarDecoration.id) }]"
|
||||||
@click="toggleDecoration(avatarDecoration)"
|
@click="openDecoration(avatarDecoration)"
|
||||||
>
|
>
|
||||||
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
|
<div :class="$style.avatarDecorationName"><MkCondensedLine :minScale="2 / 3">{{ avatarDecoration.name }}</MkCondensedLine></div>
|
||||||
<MkAvatar style="width: 64px; height: 64px;" :user="$i" :decoration="avatarDecoration.url"/>
|
<MkAvatar style="width: 60px; height: 60px;" :user="$i" :decoration="{ url: avatarDecoration.url }" forceShowDecoration/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
@ -266,18 +266,10 @@ function changeBanner(ev) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDecoration(avatarDecoration) {
|
function openDecoration(avatarDecoration) {
|
||||||
if ($i.avatarDecorations.some(x => x.id === avatarDecoration.id)) {
|
os.popup(defineAsyncComponent(() => import('./profile.avatar-decoration-dialog.vue')), {
|
||||||
os.apiWithDialog('i/update', {
|
decoration: avatarDecoration,
|
||||||
avatarDecorations: [],
|
}, {}, 'closed');
|
||||||
});
|
|
||||||
$i.avatarDecorations = [];
|
|
||||||
} else {
|
|
||||||
os.apiWithDialog('i/update', {
|
|
||||||
avatarDecorations: [avatarDecoration.id],
|
|
||||||
});
|
|
||||||
$i.avatarDecorations.push(avatarDecoration);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions = $computed(() => []);
|
const headerActions = $computed(() => []);
|
||||||
|
@ -293,6 +293,10 @@ export const defaultStore = markRaw(new Storage('base', {
|
|||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
showAvatarDecorations: {
|
||||||
|
where: 'device',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
postFormWithHashtags: {
|
postFormWithHashtags: {
|
||||||
where: 'device',
|
where: 'device',
|
||||||
default: false,
|
default: false,
|
||||||
|
@ -58,9 +58,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||||
import { openInstanceMenu } from './common';
|
import { openInstanceMenu } from './common.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { navbarItemDef } from '@/navbar';
|
import { navbarItemDef } from '@/navbar.js';
|
||||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
@ -230,6 +230,7 @@ function more(ev: MouseEvent) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
@ -401,6 +402,7 @@ function more(ev: MouseEvent) {
|
|||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
|
@ -2996,6 +2996,8 @@ type UserLite = {
|
|||||||
avatarDecorations: {
|
avatarDecorations: {
|
||||||
id: ID;
|
id: ID;
|
||||||
url: string;
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
}[];
|
}[];
|
||||||
emojis: {
|
emojis: {
|
||||||
name: string;
|
name: string;
|
||||||
@ -3021,8 +3023,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
|
|||||||
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
|
||||||
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
|
||||||
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
// src/api.types.ts:633:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
|
||||||
// src/entities.ts:113:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
|
// src/entities.ts:115:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
|
||||||
// src/entities.ts:609:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
// src/entities.ts:611:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
|
||||||
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
|
||||||
|
|
||||||
// (No @packageDocumentation comment for this package)
|
// (No @packageDocumentation comment for this package)
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
"@types/node": "20.8.7",
|
"@types/node": "20.8.7",
|
||||||
"@typescript-eslint/eslint-plugin": "6.8.0",
|
"@typescript-eslint/eslint-plugin": "6.8.0",
|
||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"eslint": "8.51.0",
|
"eslint": "8.52.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-fetch-mock": "3.0.3",
|
"jest-fetch-mock": "3.0.3",
|
||||||
"jest-websocket-mock": "2.5.0",
|
"jest-websocket-mock": "2.5.0",
|
||||||
@ -39,7 +39,7 @@
|
|||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/cli": "0.1.62",
|
"@swc/cli": "0.1.62",
|
||||||
"@swc/core": "1.3.93",
|
"@swc/core": "1.3.94",
|
||||||
"eventemitter3": "5.0.1",
|
"eventemitter3": "5.0.1",
|
||||||
"reconnecting-websocket": "4.4.0"
|
"reconnecting-websocket": "4.4.0"
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,8 @@ export type UserLite = {
|
|||||||
avatarDecorations: {
|
avatarDecorations: {
|
||||||
id: ID;
|
id: ID;
|
||||||
url: string;
|
url: string;
|
||||||
|
angle?: number;
|
||||||
|
flipH?: boolean;
|
||||||
}[];
|
}[];
|
||||||
emojis: {
|
emojis: {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@typescript-eslint/parser": "6.8.0",
|
"@typescript-eslint/parser": "6.8.0",
|
||||||
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
|
||||||
"eslint": "8.51.0",
|
"eslint": "8.52.0",
|
||||||
"eslint-plugin-import": "2.28.1",
|
"eslint-plugin-import": "2.28.1",
|
||||||
"typescript": "5.2.2"
|
"typescript": "5.2.2"
|
||||||
},
|
},
|
||||||
|
604
pnpm-lock.yaml
604
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user