Fix existing bugs in tests, refactor users

This commit is contained in:
Jesse Wierzbinski 2023-10-08 10:20:42 -10:00
parent b7587f8d3f
commit 65ff53e90c
19 changed files with 385 additions and 117 deletions

92
benchmarks/posting.ts Normal file
View file

@ -0,0 +1,92 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { getConfig } from "@config";
import { AppDataSource } from "~database/datasource";
import { Application } from "~database/entities/Application";
import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User";
const config = getConfig();
let token: Token;
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
const user = await User.createNewLocal({
email: "test@test.com",
username: "test",
password: "test",
display_name: "",
});
const app = new Application();
app.name = "Test Application";
app.website = "https://example.com";
app.client_id = "test";
app.redirect_uris = "https://example.com";
app.scopes = "read write";
app.secret = "test";
app.vapid_key = null;
await app.save();
// Initialize test token
token = new Token();
token.access_token = "test";
token.application = app;
token.code = "test";
token.scope = "read write";
token.token_type = TokenType.BEARER;
token.user = user;
token = await token.save();
await fetch(`${config.http.base_url}/api/v1/statuses`, {
method: "POST",
headers: {
Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: "Hello, world!",
visibility: "public",
}),
});
const timeBefore = performance.now();
// Repeat 100 times
for (let i = 0; i < 100; i++) {
await fetch(`${config.http.base_url}/api/v1/timelines/public`, {
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
});
}
const timeAfter = performance.now();
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}/@test`,
})
.leftJoinAndSelect("activity.objects", "objects")
.getMany();
// Delete all created objects and activities as part of testing
await Promise.all(
activities.map(async activity => {
await Promise.all(
activity.objects.map(async object => await object.remove())
);
await activity.remove();
})
);
await user.remove();
console.log(`Time taken: ${timeAfter - timeBefore}ms`);

View file

@ -34,7 +34,7 @@ export class Emoji extends BaseEntity {
@ManyToOne(() => Instance, {
nullable: true,
})
instance!: Instance;
instance!: Instance | null;
/**
* The URL for the emoji.
@ -56,9 +56,9 @@ export class Emoji extends BaseEntity {
async toAPI(): Promise<APIEmoji> {
return {
shortcode: this.shortcode,
static_url: "",
url: "",
visible_in_picker: false,
static_url: this.url, // TODO: Add static version
url: this.url,
visible_in_picker: this.visible_in_picker,
category: undefined,
};
}

View file

@ -2,6 +2,7 @@ import {
BaseEntity,
Column,
Entity,
Index,
JoinTable,
ManyToMany,
PrimaryGeneratedColumn,
@ -23,6 +24,8 @@ export class RawActivity extends BaseEntity {
id!: string;
@Column("jsonb")
// Index ID for faster lookups
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
data!: APActivity;
@ManyToMany(() => RawObject)
@ -239,7 +242,6 @@ export class RawActivity extends BaseEntity {
const rawActor = new RawActor();
rawActor.data = actor;
rawActor.followers = [];
const config = getConfig();

View file

@ -3,18 +3,15 @@ import {
BaseEntity,
Column,
Entity,
JoinTable,
ManyToMany,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { APActor, APImage, APOrderedCollectionPage } from "activitypub-types";
import { APActor, APImage } from "activitypub-types";
import { getConfig, getHost } from "@config";
import { appendFile } from "fs/promises";
import { errorResponse } from "@response";
import { APIAccount } from "~types/entities/account";
import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject";
/**
* Represents a raw actor entity in the database.
*/
@ -30,21 +27,9 @@ export class RawActor extends BaseEntity {
* The ActivityPub actor data associated with the actor.
*/
@Column("jsonb")
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
data!: APActor;
/**
* The list of follower IDs associated with the actor.
*/
@Column("jsonb", { default: [] })
followers!: string[];
/**
* The list of featured objects associated with the actor.
*/
@ManyToMany(() => RawObject, { nullable: true })
@JoinTable()
featured!: RawObject[];
/**
* Retrieves a RawActor entity by actor ID.
* @param id The ID of the actor to retrieve.
@ -62,13 +47,13 @@ export class RawActor extends BaseEntity {
* @returns The newly created RawActor entity, or an error response if the actor already exists or is filtered.
*/
static async addIfNotExists(data: APActor) {
// TODO: Also add corresponding user
if (await RawActor.exists(data.id ?? "")) {
return errorResponse("Actor already exists", 409);
}
const actor = new RawActor();
actor.data = data;
actor.followers = [];
const config = getConfig();
@ -105,33 +90,6 @@ export class RawActor extends BaseEntity {
return new URL(this.data.id ?? "").host;
}
/**
* Fetches the list of followers associated with the actor and updates the `followers` property.
*/
async fetchFollowers() {
let followers: APOrderedCollectionPage = await fetch(
`${this.data.followers?.toString() ?? ""}?page=1`,
{
headers: { Accept: "application/activity+json" },
}
);
let followersList = followers.orderedItems ?? [];
while (followers.type === "OrderedCollectionPage" && followers.next) {
followers = await fetch((followers.next as string).toString(), {
headers: { Accept: "application/activity+json" },
}).then(res => res.json());
followersList = {
...followersList,
...(followers.orderedItems ?? []),
};
}
this.followers = followersList as string[];
}
/**
* Converts the RawActor entity to an API account object.
* @param isOwnAccount Whether the account is the user's own account.
@ -169,7 +127,7 @@ export class RawActor extends BaseEntity {
config.defaults.header,
locked: false,
created_at: new Date(published ?? 0).toISOString(),
followers_count: this.followers.length,
followers_count: 0,
following_count: 0,
statuses_count: statusCount,
emojis: [],
@ -249,7 +207,6 @@ export class RawActor extends BaseEntity {
* @returns Whether an actor with the specified ID exists in the database.
*/
static async exists(id: string) {
console.log(!!(await RawActor.getByActorId(id)));
return !!(await RawActor.getByActorId(id));
}
}

View file

@ -1,4 +1,10 @@
import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import {
BaseEntity,
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from "typeorm";
import { APImage, APObject, DateTime } from "activitypub-types";
import { getConfig } from "@config";
import { appendFile } from "fs/promises";
@ -6,6 +12,7 @@ import { APIStatus } from "~types/entities/status";
import { RawActor } from "./RawActor";
import { APIAccount } from "~types/entities/account";
import { APIEmoji } from "~types/entities/emoji";
import { User } from "./User";
/**
* Represents a raw ActivityPub object in the database.
@ -24,6 +31,11 @@ export class RawObject extends BaseEntity {
* The data associated with the object.
*/
@Column("jsonb")
// Index ID, attributedTo, to and published for faster lookups
@Index({ unique: true, where: "(data->>'id') IS NOT NULL" })
@Index({ where: "(data->>'attributedTo') IS NOT NULL" })
@Index({ where: "(data->>'to') IS NOT NULL" })
@Index({ where: "(data->>'published') IS NOT NULL" })
data!: APObject;
/**
@ -82,10 +94,8 @@ export class RawObject extends BaseEntity {
return {
account:
(await (
await RawActor.getByActorId(
this.data.attributedTo as string
)
)?.toAPIAccount()) ?? (null as unknown as APIAccount),
await User.getByActorId(this.data.attributedTo as string)
)?.toAPI()) ?? (null as unknown as APIAccount),
created_at: new Date(this.data.published as DateTime).toISOString(),
id: this.id,
in_reply_to_id: null,

View file

@ -17,7 +17,6 @@ import { Application } from "./Application";
import { Emoji } from "./Emoji";
import { RawActivity } from "./RawActivity";
import { RawObject } from "./RawObject";
import { RawActor } from "./RawActor";
const config = getConfig();
@ -100,10 +99,10 @@ export class Status extends BaseEntity {
/**
* The raw actor that this status is a reply to, if any.
*/
@ManyToOne(() => RawActor, {
@ManyToOne(() => User, {
nullable: true,
})
in_reply_to_account!: RawActor | null;
in_reply_to_account!: User | null;
/**
* Whether this status is sensitive.
@ -196,7 +195,7 @@ export class Status extends BaseEntity {
emojis: Emoji[];
reply?: {
object: RawObject;
actor: RawActor;
user: User;
};
}) {
const newStatus = new Status();
@ -217,7 +216,7 @@ export class Status extends BaseEntity {
if (data.reply) {
newStatus.in_reply_to_post = data.reply.object;
newStatus.in_reply_to_account = data.reply.actor;
newStatus.in_reply_to_account = data.reply.user;
}
newStatus.object.data = {
@ -240,8 +239,8 @@ export class Status extends BaseEntity {
return `${config.http.base_url}/@${match[1]}`;
});
// Map this to Actors
const mentionedActors = (
// Map this to Users
const mentionedUsers = (
await Promise.all(
mentionedPeople.map(async person => {
// Check if post is in format @username or @username@instance.com
@ -252,23 +251,23 @@ export class Status extends BaseEntity {
: null;
if (instanceUrl) {
const actor = await RawActor.createQueryBuilder("actor")
.where("actor.data->>'id' = :id", {
// Where ID contains the instance URL
id: `%${instanceUrl}%`,
})
// Where actor preferredUsername is the username
.andWhere(
"actor.data->>'preferredUsername' = :username",
{
username: person.split("@")[1],
}
)
.getOne();
const user = await User.findOne({
where: {
username: person.split("@")[1],
// If contains instanceUrl
instance: {
base_url: instanceUrl,
},
},
relations: {
actor: true,
instance: true,
},
});
return actor?.data.id;
return user?.actor.data.id;
} else {
const actor = await User.findOne({
const user = await User.findOne({
where: {
username: person.split("@")[1],
},
@ -277,13 +276,13 @@ export class Status extends BaseEntity {
},
});
return actor?.actor.data.id;
return user?.actor.data.id;
}
})
)
).map(actor => actor as string);
).map(user => user as string);
newStatus.object.data.to = mentionedActors;
newStatus.object.data.to = mentionedUsers;
if (data.visibility === "private") {
newStatus.object.data.cc = [

View file

@ -14,12 +14,13 @@ import {
} from "typeorm";
import { APIAccount } from "~types/entities/account";
import { RawActor } from "./RawActor";
import { APActor } from "activitypub-types";
import { APActor, APOrderedCollectionPage } from "activitypub-types";
import { RawObject } from "./RawObject";
import { Token } from "./Token";
import { Status } from "./Status";
import { APISource } from "~types/entities/source";
import { Relationship } from "./Relationship";
import { Instance } from "./Instance";
/**
* Represents a user in the database.
@ -52,16 +53,19 @@ export class User extends BaseEntity {
/**
* The password for the user.
*/
@Column("varchar")
password!: string;
@Column("varchar", {
nullable: true,
})
password!: string | null;
/**
* The email address for the user.
*/
@Column("varchar", {
unique: true,
nullable: true,
})
email!: string;
email!: string | null;
/**
* The note for the user.
@ -118,8 +122,10 @@ export class User extends BaseEntity {
/**
* The private key for the user.
*/
@Column("varchar")
private_key!: string;
@Column("varchar", {
nullable: true,
})
private_key!: string | null;
/**
* The relationships for the user.
@ -127,6 +133,16 @@ export class User extends BaseEntity {
@OneToMany(() => Relationship, relationship => relationship.owner)
relationships!: Relationship[];
/**
* User's instance, null if local user
*/
@ManyToOne(() => Instance, {
nullable: true,
})
instance!: Instance | null;
/** */
/**
* The actor for the user.
*/
@ -140,6 +156,50 @@ export class User extends BaseEntity {
@JoinTable()
pinned_notes!: RawObject[];
/**
* Update this user data from its actor
* @returns The updated user.
*/
async updateFromActor() {
const actor = await this.actor.toAPIAccount();
this.username = actor.username;
this.display_name = actor.display_name;
this.note = actor.note;
this.avatar = actor.avatar;
this.header = actor.header;
this.avatar = actor.avatar;
return await this.save();
}
/**
* Fetches the list of followers associated with the actor and updates the user's followers
*/
async fetchFollowers() {
let followers: APOrderedCollectionPage = await fetch(
`${this.actor.data.followers?.toString() ?? ""}?page=1`,
{
headers: { Accept: "application/activity+json" },
}
);
let followersList = followers.orderedItems ?? [];
while (followers.type === "OrderedCollectionPage" && followers.next) {
followers = await fetch((followers.next as string).toString(), {
headers: { Accept: "application/activity+json" },
}).then(res => res.json());
followersList = {
...followersList,
...(followers.orderedItems ?? []),
};
}
// TODO: integrate followers
}
/**
* Gets a user by actor ID.
* @param id The actor ID to search for.
@ -159,11 +219,11 @@ export class User extends BaseEntity {
}
/**
* Creates a new user.
* Creates a new LOCAL user.
* @param data The data for the new user.
* @returns The newly created user.
*/
static async createNew(data: {
static async createNewLocal(data: {
username: string;
display_name?: string;
password: string;
@ -184,6 +244,7 @@ export class User extends BaseEntity {
user.header = data.header ?? config.defaults.avatar;
user.relationships = [];
user.instance = null;
user.source = {
language: null,
@ -401,7 +462,32 @@ export class User extends BaseEntity {
}
// eslint-disable-next-line @typescript-eslint/require-await
async toAPI(): Promise<APIAccount> {
return await this.actor.toAPIAccount();
async toAPI(isOwnAccount = false): Promise<APIAccount> {
const follower_count = await Relationship.count({
where: {
subject: {
id: this.id,
},
following: true,
},
relations: ["subject"],
});
const following_count = await Relationship.count({
where: {
owner: {
id: this.id,
},
following: true,
},
relations: ["owner"],
});
return {
...(await this.actor.toAPIAccount(isOwnAccount)),
id: this.id,
followers_count: follower_count,
following_count: following_count,
};
}
}

View file

@ -1,6 +1,5 @@
import { errorResponse, jsonResponse } from "@response";
import { MatchedRoute } from "bun";
import { RawActor } from "~database/entities/RawActor";
import { User } from "~database/entities/User";
/**
@ -20,9 +19,9 @@ export default async (
const user = await User.retrieveFromToken(token);
let foundUser: RawActor | null;
let foundUser: User | null;
try {
foundUser = await RawActor.findOneBy({
foundUser = await User.findOneBy({
id,
});
} catch (e) {
@ -31,7 +30,5 @@ export default async (
if (!foundUser) return errorResponse("User not found", 404);
return jsonResponse(
await foundUser.toAPIAccount(user?.id === foundUser.id)
);
return jsonResponse(await foundUser.toAPI(user?.id === foundUser.id));
};

View file

@ -147,7 +147,7 @@ export default async (req: Request): Promise<Response> => {
// TODO: Check if locale is valid
await User.createNew({
await User.createNewLocal({
username: body.username ?? "",
password: body.password ?? "",
email: body.email ?? "",

View file

@ -0,0 +1,17 @@
import { jsonResponse } from "@response";
import { IsNull } from "typeorm";
import { Emoji } from "~database/entities/Emoji";
/**
* Creates a new user
*/
// eslint-disable-next-line @typescript-eslint/require-await
export default async (): Promise<Response> => {
const emojis = await Emoji.findBy({
instance: IsNull(),
});
return jsonResponse(
await Promise.all(emojis.map(async emoji => await emoji.toAPI()))
);
};

View file

@ -37,7 +37,7 @@ export default async (
if (req.method === "GET") {
return jsonResponse(await foundStatus.toAPI());
} else if (req.method === "DELETE") {
if ((await foundStatus.toAPI()).account.id !== user?.actor.id) {
if ((await foundStatus.toAPI()).account.id !== user?.id) {
return errorResponse("Unauthorized", 401);
}

View file

@ -6,7 +6,6 @@ import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { APActor } from "activitypub-types";
import { Application } from "~database/entities/Application";
import { RawActor } from "~database/entities/RawActor";
import { RawObject } from "~database/entities/RawObject";
import { Status } from "~database/entities/Status";
import { User } from "~database/entities/User";
@ -108,7 +107,7 @@ export default async (req: Request): Promise<Response> => {
// Get reply account and status if exists
let replyObject: RawObject | null = null;
let replyActor: RawActor | null = null;
let replyUser: User | null = null;
if (in_reply_to_id) {
replyObject = await RawObject.findOne({
@ -117,7 +116,7 @@ export default async (req: Request): Promise<Response> => {
},
});
replyActor = await RawActor.getByActorId(
replyUser = await User.getByActorId(
(replyObject?.data.attributedTo as APActor).id ?? ""
);
}
@ -138,9 +137,9 @@ export default async (req: Request): Promise<Response> => {
spoiler_text: spoiler_text || "",
emojis: [],
reply:
replyObject && replyActor
replyObject && replyUser
? {
actor: replyActor,
user: replyUser,
object: replyObject,
}
: undefined,

View file

@ -0,0 +1,68 @@
import { parseRequest } from "@request";
import { errorResponse, jsonResponse } from "@response";
import { RawObject } from "~database/entities/RawObject";
/**
* Fetch home timeline statuses
*/
export default async (req: Request): Promise<Response> => {
const {
limit = 20,
max_id,
min_id,
since_id,
} = await parseRequest<{
max_id?: string;
since_id?: string;
min_id?: string;
limit?: number;
}>(req);
if (limit < 1 || limit > 40) {
return errorResponse("Limit must be between 1 and 40", 400);
}
let query = RawObject.createQueryBuilder("object")
.where("object.data->>'type' = 'Note'")
// From a user followed by the current user
.andWhere("CAST(object.data->>'to' AS jsonb) @> CAST(:to AS jsonb)", {
to: JSON.stringify([
"https://www.w3.org/ns/activitystreams#Public",
]),
})
.orderBy("object.data->>'published'", "DESC")
.take(limit);
if (max_id) {
const maxPost = await RawObject.findOneBy({ id: max_id });
if (maxPost) {
query = query.andWhere("object.data->>'published' < :max_date", {
max_date: maxPost.data.published,
});
}
}
if (min_id) {
const minPost = await RawObject.findOneBy({ id: min_id });
if (minPost) {
query = query.andWhere("object.data->>'published' > :min_date", {
min_date: minPost.data.published,
});
}
}
if (since_id) {
const sincePost = await RawObject.findOneBy({ id: since_id });
if (sincePost) {
query = query.andWhere("object.data->>'published' >= :since_date", {
since_date: sincePost.data.published,
});
}
}
const objects = await query.getMany();
return jsonResponse(
await Promise.all(objects.map(async object => await object.toAPI()))
);
};

View file

@ -35,7 +35,7 @@ export default async (
email,
});
if (!user || !(await Bun.password.verify(password, user.password)))
if (!user || !(await Bun.password.verify(password, user.password || "")))
return errorResponse("Invalid username or password", 401);
// Get application

View file

@ -13,7 +13,7 @@ beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
await User.createNew({
await User.createNewLocal({
email: "test@test.com",
username: "test",
password: "test",

View file

@ -4,10 +4,12 @@ import { getConfig } from "@config";
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource";
import { Application } from "~database/entities/Application";
import { Emoji } from "~database/entities/Emoji";
import { RawActivity } from "~database/entities/RawActivity";
import { Token, TokenType } from "~database/entities/Token";
import { User } from "~database/entities/User";
import { APIAccount } from "~types/entities/account";
import { APIEmoji } from "~types/entities/emoji";
import { APIInstance } from "~types/entities/instance";
import { APIRelationship } from "~types/entities/relationship";
import { APIStatus } from "~types/entities/status";
@ -23,7 +25,7 @@ beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
user = await User.createNew({
user = await User.createNewLocal({
email: "test@test.com",
username: "test",
password: "test",
@ -31,7 +33,7 @@ beforeAll(async () => {
});
// Initialize second test user
user2 = await User.createNew({
user2 = await User.createNewLocal({
email: "test2@test.com",
username: "test2",
password: "test2",
@ -105,7 +107,7 @@ describe("POST /api/v1/statuses", () => {
status = (await response.json()) as APIStatus;
expect(status.content).toBe("Hello, world!");
expect(status.visibility).toBe("public");
expect(status.account.id).toBe(user.actor.id);
expect(status.account.id).toBe(user.id);
expect(status.replies_count).toBe(0);
expect(status.favourites_count).toBe(0);
expect(status.reblogged).toBe(false);
@ -238,7 +240,7 @@ describe("GET /api/v1/accounts/:id/statuses", () => {
// Basic validation
expect(status1.content).toBe("Hello, world!");
expect(status1.visibility).toBe("public");
expect(status1.account.id).toBe(user.actor.id);
expect(status1.account.id).toBe(user.id);
});
});
@ -678,6 +680,42 @@ describe("GET /api/v1/instance", () => {
});
});
describe("GET /api/v1/custom_emojis", () => {
beforeAll(async () => {
const emoji = new Emoji();
emoji.instance = null;
emoji.url = "https://example.com";
emoji.shortcode = "test";
emoji.visible_in_picker = true;
await emoji.save();
});
test("should return an array of at least one custom emoji", async () => {
const response = await fetch(
`${config.http.base_url}/api/v1/custom_emojis`,
{
method: "GET",
headers: {
Authorization: `Bearer ${token.access_token}`,
},
}
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe("application/json");
const emojis: APIEmoji[] = await response.json();
expect(emojis.length).toBeGreaterThan(0);
expect(emojis[0].shortcode).toBe("test");
expect(emojis[0].url).toBe("https://example.com");
});
afterAll(async () => {
await Emoji.delete({ shortcode: "test" });
});
});
afterAll(async () => {
const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", {

View file

@ -11,7 +11,7 @@ beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
await User.createNew({
await User.createNewLocal({
email: "test@test.com",
username: "test",
password: "test",

View file

@ -16,7 +16,7 @@ beforeAll(async () => {
if (!AppDataSource.isInitialized) await AppDataSource.initialize();
// Initialize test user
await User.createNew({
await User.createNewLocal({
email: "test@test.com",
username: "test",
password: "test",
@ -32,6 +32,7 @@ describe("POST /api/v1/apps/", () => {
formData.append("website", "https://example.com");
formData.append("redirect_uris", "https://example.com");
formData.append("scopes", "read write");
const response = await fetch(`${config.http.base_url}/api/v1/apps/`, {
method: "POST",
body: formData,
@ -64,10 +65,10 @@ describe("POST /auth/login/", () => {
test("should get a code", async () => {
const formData = new FormData();
formData.append("username", "test");
formData.append("email", "test@test.com");
formData.append("password", "test");
const response = await fetch(
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scopes=read+write`,
`${config.http.base_url}/auth/login/?client_id=${client_id}&redirect_uri=https://example.com&response_type=code&scope=read+write`,
{
method: "POST",
body: formData,
@ -93,7 +94,7 @@ describe("POST /oauth/token/", () => {
formData.append("redirect_uri", "https://example.com");
formData.append("client_id", client_id);
formData.append("client_secret", client_secret);
formData.append("scope", "read write");
formData.append("scope", "read+write");
const response = await fetch(`${config.http.base_url}/oauth/token/`, {
method: "POST",

View file

@ -51,6 +51,7 @@ export interface ConfigType {
discard_avatars: string[];
force_sensitive: string[];
remove_media: string[];
fetch_all_colletion_members: boolean;
};
filters: {
@ -163,6 +164,7 @@ export const configDefaults: ConfigType = {
discard_avatars: [],
force_sensitive: [],
remove_media: [],
fetch_all_colletion_members: false,
},
filters: {
note_filters: [],