Merge remote-tracking branch 'misskey-original/develop' into develop
# Conflicts: # package.json # packages/backend/src/server/api/endpoints/notes/global-timeline.ts # packages/backend/src/server/api/endpoints/notes/local-timeline.ts # packages/backend/src/server/api/endpoints/notes/timeline.ts # packages/misskey-js/etc/misskey-js.api.md
This commit is contained in:
commit
b09bfd5ad2
|
@ -19,13 +19,14 @@
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
|
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
|
||||||
- API: notes/global-timeline は現在常に `[]` を返します
|
|
||||||
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
|
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
|
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
|
||||||
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
|
- Feat: ユーザーリスト内のメンバーごとに他ユーザーへの返信をユーザーリストタイムラインに含めるか設定可能になりました
|
||||||
- Feat: ユーザーごとのハイライト
|
- Feat: ユーザーごとのハイライト
|
||||||
|
- Feat: プライバシーポリシー・運営者情報(Impressum)の指定が可能になりました
|
||||||
|
- プライバシーポリシーはサーバー登録時に同意確認が入ります
|
||||||
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
|
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
|
||||||
- Enhance: モデレーションログ機能の強化
|
- Enhance: モデレーションログ機能の強化
|
||||||
- Enhance: ローカリゼーションの更新
|
- Enhance: ローカリゼーションの更新
|
||||||
|
@ -40,7 +41,9 @@
|
||||||
### Server
|
### Server
|
||||||
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上
|
- Enhance: タイムライン取得時のパフォーマンスを大幅に向上
|
||||||
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上
|
- Enhance: ハイライト取得時のパフォーマンスを大幅に向上
|
||||||
|
- Enhance: トレンドハッシュタグ取得時のパフォーマンスを大幅に向上
|
||||||
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
|
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
|
||||||
|
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
|
||||||
|
|
||||||
## 2023.9.3
|
## 2023.9.3
|
||||||
### General
|
### General
|
||||||
|
|
|
@ -1152,6 +1152,12 @@ export interface Locale {
|
||||||
"showRepliesToOthersInTimeline": string;
|
"showRepliesToOthersInTimeline": string;
|
||||||
"hideRepliesToOthersInTimeline": string;
|
"hideRepliesToOthersInTimeline": string;
|
||||||
"externalServices": string;
|
"externalServices": string;
|
||||||
|
"impressum": string;
|
||||||
|
"impressumUrl": string;
|
||||||
|
"impressumDescription": string;
|
||||||
|
"privacyPolicy": string;
|
||||||
|
"privacyPolicyUrl": string;
|
||||||
|
"tosAndPrivacyPolicy": string;
|
||||||
"_announcement": {
|
"_announcement": {
|
||||||
"forExistingUsers": string;
|
"forExistingUsers": string;
|
||||||
"forExistingUsersDescription": string;
|
"forExistingUsersDescription": string;
|
||||||
|
|
|
@ -1149,6 +1149,12 @@ mutualFollow: "相互フォロー"
|
||||||
showRepliesToOthersInTimeline: "TLに他の人への返信を含める"
|
showRepliesToOthersInTimeline: "TLに他の人への返信を含める"
|
||||||
hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない"
|
hideRepliesToOthersInTimeline: "TLに他の人への返信を含めない"
|
||||||
externalServices: "外部サービス"
|
externalServices: "外部サービス"
|
||||||
|
impressum: "運営者情報"
|
||||||
|
impressumUrl: "運営者情報URL"
|
||||||
|
impressumDescription: "ドイツなどの一部の国と地域では表示が義務付けられています(Impressum)。"
|
||||||
|
privacyPolicy: "プライバシーポリシー"
|
||||||
|
privacyPolicyUrl: "プライバシーポリシーURL"
|
||||||
|
tosAndPrivacyPolicy: "利用規約・プライバシーポリシー"
|
||||||
|
|
||||||
_announcement:
|
_announcement:
|
||||||
forExistingUsers: "既存ユーザーのみ"
|
forExistingUsers: "既存ユーザーのみ"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "misskey",
|
"name": "misskey",
|
||||||
"version": "2023.10.0-beta.5-prismisskey.2",
|
"version": "2023.10.0-beta.7",
|
||||||
"codename": "nasubi",
|
"codename": "nasubi",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AddSomeUrls1696003580220 {
|
||||||
|
name = 'AddSomeUrls1696003580220'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "impressumUrl" character varying(1024)`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" ADD "privacyPolicyUrl" character varying(1024)`);
|
||||||
|
}
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "impressumUrl"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "privacyPolicyUrl"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
|
||||||
|
|
||||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||||
|
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FeaturedService {
|
export class FeaturedService {
|
||||||
|
@ -88,6 +89,11 @@ export class FeaturedService {
|
||||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||||
|
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
|
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
|
||||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
|
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||||
|
@ -102,4 +108,9 @@ export class FeaturedService {
|
||||||
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
|
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
|
||||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
|
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getHashtagsRanking(limit: number): Promise<string[]> {
|
||||||
|
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||||
|
@ -12,15 +13,22 @@ import type { MiHashtag } from '@/models/Hashtag.js';
|
||||||
import type { HashtagsRepository } from '@/models/_.js';
|
import type { HashtagsRepository } from '@/models/_.js';
|
||||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
|
import { MetaService } from '@/core/MetaService.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HashtagService {
|
export class HashtagService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||||
|
|
||||||
@Inject(DI.hashtagsRepository)
|
@Inject(DI.hashtagsRepository)
|
||||||
private hashtagsRepository: HashtagsRepository,
|
private hashtagsRepository: HashtagsRepository,
|
||||||
|
|
||||||
private userEntityService: UserEntityService,
|
private userEntityService: UserEntityService,
|
||||||
|
private featuredService: FeaturedService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
|
private metaService: MetaService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +54,9 @@ export class HashtagService {
|
||||||
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
|
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||||
tag = normalizeForSearch(tag);
|
tag = normalizeForSearch(tag);
|
||||||
|
|
||||||
|
// TODO: サンプリング
|
||||||
|
this.updateHashtagsRanking(tag, user.id);
|
||||||
|
|
||||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||||
|
|
||||||
if (index == null && !inc) return;
|
if (index == null && !inc) return;
|
||||||
|
@ -144,4 +155,95 @@ export class HashtagService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise<void> {
|
||||||
|
const instance = await this.metaService.fetch();
|
||||||
|
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
|
||||||
|
if (hiddenTags.includes(hashtag)) return;
|
||||||
|
|
||||||
|
// YYYYMMDDHHmm (10分間隔)
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||||
|
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
||||||
|
if (exist === 1) return;
|
||||||
|
|
||||||
|
this.featuredService.updateHashtagsRanking(hashtag, 1);
|
||||||
|
|
||||||
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
|
||||||
|
// TODO: これらの Set は Bloom Filter を使うようにしても良さそう
|
||||||
|
|
||||||
|
// チャート用
|
||||||
|
redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||||
|
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
|
||||||
|
60 * 60 * 24 * 3, // 3日間
|
||||||
|
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||||
|
);
|
||||||
|
|
||||||
|
// ユニークカウント用
|
||||||
|
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
|
||||||
|
redisPipeline.expire(`hashtagUsers:${hashtag}`,
|
||||||
|
60 * 60, // 1時間
|
||||||
|
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||||
|
);
|
||||||
|
|
||||||
|
redisPipeline.exec();
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getChart(hashtag: string, range: number): Promise<number[]> {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||||
|
|
||||||
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
|
||||||
|
for (let i = 0; i < range; i++) {
|
||||||
|
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||||
|
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||||
|
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await redisPipeline.exec();
|
||||||
|
|
||||||
|
if (result == null) return [];
|
||||||
|
|
||||||
|
return result.map(x => x[1]) as number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getCharts(hashtags: string[], range: number): Promise<Record<string, number[]>> {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||||
|
|
||||||
|
const redisPipeline = this.redisClient.pipeline();
|
||||||
|
|
||||||
|
for (let i = 0; i < range; i++) {
|
||||||
|
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||||
|
}
|
||||||
|
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await redisPipeline.exec();
|
||||||
|
|
||||||
|
if (result == null) return {};
|
||||||
|
|
||||||
|
// key is hashtag
|
||||||
|
const charts = {} as Record<string, number[]>;
|
||||||
|
for (const hashtag of hashtags) {
|
||||||
|
charts[hashtag] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < range; i++) {
|
||||||
|
for (let j = 0; j < hashtags.length; j++) {
|
||||||
|
charts[hashtags[j]].push(result[(i * hashtags.length) + j][1] as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return charts;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -96,6 +96,8 @@ export class PollService {
|
||||||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||||
if (note == null) throw new Error('note not found');
|
if (note == null) throw new Error('note not found');
|
||||||
|
|
||||||
|
if (note.localOnly) return;
|
||||||
|
|
||||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||||
if (user == null) throw new Error('note not found');
|
if (user == null) throw new Error('note not found');
|
||||||
|
|
||||||
|
|
|
@ -335,6 +335,18 @@ export class MiMeta {
|
||||||
})
|
})
|
||||||
public feedbackUrl: string | null;
|
public feedbackUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 1024,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public impressumUrl: string | null;
|
||||||
|
|
||||||
|
@Column('varchar', {
|
||||||
|
length: 1024,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public privacyPolicyUrl: string | null;
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 8192,
|
length: 8192,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
|
|
|
@ -102,6 +102,8 @@ export class NodeinfoServerService {
|
||||||
},
|
},
|
||||||
langs: meta.langs,
|
langs: meta.langs,
|
||||||
tosUrl: meta.termsOfServiceUrl,
|
tosUrl: meta.termsOfServiceUrl,
|
||||||
|
privacyPolicyUrl: meta.privacyPolicyUrl,
|
||||||
|
impressumUrl: meta.impressumUrl,
|
||||||
repositoryUrl: meta.repositoryUrl,
|
repositoryUrl: meta.repositoryUrl,
|
||||||
feedbackUrl: meta.feedbackUrl,
|
feedbackUrl: meta.feedbackUrl,
|
||||||
disableRegistration: meta.disableRegistration,
|
disableRegistration: meta.disableRegistration,
|
||||||
|
|
|
@ -331,6 +331,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
tosUrl: instance.termsOfServiceUrl,
|
tosUrl: instance.termsOfServiceUrl,
|
||||||
repositoryUrl: instance.repositoryUrl,
|
repositoryUrl: instance.repositoryUrl,
|
||||||
feedbackUrl: instance.feedbackUrl,
|
feedbackUrl: instance.feedbackUrl,
|
||||||
|
impressumUrl: instance.impressumUrl,
|
||||||
|
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||||
disableRegistration: instance.disableRegistration,
|
disableRegistration: instance.disableRegistration,
|
||||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||||
enableHcaptcha: instance.enableHcaptcha,
|
enableHcaptcha: instance.enableHcaptcha,
|
||||||
|
|
|
@ -86,6 +86,8 @@ export const paramDef = {
|
||||||
tosUrl: { type: 'string', nullable: true },
|
tosUrl: { type: 'string', nullable: true },
|
||||||
repositoryUrl: { type: 'string' },
|
repositoryUrl: { type: 'string' },
|
||||||
feedbackUrl: { type: 'string' },
|
feedbackUrl: { type: 'string' },
|
||||||
|
impressumUrl: { type: 'string' },
|
||||||
|
privacyPolicyUrl: { type: 'string' },
|
||||||
useObjectStorage: { type: 'boolean' },
|
useObjectStorage: { type: 'boolean' },
|
||||||
objectStorageBaseUrl: { type: 'string', nullable: true },
|
objectStorageBaseUrl: { type: 'string', nullable: true },
|
||||||
objectStorageBucket: { type: 'string', nullable: true },
|
objectStorageBucket: { type: 'string', nullable: true },
|
||||||
|
@ -345,6 +347,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
set.feedbackUrl = ps.feedbackUrl;
|
set.feedbackUrl = ps.feedbackUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ps.impressumUrl !== undefined) {
|
||||||
|
set.impressumUrl = ps.impressumUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.privacyPolicyUrl !== undefined) {
|
||||||
|
set.privacyPolicyUrl = ps.privacyPolicyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
if (ps.useObjectStorage !== undefined) {
|
if (ps.useObjectStorage !== undefined) {
|
||||||
set.useObjectStorage = ps.useObjectStorage;
|
set.useObjectStorage = ps.useObjectStorage;
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,17 +86,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
});
|
});
|
||||||
|
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
const noteIdsRes = await this.redisForTimelines.xrevrange(
|
|
||||||
|
const noteIds = await this.redisForTimelines.xrevrange(
|
||||||
`antennaTimeline:${antenna.id}`,
|
`antennaTimeline:${antenna.id}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit);
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId));
|
||||||
if (noteIdsRes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
|
||||||
|
|
||||||
if (noteIds.length === 0) {
|
if (noteIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
@ -3,29 +3,11 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Brackets } from 'typeorm';
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import type { NotesRepository } from '@/models/_.js';
|
|
||||||
import type { MiNote } from '@/models/Note.js';
|
|
||||||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
|
||||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
|
||||||
import { MetaService } from '@/core/MetaService.js';
|
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||||
/*
|
import { HashtagService } from '@/core/HashtagService.js';
|
||||||
トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要
|
|
||||||
ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる
|
|
||||||
|
|
||||||
..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する
|
|
||||||
*/
|
|
||||||
|
|
||||||
const rangeA = 1000 * 60 * 60; // 60分
|
|
||||||
//const rangeB = 1000 * 60 * 120; // 2時間
|
|
||||||
//const coefficient = 1.25; // 「n倍」の部分
|
|
||||||
//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか
|
|
||||||
|
|
||||||
const max = 5;
|
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['hashtags'],
|
tags: ['hashtags'],
|
||||||
|
@ -71,98 +53,18 @@ export const paramDef = {
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DI.notesRepository)
|
private featuredService: FeaturedService,
|
||||||
private notesRepository: NotesRepository,
|
private hashtagService: HashtagService,
|
||||||
|
|
||||||
private metaService: MetaService,
|
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async () => {
|
super(meta, paramDef, async () => {
|
||||||
const instance = await this.metaService.fetch(true);
|
const ranking = await this.featuredService.getHashtagsRanking(10);
|
||||||
const hiddenTags = instance.hiddenTags.map(t => normalizeForSearch(t));
|
|
||||||
|
|
||||||
const now = new Date(); // 5分単位で丸めた現在日時
|
const charts = ranking.length === 0 ? {} : await this.hashtagService.getCharts(ranking, 20);
|
||||||
now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0);
|
|
||||||
|
|
||||||
const tagNotes = await this.notesRepository.createQueryBuilder('note')
|
const stats = ranking.map((tag, i) => ({
|
||||||
.where('note.createdAt > :date', { date: new Date(now.getTime() - rangeA) })
|
|
||||||
.andWhere(new Brackets(qb => { qb
|
|
||||||
.where('note.visibility = \'public\'')
|
|
||||||
.orWhere('note.visibility = \'home\'');
|
|
||||||
}))
|
|
||||||
.andWhere('note.tags != \'{}\'')
|
|
||||||
.select(['note.tags', 'note.userId'])
|
|
||||||
.cache(60000) // 1 min
|
|
||||||
.getMany();
|
|
||||||
|
|
||||||
if (tagNotes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags: {
|
|
||||||
name: string;
|
|
||||||
users: MiNote['userId'][];
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
for (const note of tagNotes) {
|
|
||||||
for (const tag of note.tags) {
|
|
||||||
if (hiddenTags.includes(tag)) continue;
|
|
||||||
|
|
||||||
const x = tags.find(x => x.name === tag);
|
|
||||||
if (x) {
|
|
||||||
if (!x.users.includes(note.userId)) {
|
|
||||||
x.users.push(note.userId);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tags.push({
|
|
||||||
name: tag,
|
|
||||||
users: [note.userId],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// タグを人気順に並べ替え
|
|
||||||
const hots = tags
|
|
||||||
.sort((a, b) => b.users.length - a.users.length)
|
|
||||||
.map(tag => tag.name)
|
|
||||||
.slice(0, max);
|
|
||||||
|
|
||||||
//#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する
|
|
||||||
const countPromises: Promise<number[]>[] = [];
|
|
||||||
|
|
||||||
const range = 20;
|
|
||||||
|
|
||||||
// 10分
|
|
||||||
const interval = 1000 * 60 * 10;
|
|
||||||
|
|
||||||
for (let i = 0; i < range; i++) {
|
|
||||||
countPromises.push(Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
|
|
||||||
.select('count(distinct note.userId)')
|
|
||||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
|
||||||
.andWhere('note.createdAt < :lt', { lt: new Date(now.getTime() - (interval * i)) })
|
|
||||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - (interval * (i + 1))) })
|
|
||||||
.cache(60000) // 1 min
|
|
||||||
.getRawOne()
|
|
||||||
.then(x => parseInt(x.count, 10)),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
const countsLog = await Promise.all(countPromises);
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
const totalCounts = await Promise.all(hots.map(tag => this.notesRepository.createQueryBuilder('note')
|
|
||||||
.select('count(distinct note.userId)')
|
|
||||||
.where(`'{"${safeForSql(tag) ? tag : 'aichan_kawaii'}"}' <@ note.tags`)
|
|
||||||
.andWhere('note.createdAt > :gt', { gt: new Date(now.getTime() - rangeA) })
|
|
||||||
.cache(60000 * 60) // 60 min
|
|
||||||
.getRawOne()
|
|
||||||
.then(x => parseInt(x.count, 10)),
|
|
||||||
));
|
|
||||||
|
|
||||||
const stats = hots.map((tag, i) => ({
|
|
||||||
tag,
|
tag,
|
||||||
chart: countsLog.map(counts => counts[i]),
|
chart: charts[tag],
|
||||||
usersCount: totalCounts[i],
|
usersCount: Math.max(...charts[tag]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
|
|
|
@ -299,6 +299,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
tosUrl: instance.termsOfServiceUrl,
|
tosUrl: instance.termsOfServiceUrl,
|
||||||
repositoryUrl: instance.repositoryUrl,
|
repositoryUrl: instance.repositoryUrl,
|
||||||
feedbackUrl: instance.feedbackUrl,
|
feedbackUrl: instance.feedbackUrl,
|
||||||
|
impressumUrl: instance.impressumUrl,
|
||||||
|
privacyPolicyUrl: instance.privacyPolicyUrl,
|
||||||
disableRegistration: instance.disableRegistration,
|
disableRegistration: instance.disableRegistration,
|
||||||
emailRequiredForSignup: instance.emailRequiredForSignup,
|
emailRequiredForSignup: instance.emailRequiredForSignup,
|
||||||
enableHcaptcha: instance.enableHcaptcha,
|
enableHcaptcha: instance.enableHcaptcha,
|
||||||
|
|
|
@ -50,7 +50,6 @@ export const paramDef = {
|
||||||
required: [],
|
required: [],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
constructor(
|
constructor(
|
||||||
|
|
|
@ -97,26 +97,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
let timeline: MiNote[] = [];
|
let timeline: MiNote[] = [];
|
||||||
|
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
let htlNoteIdsRes: [string, string[]][] = [];
|
|
||||||
let ltlNoteIdsRes: [string, string[]][] = [];
|
|
||||||
|
|
||||||
if (!ps.sinceId && !ps.sinceDate) {
|
const [htlNoteIds, ltlNoteIds] = await Promise.all([
|
||||||
[htlNoteIdsRes, ltlNoteIdsRes] = await Promise.all([
|
|
||||||
this.redisForTimelines.xrevrange(
|
this.redisForTimelines.xrevrange(
|
||||||
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
|
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit),
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
|
||||||
this.redisForTimelines.xrevrange(
|
this.redisForTimelines.xrevrange(
|
||||||
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
|
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit),
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
const htlNoteIds = htlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
|
||||||
const ltlNoteIds = ltlNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
|
||||||
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
|
|
|
@ -103,17 +103,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
let timeline: MiNote[] = [];
|
let timeline: MiNote[] = [];
|
||||||
|
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
let noteIdsRes: [string, string[]][] = [];
|
|
||||||
|
|
||||||
if (!ps.sinceId && !ps.sinceDate) {
|
const noteIds = await this.redisForTimelines.xrevrange(
|
||||||
noteIdsRes = await this.redisForTimelines.xrevrange(
|
|
||||||
ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`,
|
ps.withFiles ? `userListTimelineWithFiles:${list.id}` : `userListTimeline:${list.id}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit);
|
'COUNT', limit,
|
||||||
}
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId));
|
||||||
|
|
||||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
|
||||||
|
|
||||||
if (noteIds.length === 0) {
|
if (noteIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
@ -79,17 +79,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
const noteIdsRes = await this.redisForTimelines.xrevrange(
|
|
||||||
|
const noteIds = await this.redisForTimelines.xrevrange(
|
||||||
`roleTimeline:${role.id}`,
|
`roleTimeline:${role.id}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit);
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId));
|
||||||
if (noteIdsRes.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteIds = noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId);
|
|
||||||
|
|
||||||
if (noteIds.length === 0) {
|
if (noteIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
@ -50,16 +50,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
|
let noteIds = await this.featuredService.getPerUserNotesRanking(ps.userId, 50);
|
||||||
|
|
||||||
if (noteIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
if (ps.untilId) {
|
if (ps.untilId) {
|
||||||
noteIds = noteIds.filter(id => id < ps.untilId!);
|
noteIds = noteIds.filter(id => id < ps.untilId!);
|
||||||
}
|
}
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
|
|
||||||
|
if (noteIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const query = this.notesRepository.createQueryBuilder('note')
|
const query = this.notesRepository.createQueryBuilder('note')
|
||||||
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
|
||||||
.innerJoinAndSelect('note.user', 'user')
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
|
|
@ -10,10 +10,10 @@ import type { MiNote, NotesRepository } from '@/models/_.js';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import { GetterService } from '@/server/api/GetterService.js';
|
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||||
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { ApiError } from '../../error.js';
|
import { ApiError } from '../../error.js';
|
||||||
import {QueryService} from "@/core/QueryService.js";
|
import {QueryService} from "@/core/QueryService.js";
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private noteEntityService: NoteEntityService,
|
private noteEntityService: NoteEntityService,
|
||||||
private getterService: GetterService,
|
private queryService: QueryService,
|
||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
) {
|
) {
|
||||||
|
@ -83,38 +83,36 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
let timeline: MiNote[] = [];
|
let timeline: MiNote[] = [];
|
||||||
|
|
||||||
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
|
||||||
let noteIdsRes: [string, string[]][] = [];
|
|
||||||
let repliesNoteIdsRes: [string, string[]][] = [];
|
|
||||||
let channelNoteIdsRes: [string, string[]][] = [];
|
|
||||||
|
|
||||||
if (!ps.sinceId && !ps.sinceDate) {
|
const [noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
||||||
[noteIdsRes, repliesNoteIdsRes, channelNoteIdsRes] = await Promise.all([
|
|
||||||
this.redisForTimelines.xrevrange(
|
this.redisForTimelines.xrevrange(
|
||||||
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`,
|
ps.withFiles ? `userTimelineWithFiles:${ps.userId}` : `userTimeline:${ps.userId}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit),
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
|
||||||
ps.withReplies
|
ps.withReplies
|
||||||
? this.redisForTimelines.xrevrange(
|
? this.redisForTimelines.xrevrange(
|
||||||
`userTimelineWithReplies:${ps.userId}`,
|
`userTimelineWithReplies:${ps.userId}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit)
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
ps.withChannelNotes
|
ps.withChannelNotes
|
||||||
? this.redisForTimelines.xrevrange(
|
? this.redisForTimelines.xrevrange(
|
||||||
`userTimelineWithChannel:${ps.userId}`,
|
`userTimelineWithChannel:${ps.userId}`,
|
||||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
|
||||||
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
|
||||||
'COUNT', limit)
|
'COUNT', limit,
|
||||||
|
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId))
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
let noteIds = Array.from(new Set([
|
let noteIds = Array.from(new Set([
|
||||||
...noteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
...noteIdsRes,
|
||||||
...repliesNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
...repliesNoteIdsRes,
|
||||||
...channelNoteIdsRes.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
|
...channelNoteIdsRes,
|
||||||
]));
|
]));
|
||||||
noteIds.sort((a, b) => a > b ? -1 : 1);
|
noteIds.sort((a, b) => a > b ? -1 : 1);
|
||||||
noteIds = noteIds.slice(0, ps.limit);
|
noteIds = noteIds.slice(0, ps.limit);
|
||||||
|
@ -212,6 +210,43 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
timeline.sort((a, b) => a.id > b.id ? -1 : 1);
|
||||||
|
|
||||||
|
// fallback to database
|
||||||
|
if (timeline.length === 0) {
|
||||||
|
//#region Construct query
|
||||||
|
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
|
||||||
|
.andWhere('note.userId = :userId', { userId: ps.userId })
|
||||||
|
.innerJoinAndSelect('note.user', 'user')
|
||||||
|
.leftJoinAndSelect('note.reply', 'reply')
|
||||||
|
.leftJoinAndSelect('note.renote', 'renote')
|
||||||
|
.leftJoinAndSelect('note.channel', 'channel')
|
||||||
|
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||||
|
.leftJoinAndSelect('renote.user', 'renoteUser');
|
||||||
|
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.channelId IS NULL');
|
||||||
|
qb.orWhere('channel.isSensitive = false');
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.queryService.generateVisibilityQuery(query, me);
|
||||||
|
|
||||||
|
if (ps.withFiles) {
|
||||||
|
query.andWhere('note.fileIds != \'{}\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ps.includeMyRenotes === false) {
|
||||||
|
query.andWhere(new Brackets(qb => {
|
||||||
|
qb.orWhere('note.userId != :userId', { userId: ps.userId });
|
||||||
|
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)');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
//#endregion
|
||||||
|
|
||||||
|
timeline = await query.limit(ps.limit).getMany();
|
||||||
|
}
|
||||||
|
|
||||||
return await this.noteEntityService.packMany(timeline, me);
|
return await this.noteEntityService.packMany(timeline, me);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -301,12 +301,14 @@ export const uploadFile = async (user?: UserToken, { path, name, blob }: UploadO
|
||||||
};
|
};
|
||||||
|
|
||||||
export const uploadUrl = async (user: UserToken, url: string) => {
|
export const uploadUrl = async (user: UserToken, url: string) => {
|
||||||
let file: any;
|
let resolve: unknown;
|
||||||
|
const file = new Promise(ok => resolve = ok);
|
||||||
const marker = Math.random().toString();
|
const marker = Math.random().toString();
|
||||||
|
|
||||||
const ws = await connectStream(user, 'main', (msg) => {
|
const ws = await connectStream(user, 'main', (msg) => {
|
||||||
if (msg.type === 'urlUploadFinished' && msg.body.marker === marker) {
|
if (msg.type === 'urlUploadFinished' && msg.body.marker === marker) {
|
||||||
file = msg.body.file;
|
ws.close();
|
||||||
|
resolve(msg.body.file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -316,9 +318,6 @@ export const uploadUrl = async (user: UserToken, url: string) => {
|
||||||
force: true,
|
force: true,
|
||||||
}, user);
|
}, user);
|
||||||
|
|
||||||
await sleep(7000);
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
return file;
|
return file;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -30,13 +30,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch>
|
<MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="availableTos" :defaultOpen="true">
|
<MkFolder v-if="availableTos || availablePrivacyPolicy" :defaultOpen="true">
|
||||||
<template #label>{{ i18n.ts.termsOfService }}</template>
|
<template #label>{{ tosPrivacyPolicyLabel }}</template>
|
||||||
<template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template>
|
<template #suffix><i v-if="agreeTosAndPrivacyPolicy" class="ti ti-check" style="color: var(--success)"></i></template>
|
||||||
|
<div class="_gaps_s">
|
||||||
|
<div v-if="availableTos"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a></div>
|
||||||
|
<div v-if="availablePrivacyPolicy"><a :href="instance.privacyPolicyUrl" class="_link" target="_blank">{{ i18n.ts.privacyPolicy }} <i class="ti ti-external-link"></i></a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
|
<MkSwitch :modelValue="agreeTosAndPrivacyPolicy" style="margin-top: 16px;" @update:modelValue="updateAgreeTosAndPrivacyPolicy">{{ i18n.ts.agree }}</MkSwitch>
|
||||||
|
|
||||||
<MkSwitch :modelValue="agreeTos" style="margin-top: 16px;" @update:modelValue="updateAgreeTos">{{ i18n.ts.agree }}</MkSwitch>
|
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder :defaultOpen="true">
|
<MkFolder :defaultOpen="true">
|
||||||
|
@ -102,14 +104,15 @@ watch(gamingMode, () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const availableServerRules = instance.serverRules.length > 0;
|
const availableServerRules = instance.serverRules.length > 0;
|
||||||
const availableTos = instance.tosUrl != null;
|
const availableTos = instance.tosUrl != null && instance.tosUrl !== '';
|
||||||
|
const availablePrivacyPolicy = instance.privacyPolicyUrl != null && instance.privacyPolicyUrl !== '';
|
||||||
|
|
||||||
const agreeServerRules = ref(false);
|
const agreeServerRules = ref(false);
|
||||||
const agreeTos = ref(false);
|
const agreeTosAndPrivacyPolicy = ref(false);
|
||||||
const agreeNote = ref(false);
|
const agreeNote = ref(false);
|
||||||
|
|
||||||
const agreed = computed(() => {
|
const agreed = computed(() => {
|
||||||
return (!availableServerRules || agreeServerRules.value) && (!availableTos || agreeTos.value) && agreeNote.value;
|
return (!availableServerRules || agreeServerRules.value) && ((!availableTos && !availablePrivacyPolicy) || agreeTosAndPrivacyPolicy.value) && agreeNote.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -117,6 +120,18 @@ const emit = defineEmits<{
|
||||||
(ev: 'done'): void;
|
(ev: 'done'): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const tosPrivacyPolicyLabel = computed(() => {
|
||||||
|
if (availableTos && availablePrivacyPolicy) {
|
||||||
|
return i18n.ts.tosAndPrivacyPolicy;
|
||||||
|
} else if (availableTos) {
|
||||||
|
return i18n.ts.termsOfService;
|
||||||
|
} else if (availablePrivacyPolicy) {
|
||||||
|
return i18n.ts.privacyPolicy;
|
||||||
|
} else {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function updateAgreeServerRules(v: boolean) {
|
async function updateAgreeServerRules(v: boolean) {
|
||||||
if (v) {
|
if (v) {
|
||||||
const confirm = await os.confirm({
|
const confirm = await os.confirm({
|
||||||
|
@ -131,17 +146,19 @@ async function updateAgreeServerRules(v: boolean) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateAgreeTos(v: boolean) {
|
async function updateAgreeTosAndPrivacyPolicy(v: boolean) {
|
||||||
if (v) {
|
if (v) {
|
||||||
const confirm = await os.confirm({
|
const confirm = await os.confirm({
|
||||||
type: 'question',
|
type: 'question',
|
||||||
title: i18n.ts.doYouAgree,
|
title: i18n.ts.doYouAgree,
|
||||||
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.termsOfService }),
|
text: i18n.t('iHaveReadXCarefullyAndAgree', {
|
||||||
|
x: tosPrivacyPolicyLabel.value,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (confirm.canceled) return;
|
if (confirm.canceled) return;
|
||||||
agreeTos.value = true;
|
agreeTosAndPrivacyPolicy.value = true;
|
||||||
} else {
|
} else {
|
||||||
agreeTos.value = false;
|
agreeTosAndPrivacyPolicy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -104,7 +104,25 @@ function showMenu(ev) {
|
||||||
action: () => {
|
action: () => {
|
||||||
os.pageWindow('/about-misskey');
|
os.pageWindow('/about-misskey');
|
||||||
},
|
},
|
||||||
}, null, {
|
}, null, (instance.impressumUrl) ? {
|
||||||
|
text: i18n.ts.impressum,
|
||||||
|
icon: 'ti ti-file-invoice',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.impressumUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (instance.tosUrl) ? {
|
||||||
|
text: i18n.ts.termsOfService,
|
||||||
|
icon: 'ti ti-notebook',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.tosUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (instance.privacyPolicyUrl) ? {
|
||||||
|
text: i18n.ts.privacyPolicy,
|
||||||
|
icon: 'ti ti-shield-lock',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.privacyPolicyUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : null, {
|
||||||
text: i18n.ts.help,
|
text: i18n.ts.help,
|
||||||
icon: 'ti ti-help-circle',
|
icon: 'ti ti-help-circle',
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
|
@ -46,14 +46,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #value>{{ instance.maintainerEmail }}</template>
|
<template #value>{{ instance.maintainerEmail }}</template>
|
||||||
</MkKeyValue>
|
</MkKeyValue>
|
||||||
</FormSplit>
|
</FormSplit>
|
||||||
|
<FormLink v-if="instance.impressumUrl" :to="instance.impressumUrl" external>{{ i18n.ts.impressum }}</FormLink>
|
||||||
|
<div class="_formLinks">
|
||||||
<MkFolder v-if="instance.serverRules.length > 0">
|
<MkFolder v-if="instance.serverRules.length > 0">
|
||||||
<template #label>{{ i18n.ts.serverRules }}</template>
|
<template #label>{{ i18n.ts.serverRules }}</template>
|
||||||
|
|
||||||
<ol class="_gaps_s" :class="$style.rules">
|
<ol class="_gaps_s" :class="$style.rules">
|
||||||
<li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
|
<li v-for="item, index in instance.serverRules" :key="index" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
|
||||||
</ol>
|
</ol>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
|
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
|
||||||
|
<FormLink v-if="instance.privacyPolicyUrl" :to="instance.privacyPolicyUrl" external>{{ i18n.ts.privacyPolicy }}</FormLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #label>{{ i18n.ts.tosUrl }}</template>
|
<template #label>{{ i18n.ts.tosUrl }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
||||||
|
<MkInput v-model="privacyPolicyUrl">
|
||||||
|
<template #prefix><i class="ti ti-link"></i></template>
|
||||||
|
<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
<MkTextarea v-model="preservedUsernames">
|
<MkTextarea v-model="preservedUsernames">
|
||||||
<template #label>{{ i18n.ts.preservedUsernames }}</template>
|
<template #label>{{ i18n.ts.preservedUsernames }}</template>
|
||||||
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
|
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
|
||||||
|
@ -69,6 +74,7 @@ let emailRequiredForSignup: boolean = $ref(false);
|
||||||
let sensitiveWords: string = $ref('');
|
let sensitiveWords: string = $ref('');
|
||||||
let preservedUsernames: string = $ref('');
|
let preservedUsernames: string = $ref('');
|
||||||
let tosUrl: string | null = $ref(null);
|
let tosUrl: string | null = $ref(null);
|
||||||
|
let privacyPolicyUrl: string | null = $ref(null);
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
const meta = await os.api('admin/meta');
|
const meta = await os.api('admin/meta');
|
||||||
|
@ -77,6 +83,7 @@ async function init() {
|
||||||
sensitiveWords = meta.sensitiveWords.join('\n');
|
sensitiveWords = meta.sensitiveWords.join('\n');
|
||||||
preservedUsernames = meta.preservedUsernames.join('\n');
|
preservedUsernames = meta.preservedUsernames.join('\n');
|
||||||
tosUrl = meta.tosUrl;
|
tosUrl = meta.tosUrl;
|
||||||
|
privacyPolicyUrl = meta.privacyPolicyUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
function save() {
|
function save() {
|
||||||
|
@ -84,6 +91,7 @@ function save() {
|
||||||
disableRegistration: !enableRegistration,
|
disableRegistration: !enableRegistration,
|
||||||
emailRequiredForSignup,
|
emailRequiredForSignup,
|
||||||
tosUrl,
|
tosUrl,
|
||||||
|
privacyPolicyUrl,
|
||||||
sensitiveWords: sensitiveWords.split('\n'),
|
sensitiveWords: sensitiveWords.split('\n'),
|
||||||
preservedUsernames: preservedUsernames.split('\n'),
|
preservedUsernames: preservedUsernames.split('\n'),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
|
|
|
@ -34,6 +34,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
</FormSplit>
|
</FormSplit>
|
||||||
|
|
||||||
|
<MkInput v-model="impressumUrl">
|
||||||
|
<template #label>{{ i18n.ts.impressumUrl }}</template>
|
||||||
|
<template #prefix><i class="ti ti-link"></i></template>
|
||||||
|
<template #caption>{{ i18n.ts.impressumDescription }}</template>
|
||||||
|
</MkInput>
|
||||||
|
|
||||||
<MkTextarea v-model="pinnedUsers">
|
<MkTextarea v-model="pinnedUsers">
|
||||||
<template #label>{{ i18n.ts.pinnedUsers }}</template>
|
<template #label>{{ i18n.ts.pinnedUsers }}</template>
|
||||||
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
|
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
|
||||||
|
@ -135,6 +141,7 @@ let shortName: string | null = $ref(null);
|
||||||
let description: string | null = $ref(null);
|
let description: string | null = $ref(null);
|
||||||
let maintainerName: string | null = $ref(null);
|
let maintainerName: string | null = $ref(null);
|
||||||
let maintainerEmail: string | null = $ref(null);
|
let maintainerEmail: string | null = $ref(null);
|
||||||
|
let impressumUrl: string | null = $ref(null);
|
||||||
let pinnedUsers: string = $ref('');
|
let pinnedUsers: string = $ref('');
|
||||||
let cacheRemoteFiles: boolean = $ref(false);
|
let cacheRemoteFiles: boolean = $ref(false);
|
||||||
let cacheRemoteSensitiveFiles: boolean = $ref(false);
|
let cacheRemoteSensitiveFiles: boolean = $ref(false);
|
||||||
|
@ -153,6 +160,7 @@ async function init(): Promise<void> {
|
||||||
description = meta.description;
|
description = meta.description;
|
||||||
maintainerName = meta.maintainerName;
|
maintainerName = meta.maintainerName;
|
||||||
maintainerEmail = meta.maintainerEmail;
|
maintainerEmail = meta.maintainerEmail;
|
||||||
|
impressumUrl = meta.impressumUrl;
|
||||||
pinnedUsers = meta.pinnedUsers.join('\n');
|
pinnedUsers = meta.pinnedUsers.join('\n');
|
||||||
cacheRemoteFiles = meta.cacheRemoteFiles;
|
cacheRemoteFiles = meta.cacheRemoteFiles;
|
||||||
cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles;
|
cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles;
|
||||||
|
@ -172,6 +180,7 @@ function save(): void {
|
||||||
description,
|
description,
|
||||||
maintainerName,
|
maintainerName,
|
||||||
maintainerEmail,
|
maintainerEmail,
|
||||||
|
impressumUrl,
|
||||||
pinnedUsers: pinnedUsers.split('\n'),
|
pinnedUsers: pinnedUsers.split('\n'),
|
||||||
cacheRemoteFiles,
|
cacheRemoteFiles,
|
||||||
cacheRemoteSensitiveFiles,
|
cacheRemoteSensitiveFiles,
|
||||||
|
|
|
@ -68,7 +68,25 @@ export function openInstanceMenu(ev: MouseEvent) {
|
||||||
text: i18n.ts.manageCustomEmojis,
|
text: i18n.ts.manageCustomEmojis,
|
||||||
icon: 'ti ti-icons',
|
icon: 'ti ti-icons',
|
||||||
} : undefined],
|
} : undefined],
|
||||||
}, null, {
|
}, null, (instance.impressumUrl) ? {
|
||||||
|
text: i18n.ts.impressum,
|
||||||
|
icon: 'ti ti-file-invoice',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.impressumUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (instance.tosUrl) ? {
|
||||||
|
text: i18n.ts.termsOfService,
|
||||||
|
icon: 'ti ti-notebook',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.tosUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (instance.privacyPolicyUrl) ? {
|
||||||
|
text: i18n.ts.privacyPolicy,
|
||||||
|
icon: 'ti ti-shield-lock',
|
||||||
|
action: () => {
|
||||||
|
window.open(instance.privacyPolicyUrl, '_blank');
|
||||||
|
},
|
||||||
|
} : undefined, (!instance.impressumUrl && !instance.tosUrl && !instance.privacyPolicyUrl) ? undefined : null, {
|
||||||
text: i18n.ts.help,
|
text: i18n.ts.help,
|
||||||
icon: 'ti ti-help-circle',
|
icon: 'ti ti-help-circle',
|
||||||
action: () => {
|
action: () => {
|
||||||
|
|
|
@ -2408,6 +2408,8 @@ type LiteInstanceMetadata = {
|
||||||
tosUrl: string | null;
|
tosUrl: string | null;
|
||||||
repositoryUrl: string;
|
repositoryUrl: string;
|
||||||
feedbackUrl: string;
|
feedbackUrl: string;
|
||||||
|
impressumUrl: string | null;
|
||||||
|
privacyPolicyUrl: string | null;
|
||||||
disableRegistration: boolean;
|
disableRegistration: boolean;
|
||||||
disableLocalTimeline: boolean;
|
disableLocalTimeline: boolean;
|
||||||
disableGlobalTimeline: boolean;
|
disableGlobalTimeline: boolean;
|
||||||
|
|
|
@ -323,6 +323,8 @@ export type LiteInstanceMetadata = {
|
||||||
tosUrl: string | null;
|
tosUrl: string | null;
|
||||||
repositoryUrl: string;
|
repositoryUrl: string;
|
||||||
feedbackUrl: string;
|
feedbackUrl: string;
|
||||||
|
impressumUrl: string | null;
|
||||||
|
privacyPolicyUrl: string | null;
|
||||||
disableRegistration: boolean;
|
disableRegistration: boolean;
|
||||||
disableLocalTimeline: boolean;
|
disableLocalTimeline: boolean;
|
||||||
disableGlobalTimeline: boolean;
|
disableGlobalTimeline: boolean;
|
||||||
|
|
Loading…
Reference in New Issue