Compare commits

...

14 Commits

Author SHA1 Message Date
syuilo
1564651bf6 2023.10.0-beta.10 2023-10-09 12:37:21 +09:00
syuilo
fce557715b New Crowdin updates (#11980)
* New translations ja-jp.yml (Italian)

* New translations ja-jp.yml (Chinese Traditional)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (German)

* New translations ja-jp.yml (English)

* New translations ja-jp.yml (Chinese Traditional)
2023-10-09 12:37:04 +09:00
_
ca07459f5e fix(backend): ダイレクト投稿がタイムライン上に正常に表示されない問題を修正 (#11993)
* DMをredisにpushするように

* add test

* add CHANGELOG

* Update NoteCreateService.ts

* lint

* ✌️

* 前のバージョンから発生した問題ではないため不要

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-09 12:36:25 +09:00
syuilo
457b4cf608 fix(backend): users/notes で 自身の visibility: followers なノートが含まれない問題を修正 2023-10-09 08:54:57 +09:00
syuilo
5601ed0914 enhance(backend): UserListMembershipにユーザーリストの作成者IDを非正規化 2023-10-09 08:46:05 +09:00
syuilo
ca022cbbdf Update about-misskey.vue 2023-10-08 18:04:56 +09:00
syuilo
c78b4a7597 2023.10.0-beta.9 2023-10-08 18:00:55 +09:00
syuilo
274c21e2cc chore(backend): tweak MkInput 2023-10-08 17:57:48 +09:00
かっこかり
4bbfc98883 Feat: タイムライン更新中に広告を挿入 (#11989)
* Feat: タイムライン更新中に広告を挿入

* 翻訳を変更

* Run api extractor

* fix api extractor

* Update locales/ja-JP.yml

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>

* confirm -> mkinfo

* MkInputにmin, maxを指定できるように

* 負の値が指定されたら何もしない

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-08 17:56:44 +09:00
syuilo
9240db35f3 perf(backend): improve hybrid-timeline performance 2023-10-08 15:22:43 +09:00
syuilo
774bf6a55e enhance(frontend): make default volume of video 30% 2023-10-08 14:01:40 +09:00
かっこかり
f37a3eff79 (fix) メールアドレス認証失敗時にメッセージを表示 (#11986) 2023-10-08 13:48:12 +09:00
Srgr0
bb9f04d586 Set http header for CORS in nodeinfo page (#11988)
* add Access-Control-Allow-Origin header

* WellKnownServerService.tsに合わせる

* update changelog

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
2023-10-08 13:47:45 +09:00
syuilo
8e0fb23068 fix(backend): 同じ種類のTLのストリーミングを複数接続できない問題を修正
Fix #11985
2023-10-08 13:46:02 +09:00
38 changed files with 439 additions and 93 deletions

View File

@@ -28,6 +28,9 @@
- Feat: ユーザーごとのハイライト
- Feat: プライバシーポリシー・運営者情報Impressumの指定が可能になりました
- プライバシーポリシーはサーバー登録時に同意確認が入ります
- Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました
- デフォルトは無効
- 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。
- Enhance: ソフトワードミュートとハードワードミュートは統合されました
- Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新
@@ -37,6 +40,7 @@
### Client
- Enhance: 二要素認証のバックアップコード一覧をテキストファイルでダウンロード可能に
- Enhance: 動画再生時のデフォルトボリュームを30%に
- Fix: リアクションしたユーザ一覧のUIが稀に左上に残ってしまう不具合を修正
### Server
@@ -46,6 +50,8 @@
- Enhance: WebSocket接続が多い場合のパフォーマンスを向上
- Enhance: 不要なPostgreSQLのインデックスを削除しパフォーマンスを向上
- Fix: 連合なしアンケートに投票をするとUpdateがリモートに配信されてしまうのを修正
- Fix: nodeinfoにおいてCORS用のヘッダーが設定されていないのを修正
- Fix: 同じ種類のTLのストリーミングを複数接続できない問題を修正
## 2023.9.3
### General

View File

@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Nur Notizen mit Dateien"
showRepliesToOthersInTimeline: "Antworten in Chronik anzeigen"
hideRepliesToOthersInTimeline: "Antworten nicht in Chronik anzeigen"
externalServices: "Externe Dienste"
impressum: "Impressum"
impressumUrl: "Impressums-URL"
impressumDescription: "In manchen Ländern, wie Deutschland und dessen Umgebung, ist die Angabe von Betreiberinformationen (ein Impressum) bei kommerziellem Betrieb zwingend."
privacyPolicy: "Datenschutzerklärung"
privacyPolicyUrl: "Datenschutzerklärungs-URL"
tosAndPrivacyPolicy: "Nutzungsbedingungen und Datenschutzerklärung"
_announcement:
forExistingUsers: "Nur für existierende Nutzer"
forExistingUsersDescription: "Ist diese Option aktiviert, wird diese Ankündigung nur Nutzern angezeigt, die zum Zeitpunkt der Ankündigung bereits registriert sind. Ist sie deaktiviert, wird sie auch Nutzern, die sich nach dessen Veröffentlichung registrieren, angezeigt."
@@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Diese Werbung weniger anzeigen"
hide: "Ausblenden"
timezoneinfo: "Der Wochentag wird durch die Serverzeitzone bestimmt."
adsSettings: "Werbeeinstellungen"
notesPerOneAd: "Werbeintervall während Echtzeitaktualisierung (Notizen pro Werbung)"
setZeroToDisable: "Setze dies auf 0, um Werbung während Echtzeitaktualisierung zu deaktivieren"
adsTooClose: "Durch den momentan sehr niedrigen Werbeintervall kann es zu einer starken Verschlechterung der Benutzererfahrung kommen."
_forgotPassword:
enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst."
ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, wende dich bitte an den Administrator."

View File

@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Only notes with files"
showRepliesToOthersInTimeline: "Show replies to others in TL"
hideRepliesToOthersInTimeline: "Hide replies to others from TL"
externalServices: "External Services"
impressum: "Impressum"
impressumUrl: "Impressum URL"
impressumDescription: "In some countries, like germany, the inclusion of operator contact information (an Impressum) is legally required for commercial websites."
privacyPolicy: "Privacy Policy"
privacyPolicyUrl: "Privacy Policy URL"
tosAndPrivacyPolicy: "Terms of Service and Privacy Policy"
_announcement:
forExistingUsers: "Existing users only"
forExistingUsersDescription: "This announcement will only be shown to users existing at the point of publishment if enabled. If disabled, those newly signing up after it has been posted will also see it."
@@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "Show this ad less"
hide: "Hide"
timezoneinfo: "The day of the week is determined from the server's timezone."
adsSettings: "Ad settings"
notesPerOneAd: "Real-time update ad placement interval (Notes per ad)"
setZeroToDisable: "Set this value to 0 to disable real-time update ads"
adsTooClose: "The current ad interval may significantly worsen the user experience due to being too low."
_forgotPassword:
enterEmail: "Enter the email address you used to register. A link with which you can reset your password will then be sent to it."
ifNoEmail: "If you did not use an email during registration, please contact the instance administrator instead."

4
locales/index.d.ts vendored
View File

@@ -1627,6 +1627,10 @@ export interface Locale {
"reduceFrequencyOfThisAd": string;
"hide": string;
"timezoneinfo": string;
"adsSettings": string;
"notesPerOneAd": string;
"setZeroToDisable": string;
"adsTooClose": string;
};
"_forgotPassword": {
"enterEmail": string;

View File

@@ -64,7 +64,7 @@ reply: "Rispondi"
loadMore: "Mostra di più"
showMore: "Espandi"
showLess: "Comprimi"
youGotNewFollower: "Ti sta seguendo"
youGotNewFollower: "Adesso ti segue"
receiveFollowRequest: "Hai ricevuto una richiesta di follow"
followRequestAccepted: "Ha accettato la tua richiesta di follow"
mention: "Menzioni"
@@ -336,7 +336,7 @@ instanceName: "Nome dell'istanza"
instanceDescription: "Descrizione dell'istanza"
maintainerName: "Nome dell'amministratore"
maintainerEmail: "Indirizzo e-mail dell'amministratore"
tosUrl: "URL dei termini del servizio e della privacy"
tosUrl: "URL delle condizioni d'uso"
thisYear: "Anno"
thisMonth: "Mese"
today: "Oggi"
@@ -1129,6 +1129,12 @@ fileAttachedOnly: "Con file in allegato"
showRepliesToOthersInTimeline: "Risposte altrui nella TL"
hideRepliesToOthersInTimeline: "Nascondi Riposte altrui nella TL"
externalServices: "Servizi esterni"
impressum: "Dichiarazione di proprietà"
impressumUrl: "URL della dichiarazione di proprietà"
impressumDescription: "La dichiarazione di proprietà, è obbligatoria in alcuni paesi come la Germania (Impressum)."
privacyPolicy: "Informativa sulla privacy"
privacyPolicyUrl: "URL della informativa privacy"
tosAndPrivacyPolicy: "Condizioni d'uso e informativa sulla privacy"
_announcement:
forExistingUsers: "Solo ai profili attuali"
forExistingUsersDescription: "L'annuncio sarà visibile solo ai profili esistenti in questo momento. Se disabilitato, sarà visibile anche ai profili che verranno creati dopo la pubblicazione di questo annuncio."
@@ -1538,7 +1544,7 @@ _gallery:
unlike: "Non mi piace più"
_email:
_follow:
title: "Ha iniziato a seguirti"
title: "Adesso ti segue"
_receiveFollowRequest:
title: "Hai ricevuto una richiesta di follow"
_plugin:
@@ -2019,7 +2025,7 @@ _notification:
youGotReply: "{name} ti ha risposto"
youGotQuote: "{name} ha citato la tua Nota e ha detto"
youRenoted: "{name} ha rinotato"
youWereFollowed: "Ha iniziato a seguirti"
youWereFollowed: "Adesso ti segue"
youReceivedFollowRequest: "Hai ricevuto una richiesta di follow"
yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata"
pollEnded: "Risultati del sondaggio."

View File

@@ -1546,6 +1546,10 @@ _ad:
reduceFrequencyOfThisAd: "この広告の表示頻度を下げる"
hide: "表示しない"
timezoneinfo: "曜日はサーバーのタイムゾーンを元に指定されます。"
adsSettings: "広告配信設定"
notesPerOneAd: "リアルタイム更新中に広告を配信する間隔(ノートの個数)"
setZeroToDisable: "0でリアルタイム更新時の広告配信を無効"
adsTooClose: "広告の配信間隔が極めて短いため、ユーザー体験が著しく損われる可能性があります。"
_forgotPassword:
enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。"

View File

@@ -1129,6 +1129,12 @@ fileAttachedOnly: "包含附件"
showRepliesToOthersInTimeline: "在時間軸上顯示給其他人的回覆"
hideRepliesToOthersInTimeline: "在時間軸上隱藏給其他人的回覆"
externalServices: "外部服務"
impressum: "營運者資訊"
impressumUrl: "營運者資訊網址"
impressumDescription: "在德國與部份地區必須要明確顯示營運者資訊。"
privacyPolicy: "隱私政策"
privacyPolicyUrl: "隱私政策網址"
tosAndPrivacyPolicy: "服務條款和隱私政策"
_announcement:
forExistingUsers: "僅限既有的使用者"
forExistingUsersDescription: "啟用代表僅向現存使用者顯示;停用代表張貼後註冊的新使用者也會看到。"
@@ -1527,6 +1533,10 @@ _ad:
reduceFrequencyOfThisAd: "降低此廣告的頻率 "
hide: "隱藏"
timezoneinfo: "星期幾是由伺服器的時區指定的。"
adsSettings: "廣告投放設定"
notesPerOneAd: "即時更新中投放廣告的間隔(貼文數)"
setZeroToDisable: "設為 0 則在即時更新時不投放廣告"
adsTooClose: "由於廣告投放的間隔極短,可能會嚴重影響使用者體驗。"
_forgotPassword:
enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。"
ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。 "

View File

@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2023.10.0-beta.8",
"version": "2023.10.0-beta.10",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AdsOnStream1696743032098 {
name = 'AdsOnStream1696743032098'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "notesPerOneAd" integer NOT NULL DEFAULT '0'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "notesPerOneAd"`);
}
}

View File

@@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserListUserId1696807733453 {
name = 'UserListUserId1696807733453'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ADD "userListUserId" character varying(32) NOT NULL DEFAULT ''`);
const memberships = await queryRunner.query(`SELECT "id", "userListId" FROM "user_list_membership"`);
for(let i = 0; i < memberships.length; i++) {
const userList = await queryRunner.query(`SELECT "userId" FROM "user_list" WHERE "id" = $1`, [memberships[i].userListId]);
await queryRunner.query(`UPDATE "user_list_membership" SET "userListUserId" = $1 WHERE "id" = $2`, [userList[0].userId, memberships[i].id]);
}
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" DROP COLUMN "userListUserId"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UserListUserId21696808725134 {
name = 'UserListUserId21696808725134'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" DROP DEFAULT`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_list_membership" ALTER COLUMN "userListUserId" SET DEFAULT ''`);
}
}

View File

@@ -228,7 +228,7 @@ export class AccountMoveService {
},
}).then(memberships => memberships.map(membership => membership.userListId));
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; }> = new Map();
const newMemberships: Map<string, { createdAt: Date; userId: string; userListId: string; userListUserId: string; }> = new Map();
// 重複しないようにIDを生成
const genId = (): string => {
@@ -244,6 +244,7 @@ export class AccountMoveService {
createdAt: new Date(),
userId: dst.id,
userListId: membership.userListId,
userListUserId: membership.userListUserId,
});
}

View File

@@ -494,11 +494,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// Increment notes count (user)
this.incNotesCountOfUser(user);
if (data.visibility === 'specified') {
// TODO?
} else {
this.pushToTl(note, user);
}
this.pushToTl(note, user);
this.antennaService.addNoteToAntennas(note, user);
@@ -861,24 +857,34 @@ export class NoteCreateService implements OnApplicationShutdown {
}
} else {
// TODO: キャッシュ?
const followings = await this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],
});
// eslint-disable-next-line prefer-const
let [followings, userListMemberships] = await Promise.all([
this.followingsRepository.find({
where: {
followeeId: user.id,
followerHost: IsNull(),
isFollowerHibernated: false,
},
select: ['followerId', 'withReplies'],
}),
this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'userListUserId', 'withReplies'],
}),
]);
const userListMemberships = await this.userListMembershipsRepository.find({
where: {
userId: user.id,
},
select: ['userListId', 'withReplies'],
});
if (note.visibility === 'followers') {
// TODO: 重そうだから何とかしたい Set 使う?
userListMemberships = userListMemberships.filter(x => followings.some(f => f.followerId === x.userListUserId));
}
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!following.withReplies) continue;
@@ -899,13 +905,13 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
// TODO
//if (note.visibility === 'followers') {
// // TODO: 重そうだから何とかしたい Set 使う?
// userLists = userLists.filter(x => followings.some(f => f.followerId === x.userListUserId));
//}
for (const userListMembership of userListMemberships) {
// ダイレクトのとき、そのリストが対象外のユーザーの場合
if (
note.visibility === 'specified' &&
!note.visibleUserIds.some(v => v === userListMembership.userListUserId)
) continue;
// 自分自身以外への返信
if (note.replyId && note.replyUserId !== note.userId) {
if (!userListMembership.withReplies) continue;
@@ -926,7 +932,7 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
{ // 自分自身のHTL
if (note.visibility !== 'specified' || !note.visibleUserIds.some(v => v === user.id)) { // 自分自身のHTL
redisPipeline.xadd(
`homeTimeline:${user.id}`,
'MAXLEN', '~', meta.perUserHomeTimelineCacheMax.toString(),

View File

@@ -97,6 +97,7 @@ export class UserListService implements OnApplicationShutdown {
createdAt: new Date(),
userId: target.id,
userListId: list.id,
userListUserId: list.userId,
} as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });

View File

@@ -503,4 +503,9 @@ export class MiMeta {
default: 300,
})
public perUserListTimelineCacheMax: number;
@Column('integer', {
default: 0,
})
public notesPerOneAd: number;
}

View File

@@ -50,4 +50,11 @@ export class MiUserListMembership {
default: false,
})
public withReplies: boolean;
//#region Denormalized fields
@Column({
...id(),
})
public userListUserId: MiUser['id'];
//#endregion
}

View File

@@ -135,7 +135,11 @@ export class NodeinfoServerService {
.type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
)
.header('Cache-Control', 'public, max-age=600');
.header('Cache-Control', 'public, max-age=600')
.header('Access-Control-Allow-Headers', 'Accept')
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Expose-Headers', 'Vary');
return { version: '2.1', ...base };
});
@@ -148,7 +152,11 @@ export class NodeinfoServerService {
.type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"',
)
.header('Cache-Control', 'public, max-age=600');
.header('Cache-Control', 'public, max-age=600')
.header('Access-Control-Allow-Headers', 'Accept')
.header('Access-Control-Allow-Methods', 'GET, OPTIONS')
.header('Access-Control-Allow-Origin', '*')
.header('Access-Control-Expose-Headers', 'Vary');
return { version: '2.0', ...base };
});

View File

@@ -199,10 +199,10 @@ export class ServerService implements OnApplicationShutdown {
includeSecrets: true,
}));
reply.code(200);
return 'Verify succeeded!';
reply.code(200).send('Verification succeeded! メールアドレスの認証に成功しました。');
return;
} else {
reply.code(404);
reply.code(404).send('Verification failed. Please try again. メールアドレスの認証に失敗しました。もう一度お試しください');
return;
}
});

View File

@@ -297,6 +297,10 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,
},
},
},
} as const;
@@ -408,6 +412,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
perRemoteUserUserTimelineCacheMax: instance.perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax: instance.perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax: instance.perUserListTimelineCacheMax,
notesPerOneAd: instance.notesPerOneAd,
};
});
}

View File

@@ -114,6 +114,7 @@ export const paramDef = {
perRemoteUserUserTimelineCacheMax: { type: 'integer' },
perUserHomeTimelineCacheMax: { type: 'integer' },
perUserListTimelineCacheMax: { type: 'integer' },
notesPerOneAd: { type: 'integer' },
},
required: [],
} as const;
@@ -471,6 +472,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.perUserListTimelineCacheMax = ps.perUserListTimelineCacheMax;
}
if (ps.notesPerOneAd !== undefined) {
set.notesPerOneAd = ps.notesPerOneAd;
}
const before = await this.metaService.fetch(true);
await this.metaService.update(set);

View File

@@ -181,6 +181,11 @@ export const meta = {
},
},
},
notesPerOneAd: {
type: 'number',
optional: false, nullable: false,
default: 0,
},
requireSetup: {
type: 'boolean',
optional: false, nullable: false,
@@ -331,6 +336,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek,
})),
notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail,
enableServiceWorker: instance.enableServiceWorker,

View File

@@ -93,20 +93,23 @@ 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 [htlNoteIds, ltlNoteIds] = await Promise.all([
this.redisForTimelines.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit,
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
this.redisForTimelines.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit,
).then(res => res.map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId)),
]);
const redisPipeline = this.redisForTimelines.pipeline();
redisPipeline.xrevrange(
ps.withFiles ? `homeTimelineWithFiles:${me.id}` : `homeTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit,
);
redisPipeline.xrevrange(
ps.withFiles ? 'localTimelineWithFiles' : 'localTimeline',
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : ps.untilDate ?? '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : ps.sinceDate ?? '-',
'COUNT', limit,
);
const [htlNoteIds, ltlNoteIds] = await redisPipeline.exec().then(res => res ? [
(res[0][1] as string[][]).map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
(res[1][1] as string[][]).map(x => x[1][1]).filter(x => x !== ps.untilId && x !== ps.sinceId),
] : []);
let noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
noteIds.sort((a, b) => a > b ? -1 : 1);

View File

@@ -114,7 +114,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
noteIds = noteIds.slice(0, ps.limit);
if (noteIds.length > 0) {
const isFollowing = me ? Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
const isFollowing = me ? me.id === ps.userId || Object.hasOwn(await this.cacheService.userFollowingsCache.fetch(me.id), ps.userId) : false;
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
@@ -136,6 +136,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
}
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing) return false;
return true;

View File

@@ -16,7 +16,7 @@ import Channel from '../channel.js';
class GlobalTimelineChannel extends Channel {
public readonly chName = 'globalTimeline';
public static shouldShare = true;
public static shouldShare = false;
public static requireCredential = false;
private withRenotes: boolean;

View File

@@ -14,7 +14,7 @@ import Channel from '../channel.js';
class HomeTimelineChannel extends Channel {
public readonly chName = 'homeTimeline';
public static shouldShare = true;
public static shouldShare = false;
public static requireCredential = true;
private withRenotes: boolean;

View File

@@ -16,7 +16,7 @@ import Channel from '../channel.js';
class HybridTimelineChannel extends Channel {
public readonly chName = 'hybridTimeline';
public static shouldShare = true;
public static shouldShare = false;
public static requireCredential = true;
private withRenotes: boolean;

View File

@@ -15,7 +15,7 @@ import Channel from '../channel.js';
class LocalTimelineChannel extends Channel {
public readonly chName = 'localTimeline';
public static shouldShare = true;
public static shouldShare = false;
public static requireCredential = false;
private withRenotes: boolean;

View File

@@ -3,6 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
// How to run:
// pnpm jest -- e2e/timelines.ts
process.env.NODE_ENV = 'test';
process.env.FORCE_FOLLOW_REMOTE_USER_FOR_TESTING = 'true';
@@ -378,6 +381,104 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('自分の visibility: specified なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi');
});
test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
await api('/following/create', { userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'ok');
});
/* TODO
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'ok');
});
*/
// ↑の挙動が理想だけど実装が面倒かも
test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] });
const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id });
await waitForPushToTl();
const res = await api('/notes/timeline', {}, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
});
describe('Local TL', () => {
@@ -630,7 +731,6 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
});
/* 未実装
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@@ -645,23 +745,6 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
*/
test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれるが隠される', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'followers' });
await waitForPushToTl();
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, null);
});
test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
@@ -778,6 +861,38 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
}, 1000 * 10);
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] });
await waitForPushToTl();
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
const list = await api('/users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('/users/lists/push', { listId: list.id, userId: bob.id }, alice);
await api('/users/lists/push', { listId: list.id, userId: carol.id }, alice);
await sleep(1000);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [carol.id] });
await waitForPushToTl();
const res = await api('/notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
});
describe('User TL', () => {
@@ -820,6 +935,19 @@ describe('Timelines', () => {
assert.strictEqual(res.body.find((note: any) => note.id === bobNote.id).text, 'hi');
});
test.concurrent('自身の visibility: followers なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: alice.id }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
assert.strictEqual(res.body.find((note: any) => note.id === aliceNote.id).text, 'hi');
});
test.concurrent('チャンネル投稿が含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@@ -938,6 +1066,30 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote3.id), true);
});
test.concurrent('自身の visibility: specified なノートが含まれる', async () => {
const [alice] = await Promise.all([signup()]);
const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: alice.id, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true);
});
test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi', visibility: 'specified' });
await waitForPushToTl();
const res = await api('/users/notes', { userId: bob.id, withReplies: true }, alice);
assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false);
});
});
// TODO: リノートミュート済みユーザーのテスト

View File

@@ -23,6 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:spellcheck="spellcheck"
:step="step"
:list="id"
:min="min"
:max="max"
@focus="focused = true"
@blur="focused = false"
@keydown="onKeydown($event)"
@@ -59,6 +61,8 @@ const props = defineProps<{
spellcheck?: boolean;
step?: any;
datalist?: string[];
min?: number;
max?: number;
inline?: boolean;
debounce?: boolean;
manualSave?: boolean;

View File

@@ -17,7 +17,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:title="media.name"
controls
preload="metadata"
@volumechange="volumechange"
/>
</div>
<a
@@ -33,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { onMounted } from 'vue';
import { onMounted, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { soundConfigStore } from '@/scripts/sound.js';
import { i18n } from '@/i18n.js';
@@ -43,15 +42,13 @@ const props = withDefaults(defineProps<{
}>(), {
});
const audioEl = $shallowRef<HTMLAudioElement | null>();
const audioEl = shallowRef<HTMLAudioElement>();
let hide = $ref(true);
function volumechange() {
if (audioEl) soundConfigStore.set('mediaVolume', audioEl.volume);
}
onMounted(() => {
if (audioEl) audioEl.volume = soundConfigStore.state.mediaVolume;
watch(audioEl, () => {
if (audioEl.value) {
audioEl.value.volume = 0.3;
}
});
</script>

View File

@@ -14,6 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div v-else :class="[$style.visible, (video.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitiveContainer]">
<video
ref="videoEl"
:class="$style.video"
:poster="video.thumbnailUrl"
:title="video.comment"
@@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, shallowRef, watch } from 'vue';
import * as Misskey from 'misskey-js';
import bytes from '@/filters/bytes.js';
import { defaultStore } from '@/store.js';
@@ -42,6 +43,14 @@ const props = defineProps<{
}>();
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
const videoEl = shallowRef<HTMLVideoElement>();
watch(videoEl, () => {
if (videoEl.value) {
videoEl.value.volume = 0.3;
}
});
</script>
<style lang="scss" module>

View File

@@ -13,6 +13,7 @@ import MkNotes from '@/components/MkNotes.vue';
import { useStream } from '@/stream.js';
import * as sound from '@/scripts/sound.js';
import { $i } from '@/account.js';
import { instance } from '@/instance.js';
import { defaultStore } from '@/store.js';
const props = withDefaults(defineProps<{
@@ -38,7 +39,15 @@ provide('inChannel', computed(() => props.src === 'channel'));
const tlComponent: InstanceType<typeof MkNotes> = $ref();
let tlNotesCount = 0;
const prepend = note => {
tlNotesCount++;
if (instance.notesPerOneAd > 0 && tlNotesCount % instance.notesPerOneAd === 0) {
note._shouldInsertAd_ = true;
}
tlComponent.pagingComponent?.prepend(note);
emit('note');

View File

@@ -187,6 +187,9 @@ const patronsWithIcon = [{
}, {
name: 'フランギ・シュウ',
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
}, {
name: '百日紅',
icon: 'https://misskey-hub.net/patrons/302dce2898dd457ba03c3f7dc037900b.jpg',
}];
const patrons = [

View File

@@ -107,6 +107,22 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</div>
</FormSection>
<FormSection>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<div class="_gaps_m">
<div class="_gaps_s">
<MkInput v-model="notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="notesPerOneAd > 0 && notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</FormSection>
</div>
</FormSuspense>
</MkSpacer>
@@ -127,6 +143,7 @@ import XHeader from './_header_.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkInfo from '@/components/MkInfo.vue';
import FormSection from '@/components/form/section.vue';
import FormSplit from '@/components/form/split.vue';
import FormSuspense from '@/components/form/suspense.vue';
@@ -152,6 +169,7 @@ let perLocalUserUserTimelineCacheMax: number = $ref(0);
let perRemoteUserUserTimelineCacheMax: number = $ref(0);
let perUserHomeTimelineCacheMax: number = $ref(0);
let perUserListTimelineCacheMax: number = $ref(0);
let notesPerOneAd: number = $ref(0);
async function init(): Promise<void> {
const meta = await os.api('admin/meta');
@@ -171,10 +189,11 @@ async function init(): Promise<void> {
perRemoteUserUserTimelineCacheMax = meta.perRemoteUserUserTimelineCacheMax;
perUserHomeTimelineCacheMax = meta.perUserHomeTimelineCacheMax;
perUserListTimelineCacheMax = meta.perUserListTimelineCacheMax;
notesPerOneAd = meta.notesPerOneAd;
}
function save(): void {
os.apiWithDialog('admin/update-meta', {
async function save(): void {
await os.apiWithDialog('admin/update-meta', {
name,
shortName: shortName === '' ? null : shortName,
description,
@@ -191,9 +210,10 @@ function save(): void {
perRemoteUserUserTimelineCacheMax,
perUserHomeTimelineCacheMax,
perUserListTimelineCacheMax,
}).then(() => {
fetchInstance();
notesPerOneAd,
});
fetchInstance();
}
const headerTabs = $computed(() => []);

View File

@@ -7,10 +7,6 @@ import { markRaw } from 'vue';
import { Storage } from '@/pizzax.js';
export const soundConfigStore = markRaw(new Storage('sound', {
mediaVolume: {
where: 'device',
default: 0.5,
},
sound_masterVolume: {
where: 'device',
default: 0.3,

View File

@@ -2448,6 +2448,7 @@ type LiteInstanceMetadata = {
url: string;
imageUrl: string;
}[];
notesPerOneAd: number;
translatorAvailable: boolean;
serverRules: string[];
};
@@ -2980,7 +2981,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// 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:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:596:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/entities.ts:597: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
// (No @packageDocumentation comment for this package)

View File

@@ -362,6 +362,7 @@ export type LiteInstanceMetadata = {
url: string;
imageUrl: string;
}[];
notesPerOneAd: number;
translatorAvailable: boolean;
serverRules: string[];
};

View File

@@ -38,6 +38,9 @@ module.exports = {
'before': true,
'after': true,
}],
'brace-style': ['error', '1tbs', {
'allowSingleLine': true,
}],
'padded-blocks': ['error', 'never'],
/* TODO: path aliasを使わないとwarnする
'no-restricted-imports': ['warn', {