Merge branch 'develop' into feature/2024.9.0

# Conflicts:
#	locales/en-US.yml
#	locales/ja-JP.yml
#	packages/backend/src/core/NoteCreateService.ts
#	packages/backend/src/core/NoteDeleteService.ts
#	packages/backend/src/core/NoteEditService.ts
#	packages/frontend-shared/js/config.ts
#	packages/frontend/src/boot/common.ts
#	packages/frontend/src/pages/following-feed.vue
#	packages/misskey-js/src/autogen/endpoint.ts
This commit is contained in:
Hazelnoot 2024-10-15 18:01:57 -04:00
commit 8a34d8e9d2
52 changed files with 1073 additions and 268 deletions

41
UPGRADE_NOTES.md Normal file
View file

@ -0,0 +1,41 @@
# Upgrade Notes
## 2024.9.0
### Following Feed
When upgrading an existing instance to version 2024.9.0, the Following Feed will initially be empty.
The feed will gradually fill as new posts federate, but it may be desirable to back-fill the feed with existing data.
This database script will populate the feed with the latest post of each type for all users, ensuring that data is fully populated after the update.
Run this after migrations but before starting the instance.
Warning: the script may take a long time to execute!
```postgresql
INSERT INTO latest_note (user_id, note_id, is_public, is_reply, is_quote)
SELECT
"userId" as user_id,
id as note_id,
visibility = 'public' AS is_public,
"replyId" IS NOT NULL AS is_reply,
(
"renoteId" IS NOT NULL
AND (
text IS NOT NULL
OR cw IS NOT NULL
OR "replyId" IS NOT NULL
OR "hasPoll"
OR "fileIds" != '{}'
)
) AS is_quote
FROM note
WHERE ( -- Exclude pure renotes (boosts)
"renoteId" IS NULL
OR text IS NOT NULL
OR cw IS NOT NULL
OR "replyId" IS NOT NULL
OR "hasPoll"
OR "fileIds" != '{}'
)
ORDER BY id DESC -- This part is very important: it ensures that we only load the *latest* notes of each type. Do not remove it!
ON CONFLICT DO NOTHING; -- Any conflicts are guaranteed to be older notes that we can ignore.
```

16
locales/index.d.ts vendored
View file

@ -10916,6 +10916,22 @@ export interface Locale extends ILocale {
* Severing all follow relations with {host} queued.
*/
"severAllFollowRelationsQueued": ParameterizedString<"host">;
/**
* Pending follow requests
*/
"pendingFollowRequests": string;
/**
* Show quotes
*/
"showQuotes": string;
/**
* Show replies
*/
"showReplies": string;
/**
* Show non-public
*/
"showNonPublicNotes": string;
"_mfm": {
/**
* This is not a widespread feature, it may not display properly on most other fedi software, including other Misskey forks

1
locales/version.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export const localesVersion: string;

14
locales/version.js Normal file
View file

@ -0,0 +1,14 @@
import { createHash } from 'crypto';
import locales from './index.js';
// MD5 is acceptable because we don't need cryptographic security.
const hash = createHash('md5');
// Derive the version hash from locale content exclusively.
// This avoids the problem of "stuck" translations after modifying locale files.
const localesText = JSON.stringify(locales);
hash.update(localesText, 'utf8');
// We can't use regular base64 since this becomes part of a filename.
// Base64URL avoids special characters that would cause an issue.
export const localesVersion = hash.digest().toString('base64url');

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class TrackLatestNoteType1728420772835 {
name = 'TrackLatestNoteType1728420772835'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a"`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_public" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_reply" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD "is_quote" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd" PRIMARY KEY ("user_id", is_public, is_reply, is_quote)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "latest_note" DROP CONSTRAINT "PK_a44ac8ca9cb916faeefc0912abd"`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_quote`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_reply`);
await queryRunner.query(`ALTER TABLE "latest_note" DROP COLUMN is_public`);
await queryRunner.query(`ALTER TABLE "latest_note" ADD CONSTRAINT "PK_f619b62bfaafabe68f52fb50c9a" PRIMARY KEY ("user_id")`);
}
}

View file

@ -43,6 +43,7 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteEditService } from './NoteEditService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { LatestNoteService } from './LatestNoteService.js';
import { NotePiningService } from './NotePiningService.js';
import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js';
@ -187,6 +188,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteEditService: Provider = { provide: 'NoteEditService', useExisting: NoteEditService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $LatestNoteService: Provider = { provide: 'LatestNoteService', useExisting: LatestNoteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
@ -339,6 +341,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
NoteCreateService,
NoteEditService,
NoteDeleteService,
LatestNoteService,
NotePiningService,
NoteReadService,
NotificationService,
@ -487,6 +490,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$NoteCreateService,
$NoteEditService,
$NoteDeleteService,
$LatestNoteService,
$NotePiningService,
$NoteReadService,
$NotificationService,
@ -636,6 +640,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
NoteCreateService,
NoteEditService,
NoteDeleteService,
LatestNoteService,
NotePiningService,
NoteReadService,
NotificationService,
@ -783,6 +788,7 @@ const $SponsorsService: Provider = { provide: 'SponsorsService', useExisting: Sp
$NoteCreateService,
$NoteEditService,
$NoteDeleteService,
$LatestNoteService,
$NotePiningService,
$NoteReadService,
$NotificationService,

View file

@ -0,0 +1,139 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not } from 'typeorm';
import { MiNote } from '@/models/Note.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { SkLatestNote } from '@/models/LatestNote.js';
import { DI } from '@/di-symbols.js';
import type { LatestNotesRepository, NotesRepository } from '@/models/_.js';
import { LoggerService } from '@/core/LoggerService.js';
import Logger from '@/logger.js';
@Injectable()
export class LatestNoteService {
private readonly logger: Logger;
constructor(
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
loggerService: LoggerService,
) {
this.logger = loggerService.getLogger('LatestNoteService');
}
handleUpdatedNoteBG(before: MiNote, after: MiNote): void {
this
.handleUpdatedNote(before, after)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after update):', err));
}
async handleUpdatedNote(before: MiNote, after: MiNote): Promise<void> {
// If the key didn't change, then there's nothing to update
if (SkLatestNote.areEquivalent(before, after)) return;
// Simulate update as delete + create
await this.handleDeletedNote(before);
await this.handleCreatedNote(after);
}
handleCreatedNoteBG(note: MiNote): void {
this
.handleCreatedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after create):', err));
}
async handleCreatedNote(note: MiNote): Promise<void> {
// Ignore DMs.
// Followers-only posts are *included*, as this table is used to back the "following" feed.
if (note.visibility === 'specified') return;
// Ignore pure renotes
if (isPureRenote(note)) return;
// Compute the compound key of the entry to check
const key = SkLatestNote.keyFor(note);
// Make sure that this isn't an *older* post.
// We can get older posts through replies, lookups, updates, etc.
const currentLatest = await this.latestNotesRepository.findOneBy(key);
if (currentLatest != null && currentLatest.noteId >= note.id) return;
// Record this as the latest note for the given user
const latestNote = new SkLatestNote({
...key,
noteId: note.id,
});
await this.latestNotesRepository.upsert(latestNote, ['userId', 'isPublic', 'isReply', 'isQuote']);
}
handleDeletedNoteBG(note: MiNote): void {
this
.handleDeletedNote(note)
.catch(err => this.logger.error('Unhandled exception while updating latest_note (after delete):', err));
}
async handleDeletedNote(note: MiNote): Promise<void> {
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
if (note.visibility === 'specified') return;
// If it's a pure renote, then it can't possibly be the latest note so we can safely skip this.
if (isPureRenote(note)) return;
// Compute the compound key of the entry to check
const key = SkLatestNote.keyFor(note);
// Check if the deleted note was possibly the latest for the user
const existingLatest = await this.latestNotesRepository.findOneBy(key);
if (existingLatest == null || existingLatest.noteId !== note.id) return;
// Find the newest remaining note for the user.
// We exclude DMs and pure renotes.
const nextLatest = await this.notesRepository
.createQueryBuilder('note')
.select()
.where({
userId: key.userId,
visibility: key.isPublic
? 'public'
: Not('specified'),
replyId: key.isReply
? Not(null)
: null,
renoteId: key.isQuote
? Not(null)
: null,
})
.andWhere(`
(
note."renoteId" IS NULL
OR note.text IS NOT NULL
OR note.cw IS NOT NULL
OR note."replyId" IS NOT NULL
OR note."hasPoll"
OR note."fileIds" != '{}'
)
`)
.orderBy({ id: 'DESC' })
.getOne();
if (!nextLatest) return;
// Record it as the latest
const latestNote = new SkLatestNote({
...key,
noteId: nextLatest.id,
});
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
await this.latestNotesRepository
.createQueryBuilder('latest')
.insert()
.into(SkLatestNote)
.values(latestNote)
.orIgnore()
.execute();
}
}

View file

@ -14,7 +14,7 @@ import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import { LatestNote } from '@/models/LatestNote.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, LatestNotesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@ -58,7 +58,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@ -172,9 +172,6 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@ -225,6 +222,7 @@ export class NoteCreateService implements OnApplicationShutdown {
private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
private cacheService: CacheService,
private latestNoteService: LatestNoteService,
) {
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
}
@ -530,8 +528,6 @@ export class NoteCreateService implements OnApplicationShutdown {
await this.notesRepository.insert(insert);
}
await this.updateLatestNote(insert);
return insert;
} catch (e) {
// duplicate key error
@ -812,6 +808,9 @@ export class NoteCreateService implements OnApplicationShutdown {
});
}
// Update the Latest Note index / following feed
this.latestNoteService.handleCreatedNoteBG(note);
// Register to search database
if (!user.noindex) this.index(note);
}
@ -1151,25 +1150,4 @@ export class NoteCreateService implements OnApplicationShutdown {
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
await this.dispose();
}
private async updateLatestNote(note: MiNote) {
// Ignore DMs.
// Followers-only posts are *included*, as this table is used to back the "following" feed.
if (note.visibility === 'specified') return;
// Ignore pure renotes
if (isRenote(note) && !isQuote(note)) return;
// Make sure that this isn't an *older* post.
// We can get older posts through replies, lookups, etc.
const currentLatest = await this.latestNotesRepository.findOneBy({ userId: note.userId });
if (currentLatest != null && currentLatest.noteId >= note.id) return;
// Record this as the latest note for the given user
const latestNote = new LatestNote({
userId: note.userId,
noteId: note.id,
});
await this.latestNotesRepository.upsert(latestNote, ['userId']);
}
}

View file

@ -3,12 +3,11 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Brackets, In, Not } from 'typeorm';
import { Brackets, In } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import { LatestNote } from '@/models/LatestNote.js';
import type { InstancesRepository, MiMeta, LatestNotesRepository, NotesRepository, UsersRepository } from '@/models/_.js';
import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { DI } from '@/di-symbols.js';
@ -24,6 +23,7 @@ import { bindThis } from '@/decorators.js';
import { SearchService } from '@/core/SearchService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
@Injectable()
export class NoteDeleteService {
@ -40,9 +40,6 @@ export class NoteDeleteService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.latestNotesRepository)
private latestNotesRepository: LatestNotesRepository,
@Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository,
@ -57,6 +54,7 @@ export class NoteDeleteService {
private notesChart: NotesChart,
private perUserNotesChart: PerUserNotesChart,
private instanceChart: InstanceChart,
private latestNoteService: LatestNoteService,
) {}
/**
@ -149,7 +147,7 @@ export class NoteDeleteService {
userId: user.id,
});
await this.updateLatestNote(note);
this.latestNoteService.handleDeletedNoteBG(note);
if (deleter && (note.userId !== deleter.id)) {
const user = await this.usersRepository.findOneByOrFail({ id: note.userId });
@ -232,52 +230,4 @@ export class NoteDeleteService {
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
}
}
private async updateLatestNote(note: MiNote) {
// If it's a DM, then it can't possibly be the latest note so we can safely skip this.
if (note.visibility === 'specified') return;
// Check if the deleted note was possibly the latest for the user
const hasLatestNote = await this.latestNotesRepository.existsBy({ userId: note.userId });
if (hasLatestNote) return;
// Find the newest remaining note for the user.
// We exclude DMs and pure renotes.
const nextLatest = await this.notesRepository
.createQueryBuilder('note')
.select()
.where({
userId: note.userId,
visibility: Not('specified'),
})
.andWhere(`
(
note."renoteId" IS NULL
OR note.text IS NOT NULL
OR note.cw IS NOT NULL
OR note."replyId" IS NOT NULL
OR note."hasPoll"
OR note."fileIds" != '{}'
)
`)
.orderBy({ id: 'DESC' })
.getOne();
if (!nextLatest) return;
// Record it as the latest
const latestNote = new LatestNote({
userId: note.userId,
noteId: nextLatest.id,
});
// When inserting the latest note, it's possible that another worker has "raced" the insert and already added a newer note.
// We must use orIgnore() to ensure that the query ignores conflicts, otherwise an exception may be thrown.
await this.latestNotesRepository
.createQueryBuilder('latest')
.insert()
.into(LatestNote)
.values(latestNote)
.orIgnore()
.execute();
}
}

View file

@ -50,6 +50,7 @@ import { isReply } from '@/misc/is-reply.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { LatestNoteService } from '@/core/LatestNoteService.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
@ -217,7 +218,7 @@ export class NoteEditService implements OnApplicationShutdown {
private utilityService: UtilityService,
private userBlockingService: UserBlockingService,
private cacheService: CacheService,
private noteCreateService: NoteCreateService,
private latestNoteService: LatestNoteService,
) {
this.updateNotesCountQueue = new CollapsedQueue(60 * 1000 * 5, this.collapseNotesCount, this.performUpdateNotesCount);
}
@ -563,7 +564,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
setImmediate('post edited', { signal: this.#shutdownController.signal }).then(
() => this.postNoteEdited(note, user, data, silent, tags!, mentionedUsers!),
() => this.postNoteEdited(note, oldnote, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
@ -574,7 +575,7 @@ export class NoteEditService implements OnApplicationShutdown {
}
@bindThis
private async postNoteEdited(note: MiNote, user: {
private async postNoteEdited(note: MiNote, oldNote: MiNote, user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
@ -771,6 +772,9 @@ export class NoteEditService implements OnApplicationShutdown {
});
}
// Update the Latest Note index / following feed
this.latestNoteService.handleUpdatedNoteBG(oldNote, note);
// Register to search database
if (!user.noindex) this.index(note);
}

View file

@ -59,7 +59,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
}
@bindThis
public parseUri(value: string | IObject): UriParseResult {
public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
const separator = '/';
const uri = new URL(getApId(value));
@ -78,7 +78,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
* AP Note => Misskey Note in DB
*/
@bindThis
public async getNoteFromApId(value: string | IObject): Promise<MiNote | null> {
public async getNoteFromApId(value: string | IObject | [string | IObject]): Promise<MiNote | null> {
const parsed = this.parseUri(value);
if (parsed.local) {
@ -98,7 +98,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
* AP Person => Misskey User in DB
*/
@bindThis
public async getUserFromApId(value: string | IObject): Promise<MiLocalUser | MiRemoteUser | null> {
public async getUserFromApId(value: string | IObject | [string | IObject]): Promise<MiLocalUser | MiRemoteUser | null> {
const parsed = this.parseUri(value);
if (parsed.local) {

View file

@ -40,6 +40,7 @@ import { ApPersonService } from './models/ApPersonService.js';
import { ApQuestionService } from './models/ApQuestionService.js';
import type { Resolver } from './ApResolverService.js';
import type { IAccept, IAdd, IAnnounce, IBlock, ICreate, IDelete, IFlag, IFollow, ILike, IObject, IReject, IRemove, IUndo, IUpdate, IMove, IPost } from './type.js';
import { fromTuple } from '@/misc/from-tuple.js';
@Injectable()
export class ApInboxService {
@ -254,7 +255,8 @@ export class ApInboxService {
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
const object = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(object);
if (note == null) return 'note not found';
await this.notePiningService.addPinned(actor, note.id);
return;
@ -271,11 +273,12 @@ export class ApInboxService {
const resolver = this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
const activityObject = fromTuple(activity.object);
if (!activityObject) return 'skip: activity has no object property';
const targetUri = getApId(activityObject);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
const target = await resolver.resolve(activity.object).catch(e => {
const target = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
return e;
});
@ -370,29 +373,30 @@ export class ApInboxService {
this.logger.info(`Create: ${uri}`);
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
const activityObject = fromTuple(activity.object);
if (!activityObject) return 'skip: activity has no object property';
const targetUri = getApId(activityObject);
if (targetUri.startsWith('bear:')) return 'skip: bearcaps url not supported.';
// copy audiences between activity <=> object.
if (typeof activity.object === 'object') {
const to = unique(concat([toArray(activity.to), toArray(activity.object.to)]));
const cc = unique(concat([toArray(activity.cc), toArray(activity.object.cc)]));
if (typeof activityObject === 'object') {
const to = unique(concat([toArray(activity.to), toArray(activityObject.to)]));
const cc = unique(concat([toArray(activity.cc), toArray(activityObject.cc)]));
activity.to = to;
activity.cc = cc;
activity.object.to = to;
activity.object.cc = cc;
activityObject.to = to;
activityObject.cc = cc;
}
// If there is no attributedTo, use Activity actor.
if (typeof activity.object === 'object' && !activity.object.attributedTo) {
activity.object.attributedTo = activity.actor;
if (typeof activityObject === 'object' && !activityObject.attributedTo) {
activityObject.attributedTo = activity.actor;
}
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
const object = await resolver.resolve(activityObject).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
throw e;
});
@ -448,15 +452,15 @@ export class ApInboxService {
// 削除対象objectのtype
let formerType: string | undefined;
if (typeof activity.object === 'string') {
const activityObject = fromTuple(activity.object);
if (typeof activityObject === 'string') {
// typeが不明だけど、どうせ消えてるのでremote resolveしない
formerType = undefined;
} else {
const object = activity.object;
if (isTombstone(object)) {
formerType = toSingle(object.formerType);
if (isTombstone(activityObject)) {
formerType = toSingle(activityObject.formerType);
} else {
formerType = toSingle(object.type);
formerType = toSingle(activityObject.type);
}
}
@ -616,7 +620,8 @@ export class ApInboxService {
}
if (activity.target === actor.featured) {
const note = await this.apNoteService.resolveNote(activity.object);
const activityObject = fromTuple(activity.object);
const note = await this.apNoteService.resolveNote(activityObject);
if (note == null) return 'note not found';
await this.notePiningService.removePinned(actor, note.id);
return;

View file

@ -200,7 +200,8 @@ export class ApRendererService {
type: 'Flag',
actor: this.userEntityService.genLocalUserUri(user.id),
content,
object,
// This MUST be an array for Pleroma compatibility: https://activitypub.software/TransFem-org/Sharkey/-/issues/641#note_7301
object: [object],
};
}

View file

@ -20,6 +20,7 @@ import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { fromTuple } from '@/misc/from-tuple.js';
export class Resolver {
private history: Set<string>;
@ -66,7 +67,10 @@ export class Resolver {
}
@bindThis
public async resolve(value: string | IObject): Promise<IObject> {
public async resolve(value: string | IObject | [string | IObject]): Promise<IObject> {
// eslint-disable-next-line no-param-reassign
value = fromTuple(value);
if (typeof value !== 'string') {
return value;
}

View file

@ -3,6 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { fromTuple } from '@/misc/from-tuple.js';
export type Obj = { [x: string]: any };
export type ApObject = IObject | string | (IObject | string)[];
@ -53,10 +55,13 @@ export function getOneApId(value: ApObject): string {
/**
* Get ActivityStreams Object id
*/
export function getApId(value: string | IObject): string {
export function getApId(value: string | IObject | [string | IObject]): string {
// eslint-disable-next-line no-param-reassign
value = fromTuple(value);
if (typeof value === 'string') return value;
if (typeof value.id === 'string') return value.id;
throw new Error('cannot detemine id');
throw new Error('cannot determine id');
}
/**
@ -85,7 +90,9 @@ export function getApHrefNullable(value: string | IObject | undefined): string |
export interface IActivity extends IObject {
//type: 'Activity';
actor: IObject | string;
object: IObject | string;
// ActivityPub spec allows for arrays: https://www.w3.org/TR/activitystreams-vocabulary/#properties
// Misskey can only handle one value, so we use a tuple for that case.
object: IObject | string | [IObject | string] ;
target?: IObject | string;
/** LD-Signature */
signature?: {

View file

@ -374,6 +374,13 @@ export class UserEntityService implements OnModuleInit {
return count > 0;
}
@bindThis
public async getHasPendingSentFollowRequest(userId: MiUser['id']): Promise<boolean> {
return this.followRequestsRepository.existsBy({
followerId: userId,
});
}
@bindThis
public getOnlineStatus(user: MiUser): 'unknown' | 'online' | 'active' | 'offline' {
if (user.hideOnlineStatus) return 'unknown';
@ -643,6 +650,7 @@ export class UserEntityService implements OnModuleInit {
hasUnreadChannel: false, // 後方互換性のため
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords,

View file

@ -0,0 +1,7 @@
export function fromTuple<T>(value: T | [T]): T {
if (Array.isArray(value)) {
return value[0];
}
return value;
}

View file

@ -23,6 +23,17 @@ type Quote =
hasPoll: true
});
type PureRenote =
Renote & {
text: null,
cw: null,
replyId: null,
hasPoll: false,
fileIds: {
length: 0,
},
};
export function isRenote(note: MiNote): note is Renote {
return note.renoteId != null;
}
@ -36,6 +47,10 @@ export function isQuote(note: Renote): note is Quote {
note.fileIds.length > 0;
}
export function isPureRenote(note: MiNote): note is PureRenote {
return isRenote(note) && !isQuote(note);
}
type PackedRenote =
Packed<'Note'> & {
renoteId: NonNullable<Packed<'Note'>['renoteId']>

View file

@ -6,6 +6,7 @@
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne } from 'typeorm';
import { MiUser } from '@/models/User.js';
import { MiNote } from '@/models/Note.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
/**
* Maps a user to the most recent post by that user.
@ -13,7 +14,7 @@ import { MiNote } from '@/models/Note.js';
* DMs are not counted.
*/
@Entity('latest_note')
export class LatestNote {
export class SkLatestNote {
@PrimaryColumn({
name: 'user_id',
type: 'varchar' as const,
@ -21,6 +22,24 @@ export class LatestNote {
})
public userId: string;
@PrimaryColumn('boolean', {
name: 'is_public',
default: false,
})
public isPublic: boolean;
@PrimaryColumn('boolean', {
name: 'is_reply',
default: false,
})
public isReply: boolean;
@PrimaryColumn('boolean', {
name: 'is_quote',
default: false,
})
public isQuote: boolean;
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@ -44,11 +63,38 @@ export class LatestNote {
})
public note: MiNote | null;
constructor(data?: Partial<LatestNote>) {
constructor(data?: Partial<SkLatestNote>) {
if (!data) return;
for (const [k, v] of Object.entries(data)) {
(this as Record<string, unknown>)[k] = v;
}
}
/**
* Generates a compound key matching a provided note.
*/
static keyFor(note: MiNote) {
return {
userId: note.userId,
isPublic: note.visibility === 'public',
isReply: note.replyId != null,
isQuote: isRenote(note) && isQuote(note),
};
}
/**
* Checks if two notes would produce equivalent compound keys.
*/
static areEquivalent(first: MiNote, second: MiNote): boolean {
const firstKey = SkLatestNote.keyFor(first);
const secondKey = SkLatestNote.keyFor(second);
return (
firstKey.userId === secondKey.userId &&
firstKey.isPublic === secondKey.isPublic &&
firstKey.isReply === secondKey.isReply &&
firstKey.isQuote === secondKey.isQuote
);
}
}

View file

@ -7,7 +7,7 @@ import type { Provider } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import {
LatestNote,
SkLatestNote,
MiAbuseReportNotificationRecipient,
MiAbuseUserReport,
MiAccessToken,
@ -121,7 +121,7 @@ const $avatarDecorationsRepository: Provider = {
const $latestNotesRepository: Provider = {
provide: DI.latestNotesRepository,
useFactory: (db: DataSource) => db.getRepository(LatestNote).extend(miRepository as MiRepository<LatestNote>),
useFactory: (db: DataSource) => db.getRepository(SkLatestNote).extend(miRepository as MiRepository<SkLatestNote>),
inject: [DI.db],
};

View file

@ -10,7 +10,7 @@ import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLo
import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js';
import { ObjectUtils } from 'typeorm/util/ObjectUtils.js';
import { OrmUtils } from 'typeorm/util/OrmUtils.js';
import { LatestNote } from '@/models/LatestNote.js';
import { SkLatestNote } from '@/models/LatestNote.js';
import { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js';
import { MiAccessToken } from '@/models/AccessToken.js';
@ -127,7 +127,7 @@ export const miRepository = {
} satisfies MiRepository<ObjectLiteral>;
export {
LatestNote,
SkLatestNote,
MiAbuseUserReport,
MiAbuseReportNotificationRecipient,
MiAccessToken,
@ -226,7 +226,7 @@ export type GalleryPostsRepository = Repository<MiGalleryPost> & MiRepository<Mi
export type HashtagsRepository = Repository<MiHashtag> & MiRepository<MiHashtag>;
export type InstancesRepository = Repository<MiInstance> & MiRepository<MiInstance>;
export type MetasRepository = Repository<MiMeta> & MiRepository<MiMeta>;
export type LatestNotesRepository = Repository<LatestNote> & MiRepository<LatestNote>;
export type LatestNotesRepository = Repository<SkLatestNote> & MiRepository<SkLatestNote>;
export type ModerationLogsRepository = Repository<MiModerationLog> & MiRepository<MiModerationLog>;
export type MutingsRepository = Repository<MiMuting> & MiRepository<MiMuting>;
export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<MiRenoteMuting>;

View file

@ -590,6 +590,10 @@ export const packedMeDetailedOnlySchema = {
type: 'boolean',
nullable: false, optional: false,
},
hasPendingSentFollowRequest: {
type: 'boolean',
nullable: false, optional: false,
},
unreadNotificationsCount: {
type: 'number',
nullable: false, optional: false,

View file

@ -83,7 +83,7 @@ import { MiReversiGame } from '@/models/ReversiGame.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { LatestNote } from '@/models/LatestNote.js';
import { SkLatestNote } from '@/models/LatestNote.js';
pg.types.setTypeParser(20, Number);
@ -131,7 +131,7 @@ class MyCustomLogger implements Logger {
}
export const entities = [
LatestNote,
SkLatestNote,
MiAnnouncement,
MiAnnouncementRead,
MiMeta,

View file

@ -190,6 +190,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@ -589,6 +590,7 @@ const $following_invalidate: Provider = { provide: 'ep:following/invalidate', us
const $following_requests_accept: Provider = { provide: 'ep:following/requests/accept', useClass: ep___following_requests_accept.default };
const $following_requests_cancel: Provider = { provide: 'ep:following/requests/cancel', useClass: ep___following_requests_cancel.default };
const $following_requests_list: Provider = { provide: 'ep:following/requests/list', useClass: ep___following_requests_list.default };
const $following_requests_sent: Provider = { provide: 'ep:following/requests/sent', useClass: ep___following_requests_sent.default };
const $following_requests_reject: Provider = { provide: 'ep:following/requests/reject', useClass: ep___following_requests_reject.default };
const $gallery_featured: Provider = { provide: 'ep:gallery/featured', useClass: ep___gallery_featured.default };
const $gallery_popular: Provider = { provide: 'ep:gallery/popular', useClass: ep___gallery_popular.default };
@ -992,6 +994,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$following_requests_accept,
$following_requests_cancel,
$following_requests_list,
$following_requests_sent,
$following_requests_reject,
$gallery_featured,
$gallery_popular,

View file

@ -196,6 +196,7 @@ import * as ep___following_invalidate from './endpoints/following/invalidate.js'
import * as ep___following_requests_accept from './endpoints/following/requests/accept.js';
import * as ep___following_requests_cancel from './endpoints/following/requests/cancel.js';
import * as ep___following_requests_list from './endpoints/following/requests/list.js';
import * as ep___following_requests_sent from './endpoints/following/requests/sent.js';
import * as ep___following_requests_reject from './endpoints/following/requests/reject.js';
import * as ep___gallery_featured from './endpoints/gallery/featured.js';
import * as ep___gallery_popular from './endpoints/gallery/popular.js';
@ -593,6 +594,7 @@ const eps = [
['following/requests/accept', ep___following_requests_accept],
['following/requests/cancel', ep___following_requests_cancel],
['following/requests/list', ep___following_requests_list],
['following/requests/sent', ep___following_requests_sent],
['following/requests/reject', ep___following_requests_reject],
['gallery/featured', ep___gallery_featured],
['gallery/popular', ep___gallery_popular],

View file

@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import type { FollowRequestsRepository } from '@/models/_.js';
import { FollowRequestEntityService } from '@/core/entities/FollowRequestEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['following', 'account'],
requireCredential: true,
kind: 'read:following',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
follower: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
followee: {
type: 'object',
optional: false, nullable: false,
ref: 'UserLite',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private followRequestEntityService: FollowRequestEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.followRequestsRepository.createQueryBuilder('request'), ps.sinceId, ps.untilId)
.andWhere('request.followerId = :meId', { meId: me.id });
const requests = await query
.limit(ps.limit)
.getMany();
return await this.followRequestEntityService.packMany(requests, me);
});
}
}

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { LatestNote, MiFollowing } from '@/models/_.js';
import { SkLatestNote, MiFollowing } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
@ -33,6 +33,12 @@ export const paramDef = {
type: 'object',
properties: {
mutualsOnly: { type: 'boolean', default: false },
filesOnly: { type: 'boolean', default: false },
includeNonPublic: { type: 'boolean', default: false },
includeReplies: { type: 'boolean', default: false },
includeQuotes: { type: 'boolean', default: false },
includeBots: { type: 'boolean', default: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@ -52,12 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
let query = this.notesRepository
const query = this.notesRepository
.createQueryBuilder('note')
.setParameter('me', me.id)
// Limit to latest notes
.innerJoin(LatestNote, 'latest', 'note.id = latest.note_id')
.innerJoin(SkLatestNote, 'latest', 'note.id = latest.note_id')
// Avoid N+1 queries from the "pack" method
.innerJoinAndSelect('note.user', 'user')
@ -73,8 +79,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Limit to mutuals, if requested
if (ps.mutualsOnly) {
query = query
.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me');
query.innerJoin(MiFollowing, 'mutuals', 'latest.user_id = mutuals."followerId" AND mutuals."followeeId" = :me');
}
// Limit to files, if requested
if (ps.filesOnly) {
query.andWhere('note."fileIds" != \'{}\'');
}
// Match selected note types.
if (!ps.includeNonPublic) {
query.andWhere('latest.is_public');
}
if (!ps.includeReplies) {
query.andWhere('latest.is_reply = false');
}
if (!ps.includeQuotes) {
query.andWhere('latest.is_quote = false');
}
// Match selected user types.
if (!ps.includeBots) {
query.andWhere('"user"."isBot" = false');
}
// Respect blocks and mutes
@ -82,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQuery(query, me);
// Support pagination
query = this.queryService
this.queryService
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.orderBy('note.id', 'DESC')
.take(ps.limit);

View file

@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { FanoutTimelineName } from '@/core/FanoutTimelineService.js';
import { ApiError } from '@/server/api/error.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
export const meta = {
tags: ['users', 'notes'],
@ -50,7 +51,11 @@ export const paramDef = {
properties: {
userId: { type: 'string', format: 'misskey:id' },
withReplies: { type: 'boolean', default: false },
withRepliesToSelf: { type: 'boolean', default: true },
withQuotes: { type: 'boolean', default: true },
withRenotes: { type: 'boolean', default: true },
withBots: { type: 'boolean', default: true },
withNonPublic: { type: 'boolean', default: true },
withChannelNotes: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
@ -102,6 +107,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
withQuotes: ps.withQuotes,
withBots: ps.withBots,
withNonPublic: ps.withNonPublic,
withRepliesToOthers: ps.withReplies,
withRepliesToSelf: ps.withRepliesToSelf,
}, me);
return await this.noteEntityService.packMany(timeline, me);
@ -126,11 +136,17 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies
excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files
excludePureRenotes: !ps.withRenotes,
excludeBots: !ps.withBots,
noteFilter: note => {
if (note.channel?.isSensitive && !isSelf) return false;
if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false;
if (note.visibility === 'followers' && !isFollowing && !isSelf) return false;
// These are handled by DB fallback, but we duplicate them here in case a timeline was already populated with notes
if (!ps.withRepliesToSelf && note.reply?.userId === note.userId) return false;
if (!ps.withQuotes && isRenote(note) && isQuote(note)) return false;
if (!ps.withNonPublic && note.visibility !== 'public') return false;
return true;
},
dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({
@ -141,6 +157,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: ps.withChannelNotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
withQuotes: ps.withQuotes,
withBots: ps.withBots,
withNonPublic: ps.withNonPublic,
withRepliesToOthers: ps.withReplies,
withRepliesToSelf: ps.withRepliesToSelf,
}, me),
});
@ -156,6 +177,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withChannelNotes: boolean,
withFiles: boolean,
withRenotes: boolean,
withQuotes: boolean,
withBots: boolean,
withNonPublic: boolean,
withRepliesToOthers: boolean,
withRepliesToSelf: boolean,
}, me: MiLocalUser | null) {
const isSelf = me && (me.id === ps.userId);
@ -187,7 +213,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
if (!ps.withRenotes && !ps.withQuotes) {
query.andWhere('note.renoteId IS NULL');
} else if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: ps.userId });
qb.orWhere('note.renoteId IS NULL');
@ -195,6 +223,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
} else if (!ps.withQuotes) {
query.andWhere(`
(
note."renoteId" IS NULL
OR (
note.text IS NULL
AND note.cw IS NULL
AND note."replyId" IS NULL
AND note."hasPoll" IS FALSE
AND note."fileIds" = '{}'
)
)
`);
}
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
query.andWhere('reply.id IS NULL');
} else if (!ps.withRepliesToOthers) {
query.andWhere('(reply.id IS NULL OR reply."userId" = note."userId")');
} else if (!ps.withRepliesToSelf) {
query.andWhere('(reply.id IS NULL OR reply."userId" != note."userId")');
}
if (!ps.withNonPublic) {
query.andWhere('note.visibility = \'public\'');
}
if (!ps.withBots) {
query.andWhere('"user"."isBot" = false');
}
return await query.limit(ps.limit).getMany();

View file

@ -22,8 +22,17 @@
return;
}
// Force update when locales change
const langsVersion = LANGS_VERSION;
const localeVersion = localStorage.getItem('localeVersion');
if (localeVersion !== langsVersion) {
console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`);
localStorage.removeItem('localeVersion');
localStorage.removeItem('locale');
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
if (!localStorage.getItem('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
@ -37,37 +46,17 @@
}
}
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
const localRes = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
localStorage.setItem('localeVersion', langsVersion);
} else {
renderError('LOCALE_FETCH');
return;

View file

@ -141,6 +141,7 @@ describe('ユーザー', () => {
hasUnreadNotification: user.hasUnreadNotification,
unreadNotificationsCount: user.unreadNotificationsCount,
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
hasPendingSentFollowRequest: user.hasPendingSentFollowRequest,
unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords,
hardMutedWords: user.hardMutedWords,
@ -381,6 +382,7 @@ describe('ユーザー', () => {
assert.strictEqual(response.hasUnreadNotification, false);
assert.strictEqual(response.unreadNotificationsCount, 0);
assert.strictEqual(response.hasPendingReceivedFollowRequest, false);
assert.strictEqual(response.hasPendingSentFollowRequest, false);
assert.deepStrictEqual(response.unreadAnnouncements, []);
assert.deepStrictEqual(response.mutedWords, []);
assert.deepStrictEqual(response.mutedInstances, []);

View file

@ -0,0 +1,13 @@
import { fromTuple } from '@/misc/from-tuple.js';
describe(fromTuple, () => {
it('should return value when value is not an array', () => {
const value = fromTuple('abc');
expect(value).toBe('abc');
});
it('should return first element when value is an array', () => {
const value = fromTuple(['abc']);
expect(value).toBe('abc');
});
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import { MiNote } from '@/models/Note.js';
const base: MiNote = {
@ -86,4 +86,24 @@ describe('misc:is-renote', () => {
expect(isRenote(note)).toBe(true);
expect(isQuote(note as any)).toBe(true);
});
describe('isPureRenote', () => {
it('should return true when note is pure renote', () => {
const note = new MiNote({ renoteId: 'abc123', fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeTruthy();
});
it('should return false when note is quote', () => {
const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeFalsy();
});
it('should return false when note is not renote', () => {
const note = new MiNote({ renoteId: null, fileIds: [] });
const result = isPureRenote(note);
expect(result).toBeFalsy();
});
});
});

View file

@ -0,0 +1,149 @@
import { SkLatestNote } from '@/models/LatestNote.js';
import { MiNote } from '@/models/Note.js';
describe(SkLatestNote, () => {
describe('keyFor', () => {
it('should include userId', () => {
const note = new MiNote({ userId: 'abc123', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.userId).toBe(note.userId);
});
it('should include isPublic when is public', () => {
const note = new MiNote({ visibility: 'public', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeTruthy();
});
it('should include isPublic when is home-only', () => {
const note = new MiNote({ visibility: 'home', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isPublic when is followers-only', () => {
const note = new MiNote({ visibility: 'followers', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isPublic when is specified', () => {
const note = new MiNote({ visibility: 'specified', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isPublic).toBeFalsy();
});
it('should include isReply when is reply', () => {
const note = new MiNote({ replyId: 'abc123', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isReply).toBeTruthy();
});
it('should include isReply when is not reply', () => {
const note = new MiNote({ replyId: null, fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isReply).toBeFalsy();
});
it('should include isQuote when is quote', () => {
const note = new MiNote({ renoteId: 'abc123', text: 'text', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isQuote).toBeTruthy();
});
it('should include isQuote when is reblog', () => {
const note = new MiNote({ renoteId: 'abc123', fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isQuote).toBeFalsy();
});
it('should include isQuote when is neither quote nor reblog', () => {
const note = new MiNote({ renoteId: null, fileIds: [] });
const key = SkLatestNote.keyFor(note);
expect(key.isQuote).toBeFalsy();
});
});
describe('areEquivalent', () => {
it('should return true when keys match', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeTruthy();
});
it('should return true when keys match with different reply IDs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: '3', renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: '4', renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeTruthy();
});
it('should return true when keys match with different renote IDs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '3', fileIds: ['1'] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '4', fileIds: ['1'] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeTruthy();
});
it('should return true when keys match with different file counts', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: ['1'] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: ['1','2'] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeTruthy();
});
it('should return true when keys match with different private visibilities', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'home', replyId: null, renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'followers', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeTruthy();
});
it('should return false when user ID differs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'def456', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeFalsy();
});
it('should return false when visibility differs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'home', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeFalsy();
});
it('should return false when reply differs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: '1', renoteId: null, fileIds: [] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeFalsy();
});
it('should return false when quote differs', () => {
const first = new MiNote({ id: '1', userId: 'abc123', visibility: 'public', replyId: null, renoteId: '3', fileIds: ['1'] });
const second = new MiNote({ id: '2', userId: 'abc123', visibility: 'public', replyId: null, renoteId: null, fileIds: [] });
const result = SkLatestNote.areEquivalent(first, second);
expect(result).toBeFalsy();
});
});
});

View file

@ -6,6 +6,7 @@
type FIXME = any;
declare const _LANGS_: string[][];
declare const _LANGS_VERSION_: string;
declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;

View file

@ -1,7 +1,7 @@
import path from 'path';
import pluginVue from '@vitejs/plugin-vue';
import { type UserConfig, defineConfig } from 'vite';
import { localesVersion } from '../../locales/version.js';
import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' };
@ -95,6 +95,7 @@ export function getConfig(): UserConfig {
define: {
_VERSION_: JSON.stringify(meta.version),
_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
_LANGS_VERSION_: JSON.stringify(localesVersion),
_ENV_: JSON.stringify(process.env.NODE_ENV),
_DEV_: process.env.NODE_ENV !== 'production',
_PERF_PREFIX_: JSON.stringify('Misskey:'),

View file

@ -7,6 +7,7 @@
type FIXME = any;
declare const _LANGS_: string[][];
declare const _LANGS_VERSION_: string;
declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;

View file

@ -16,6 +16,7 @@ export const apiUrl = location.origin + '/api';
export const wsOrigin = location.origin;
export const lang = localStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_;
export const langsVersion = _LANGS_VERSION_;
const preParseLocale = localStorage.getItem('locale');
export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null;
export const version = _VERSION_;

View file

@ -6,6 +6,7 @@
type FIXME = any;
declare const _LANGS_: string[][];
declare const _LANGS_VERSION_: string;
declare const _VERSION_: string;
declare const _ENV_: string;
declare const _DEV_: boolean;

View file

@ -5,7 +5,7 @@
import { computed, watch, version as vueVersion, App } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale } from '@@/js/config.js';
import { version, lang, langsVersion, updateLocale, locale } from '@@/js/config.js';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
import components from '@/components/index.js';
@ -81,14 +81,15 @@ export async function common(createVue: () => App<Element>) {
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
const localeOutdated = (localeVersion == null || localeVersion !== langsVersion || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
console.info(`Updating locales from version ${localeVersion ?? 'N/A'} to ${langsVersion}`);
const res = await window.fetch(`/assets/locales/${lang}.${langsVersion}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
miLocalStorage.setItem('localeVersion', langsVersion);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}

View file

@ -24,16 +24,14 @@ import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import { Paging } from '@/components/MkPagination.vue';
import { misskeyApi } from '@/scripts/misskey-api.js';
const props = withDefaults(defineProps<{
const props = defineProps<{
userId: string;
withRenotes?: boolean;
withReplies?: boolean;
onlyFiles?: boolean;
}>(), {
withRenotes: false,
withReplies: true,
onlyFiles: false,
});
withNonPublic: boolean;
withQuotes: boolean;
withReplies: boolean;
withBots: boolean;
onlyFiles: boolean;
}>();
const loadError: Ref<string | null> = ref(null);
const user: Ref<Misskey.entities.UserDetailed | null> = ref(null);
@ -43,9 +41,13 @@ const pagination: Paging<'users/notes'> = {
limit: 10,
params: computed(() => ({
userId: props.userId,
withRenotes: props.withRenotes,
withNonPublic: props.withNonPublic,
withRenotes: false,
withQuotes: props.withQuotes,
withReplies: props.withReplies,
withRepliesToSelf: props.withReplies,
withFiles: props.onlyFiles,
allowPartial: true,
})),
};

View file

@ -41,7 +41,7 @@ export const navbarItemDef = reactive({
followRequests: {
title: i18n.ts.followRequests,
icon: 'ti ti-user-plus',
show: computed(() => $i != null && $i.isLocked),
show: computed(() => $i != null && ($i.isLocked || $i.hasPendingReceivedFollowRequest || $i.hasPendingSentFollowRequest)),
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
to: '/my/follow-requests',
},

View file

@ -5,39 +5,43 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><MkPageHeader/></template>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.noFollowRequests }}</div>
</div>
</template>
<template #default="{items}">
<div class="mk-follow-requests">
<div v-for="req in items" :key="req.id" class="user _panel">
<MkAvatar class="avatar" :user="req.follower" indicator link preview/>
<div class="body">
<div class="name">
<MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA>
<p class="acct">@{{ acct(req.follower) }}</p>
</div>
<div class="commands">
<MkButton class="command" rounded primary @click="accept(req.follower)"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton class="command" rounded danger @click="reject(req.follower)"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<div :key="tab" class="_gaps">
<MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.noFollowRequests }}</div>
</div>
</template>
<template #default="{items}">
<div class="mk-follow-requests">
<div v-for="req in items" :key="req.id" class="user _panel">
<MkAvatar class="avatar" :user="displayUser(req)" indicator link preview/>
<div class="body">
<div class="name">
<MkA v-user-preview="displayUser(req).id" class="name" :to="userPage(displayUser(req))"><MkUserName :user="displayUser(req)"/></MkA>
<p class="acct">@{{ acct(displayUser(req)) }}</p>
</div>
<div v-if="tab === 'list'" class="commands">
<MkButton class="command" rounded primary @click="accept(displayUser(req))"><i class="ti ti-check"/> {{ i18n.ts.accept }}</MkButton>
<MkButton class="command" rounded danger @click="reject(displayUser(req))"><i class="ti ti-x"/> {{ i18n.ts.reject }}</MkButton>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</MkPagination>
</template>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { shallowRef, computed } from 'vue';
import { shallowRef, computed, ref } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import { userPage, acct } from '@/filters/user.js';
@ -45,29 +49,53 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import { infoImageUrl } from '@/instance.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import { $i } from '@/account';
const paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination = {
endpoint: 'following/requests/list' as const,
limit: 10,
};
const pagination = computed(() => tab.value === 'list'
? {
endpoint: 'following/requests/list' as const,
limit: 10,
}
: {
endpoint: 'following/requests/sent' as const,
limit: 10,
},
);
function accept(user) {
misskeyApi('following/requests/accept', { userId: user.id }).then(() => {
paginationComponent.value.reload();
paginationComponent.value?.reload();
});
}
function reject(user) {
misskeyApi('following/requests/reject', { userId: user.id }).then(() => {
paginationComponent.value.reload();
paginationComponent.value?.reload();
});
}
function displayUser(req) {
return tab.value === 'list' ? req.follower : req.followee;
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
const headerTabs = computed(() => [
{
key: 'list',
title: i18n.ts.followRequests,
icon: 'ph-envelope ph-bold ph-lg',
}, {
key: 'sent',
title: i18n.ts.pendingFollowRequests,
icon: 'ph-paper-plane-tilt ph-bold ph-lg',
},
]);
const tab = ref($i?.isLocked || !$i.hasPendingSentFollowRequest ? 'list' : 'sent');
definePageMetadata(() => ({
title: i18n.ts.followRequests,

View file

@ -30,18 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="isWideViewport" ref="userScroll" :class="$style.user">
<MkHorizontalSwipe v-if="selectedUserId" v-model:tab="currentTab" :tabs="headerTabs">
<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withRenotes="withUserRenotes" :withReplies="withUserReplies" :onlyFiles="withOnlyFiles"/>
<SkUserRecentNotes ref="userRecentNotes" :userId="selectedUserId" :withNonPublic="withNonPublic" :withQuotes="withQuotes" :withBots="withBots" :withReplies="withReplies" :onlyFiles="onlyFiles"/>
</MkHorizontalSwipe>
</div>
</div>
</template>
<script lang="ts">
export type FollowingFeedTab = typeof followingTab | typeof mutualsTab;
export const followingTab = 'following' as const;
export const mutualsTab = 'mutuals' as const;
</script>
<script lang="ts" setup>
import { computed, Ref, ref, shallowRef } from 'vue';
import * as Misskey from 'misskey-js';
@ -63,20 +57,49 @@ import { checkWordMute } from '@/scripts/check-word-mute.js';
import SkUserRecentNotes from '@/components/SkUserRecentNotes.vue';
import { useScrollPositionManager } from '@/nirax.js';
import { getScrollContainer } from '@@/js/scroll.js';
import { defaultStore } from '@/store.js';
import { deepMerge } from '@/scripts/merge.js';
const props = withDefaults(defineProps<{
initialTab?: FollowingFeedTab,
}>(), {
initialTab: followingTab,
const withNonPublic = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withNonPublic,
set: value => saveFollowingFilter('withNonPublic', value),
});
const withQuotes = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withQuotes,
set: value => saveFollowingFilter('withQuotes', value),
});
const withBots = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withBots,
set: value => saveFollowingFilter('withBots', value),
});
const withReplies = computed({
get: () => defaultStore.reactiveState.followingFeed.value.withReplies,
set: value => saveFollowingFilter('withReplies', value),
});
const onlyFiles = computed({
get: () => defaultStore.reactiveState.followingFeed.value.onlyFiles,
set: value => saveFollowingFilter('onlyFiles', value),
});
const onlyMutuals = computed({
get: () => defaultStore.reactiveState.followingFeed.value.onlyMutuals,
set: value => saveFollowingFilter('onlyMutuals', value),
});
// Based on timeline.saveTlFilter()
function saveFollowingFilter(key: keyof typeof defaultStore.state.followingFeed, value: boolean) {
const out = deepMerge({ [key]: value }, defaultStore.state.followingFeed);
defaultStore.set('followingFeed', out);
}
const router = useRouter();
// Vue complains, but we *want* to lose reactivity here.
// Otherwise, the user would be unable to change the tab.
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
const currentTab: Ref<FollowingFeedTab> = ref(props.initialTab);
const mutualsOnly: Ref<boolean> = computed(() => currentTab.value === mutualsTab);
const followingTab = 'following' as const;
const mutualsTab = 'mutuals' as const;
const currentTab = computed({
get: () => onlyMutuals.value ? mutualsTab : followingTab,
set: value => onlyMutuals.value = (value === mutualsTab),
});
const userRecentNotes = shallowRef<InstanceType<typeof SkUserRecentNotes>>();
const userScroll = shallowRef<HTMLElement>();
const noteScroll = shallowRef<HTMLElement>();
@ -161,55 +184,60 @@ const latestNotesPagination: Paging<'notes/following'> = {
endpoint: 'notes/following' as const,
limit: 20,
params: computed(() => ({
mutualsOnly: mutualsOnly.value,
mutualsOnly: onlyMutuals.value,
filesOnly: onlyFiles.value,
includeNonPublic: withNonPublic.value,
includeReplies: withReplies.value,
includeQuotes: withQuotes.value,
includeBots: withBots.value,
})),
};
const withUserRenotes = ref(false);
const withUserReplies = ref(true);
const withOnlyFiles = ref(false);
const headerActions = computed(() => {
const actions: PageHeaderItem[] = [
{
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: () => reload(),
const headerActions: PageHeaderItem[] = [
{
icon: 'ti ti-refresh',
text: i18n.ts.reload,
handler: () => reload(),
},
{
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([
{
type: 'switch',
text: i18n.ts.showNonPublicNotes,
ref: withNonPublic,
},
{
type: 'switch',
text: i18n.ts.showQuotes,
ref: withQuotes,
},
{
type: 'switch',
text: i18n.ts.showBots,
ref: withBots,
},
{
type: 'switch',
text: i18n.ts.showReplies,
ref: withReplies,
disabled: onlyFiles,
},
{
type: 'divider',
},
{
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: onlyFiles,
disabled: withReplies,
},
], ev.currentTarget ?? ev.target);
},
];
if (isWideViewport.value) {
actions.push({
icon: 'ti ti-dots',
text: i18n.ts.options,
handler: (ev) => {
os.popupMenu([
{
type: 'switch',
text: i18n.ts.showRenotes,
ref: withUserRenotes,
}, {
type: 'switch',
text: i18n.ts.showRepliesToOthersInTimeline,
ref: withUserReplies,
disabled: withOnlyFiles,
},
{
type: 'divider',
},
{
type: 'switch',
text: i18n.ts.fileAttachedOnly,
ref: withOnlyFiles,
disabled: withUserReplies,
},
], ev.currentTarget ?? ev.target);
},
});
}
return actions;
});
},
];
const headerTabs = computed(() => [
{

View file

@ -241,6 +241,17 @@ export const defaultStore = markRaw(new Storage('base', {
where: 'deviceAccount',
default: [] as Misskey.entities.UserList[],
},
followingFeed: {
where: 'account',
default: {
withNonPublic: false,
withQuotes: false,
withBots: true,
withReplies: false,
onlyFiles: false,
onlyMutuals: false,
},
},
overridedDeviceKind: {
where: 'device',

View file

@ -2,7 +2,7 @@ import path from 'path';
import pluginReplace from '@rollup/plugin-replace';
import pluginVue from '@vitejs/plugin-vue';
import { type UserConfig, defineConfig } from 'vite';
import { localesVersion } from '../../locales/version.js';
import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' };
@ -114,6 +114,7 @@ export function getConfig(): UserConfig {
define: {
_VERSION_: JSON.stringify(meta.version),
_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
_LANGS_VERSION_: JSON.stringify(localesVersion),
_ENV_: JSON.stringify(process.env.NODE_ENV),
_DEV_: process.env.NODE_ENV !== 'production',
_PERF_PREFIX_: JSON.stringify('Misskey:'),

View file

@ -1512,6 +1512,8 @@ declare namespace entities {
FollowingRequestsCancelResponse,
FollowingRequestsListRequest,
FollowingRequestsListResponse,
FollowingRequestsSentRequest,
FollowingRequestsSentResponse,
FollowingRequestsRejectRequest,
GalleryFeaturedRequest,
GalleryFeaturedResponse,
@ -2034,6 +2036,12 @@ type FollowingRequestsListResponse = operations['following___requests___list']['
// @public (undocumented)
type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json'];
// @public (undocumented)
type FollowingRequestsSentRequest = operations['following___requests___sent']['requestBody']['content']['application/json'];
// @public (undocumented)
type FollowingRequestsSentResponse = operations['following___requests___sent']['responses']['200']['content']['application/json'];
// @public (undocumented)
type FollowingUpdateAllRequest = operations['following___update-all']['requestBody']['content']['application/json'];

View file

@ -2041,6 +2041,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
request<E extends 'following/requests/sent', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View file

@ -281,6 +281,8 @@ import type {
FollowingRequestsCancelResponse,
FollowingRequestsListRequest,
FollowingRequestsListResponse,
FollowingRequestsSentRequest,
FollowingRequestsSentResponse,
FollowingRequestsRejectRequest,
GalleryFeaturedRequest,
GalleryFeaturedResponse,
@ -777,6 +779,7 @@ export type Endpoints = {
'following/requests/accept': { req: FollowingRequestsAcceptRequest; res: EmptyResponse };
'following/requests/cancel': { req: FollowingRequestsCancelRequest; res: FollowingRequestsCancelResponse };
'following/requests/list': { req: FollowingRequestsListRequest; res: FollowingRequestsListResponse };
'following/requests/sent': { req: FollowingRequestsSentRequest; res: FollowingRequestsSentResponse };
'following/requests/reject': { req: FollowingRequestsRejectRequest; res: EmptyResponse };
'gallery/featured': { req: GalleryFeaturedRequest; res: GalleryFeaturedResponse };
'gallery/popular': { req: EmptyRequest; res: GalleryPopularResponse };

View file

@ -284,6 +284,8 @@ export type FollowingRequestsCancelRequest = operations['following___requests___
export type FollowingRequestsCancelResponse = operations['following___requests___cancel']['responses']['200']['content']['application/json'];
export type FollowingRequestsListRequest = operations['following___requests___list']['requestBody']['content']['application/json'];
export type FollowingRequestsListResponse = operations['following___requests___list']['responses']['200']['content']['application/json'];
export type FollowingRequestsSentRequest = operations['following___requests___sent']['requestBody']['content']['application/json'];
export type FollowingRequestsSentResponse = operations['following___requests___sent']['responses']['200']['content']['application/json'];
export type FollowingRequestsRejectRequest = operations['following___requests___reject']['requestBody']['content']['application/json'];
export type GalleryFeaturedRequest = operations['gallery___featured']['requestBody']['content']['application/json'];
export type GalleryFeaturedResponse = operations['gallery___featured']['responses']['200']['content']['application/json'];

View file

@ -1780,6 +1780,15 @@ export type paths = {
*/
post: operations['following___requests___list'];
};
'/following/requests/sent': {
/**
* following/requests/sent
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
post: operations['following___requests___sent'];
};
'/following/requests/reject': {
/**
* following/requests/reject
@ -3985,6 +3994,7 @@ export type components = {
hasUnreadChannel: boolean;
hasUnreadNotification: boolean;
hasPendingReceivedFollowRequest: boolean;
hasPendingSentFollowRequest: boolean;
unreadNotificationsCount: number;
mutedWords: string[][];
hardMutedWords: string[][];
@ -16359,6 +16369,69 @@ export type operations = {
};
};
};
/**
* following/requests/sent
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:following*
*/
following___requests___sent: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
sinceId?: string;
/** Format: misskey:id */
untilId?: string;
/** @default 10 */
limit?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': {
/** Format: id */
id: string;
follower: components['schemas']['UserLite'];
followee: components['schemas']['UserLite'];
}[];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* following/requests/reject
* @description No description provided.
@ -22404,6 +22477,16 @@ export type operations = {
'application/json': {
/** @default false */
mutualsOnly?: boolean;
/** @default false */
filesOnly?: boolean;
/** @default false */
includeNonPublic?: boolean;
/** @default false */
includeReplies?: boolean;
/** @default false */
includeQuotes?: boolean;
/** @default true */
includeBots?: boolean;
/** @default 10 */
limit?: number;
/** Format: misskey:id */
@ -27336,7 +27419,15 @@ export type operations = {
/** @default false */
withReplies?: boolean;
/** @default true */
withRepliesToSelf?: boolean;
/** @default true */
withQuotes?: boolean;
/** @default true */
withRenotes?: boolean;
/** @default true */
withBots?: boolean;
/** @default true */
withNonPublic?: boolean;
/** @default false */
withChannelNotes?: boolean;
/** @default 10 */

View file

@ -15,6 +15,7 @@ import { build as buildLocales } from '../locales/index.js';
import generateDTS from '../locales/generateDTS.js';
import meta from '../package.json' with { type: "json" };
import buildTarball from './tarball.mjs';
import { localesVersion } from '../locales/version.js';
const configDir = fileURLToPath(new URL('../.config', import.meta.url));
const configPath = process.env.MISSKEY_CONFIG_YML
@ -56,10 +57,10 @@ async function copyFrontendLocales() {
await fs.mkdir('./built/_frontend_dist_/locales', { recursive: true });
const v = { '_version_': meta.version };
const v = { '_version_': localesVersion };
for (const [lang, locale] of Object.entries(locales)) {
await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${meta.version}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
await fs.writeFile(`./built/_frontend_dist_/locales/${lang}.${localesVersion}.json`, JSON.stringify({ ...locale, ...v }), 'utf-8');
}
}
@ -77,7 +78,8 @@ async function buildBackendScript() {
'./packages/backend/src/server/web/cli.js'
]) {
let source = await fs.readFile(file, { encoding: 'utf-8' });
source = source.replaceAll('LANGS', JSON.stringify(Object.keys(locales)));
source = source.replaceAll(/\bLANGS\b/g, JSON.stringify(Object.keys(locales)));
source = source.replaceAll(/\bLANGS_VERSION\b/g, JSON.stringify(localesVersion));
const { code } = await terser.minify(source, { toplevel: true });
await fs.writeFile(`./packages/backend/built/server/web/${path.basename(file)}`, code);
}

View file

@ -142,6 +142,10 @@ sensitiveMediaRevealConfirm: "This media might be sensitive. Are you sure you wa
severAllFollowRelations: "Break following relationships"
severAllFollowRelationsConfirm: "Really break all follow relationships? This is irreversible! This will break {followingCount} following and {followersCount} follower relations on {instanceName}!"
severAllFollowRelationsQueued: "Severing all follow relations with {host} queued."
pendingFollowRequests: "Pending follow requests"
showQuotes: "Show quotes"
showReplies: "Show replies"
showNonPublicNotes: "Show non-public"
_delivery:
stop: "Suspend delivery"
resume: "Resume delivery"