[backend] [client] Add option to hide user lists from home timeline

This commit is contained in:
Laura Hausmann 2023-10-19 18:54:23 +02:00
parent fdd8c28aed
commit 89ab890331
Signed by: zotan
GPG key ID: D044E84C5BE01605
25 changed files with 202 additions and 30 deletions

View file

@ -2135,3 +2135,4 @@ _cwStyle:
classic: "Classic (Misskey/Foundkey-like)" classic: "Classic (Misskey/Foundkey-like)"
alternative: "Alternative (Firefish-like)" alternative: "Alternative (Firefish-like)"
alwaysExpandCws: "Always expand posts with content warnings" alwaysExpandCws: "Always expand posts with content warnings"
hideFromHome: "Hide from home timeline"

View file

@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class UserListOptions1697733603329 implements MigrationInterface {
name = 'UserListOptions1697733603329'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_list" ADD "hideFromHomeTl" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`);
await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "hideFromHomeTl"`);
}
}

View file

@ -37,4 +37,10 @@ export class UserList {
comment: "The name of the UserList.", comment: "The name of the UserList.",
}) })
public name: string; public name: string;
@Column("boolean", {
default: false,
comment: "Whether posts from list members should be hidden from the home timeline."
})
public hideFromHomeTl: boolean;
} }

View file

@ -16,6 +16,7 @@ export const UserListRepository = db.getRepository(UserList).extend({
id: userList.id, id: userList.id,
createdAt: userList.createdAt.toISOString(), createdAt: userList.createdAt.toISOString(),
name: userList.name, name: userList.name,
hideFromHomeTl: userList.hideFromHomeTl,
userIds: users.map((x) => x.userId), userIds: users.map((x) => x.userId),
}; };
}, },

View file

@ -19,6 +19,11 @@ export const packedUserListSchema = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
hideFromHomeTl: {
type: "boolean",
optional: false,
nullable: false,
},
userIds: { userIds: {
type: "array", type: "array",
nullable: false, nullable: false,

View file

@ -0,0 +1,24 @@
import { Brackets, SelectQueryBuilder } from "typeorm";
import { User } from "@/models/entities/user.js";
import { UserListJoinings, UserLists } from "@/models/index.js";
export function generateListQuery(
q: SelectQueryBuilder<any>,
me: { id: User["id"] },
): void {
const listQuery = UserLists.createQueryBuilder("list")
.select("list.id")
.where("list.hideFromHomeTl = TRUE")
.andWhere("list.userId = :meId");
const memberQuery = UserListJoinings.createQueryBuilder("member")
.select("member.userId")
.where(`member.userListId IN (${listQuery.getQuery()})`)
q.andWhere(new Brackets((qb) => {
qb.where(`note.userId = :meId`);
qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`);
}));
q.setParameters({ meId: me.id });
}

View file

@ -12,6 +12,7 @@ import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.j
import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateChannelQuery } from "../../common/generate-channel-query.js";
import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js";
import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -108,6 +109,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.setParameters(followingQuery.getParameters()); .setParameters(followingQuery.getParameters());
generateListQuery(query, user);
generateChannelQuery(query, user); generateChannelQuery(query, user);
generateRepliesQuery(query, ps.withReplies, user); generateRepliesQuery(query, ps.withReplies, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);

View file

@ -11,6 +11,7 @@ import { generateChannelQuery } from "../../common/generate-channel-query.js";
import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js";
import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js";
import { ApiError } from "../../error.js"; import { ApiError } from "../../error.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export const meta = { export const meta = {
tags: ["notes"], tags: ["notes"],
@ -104,6 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner") .leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.setParameters(followingQuery.getParameters()); .setParameters(followingQuery.getParameters());
generateListQuery(query, user);
generateChannelQuery(query, user); generateChannelQuery(query, user);
generateRepliesQuery(query, ps.withReplies, user); generateRepliesQuery(query, ps.withReplies, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);

View file

@ -1,8 +1,8 @@
import { publishUserListStream } from "@/services/stream.js"; import { UserLists } from "@/models/index.js";
import { UserLists, UserListJoinings, Users } from "@/models/index.js";
import define from "../../../define.js"; import define from "../../../define.js";
import { ApiError } from "../../../error.js"; import { ApiError } from "../../../error.js";
import { getUser } from "../../../common/getters.js"; import { getUser } from "../../../common/getters.js";
import { pullUserFromUserList } from "@/services/user-list/pull.js";
export const meta = { export const meta = {
tags: ["lists", "users"], tags: ["lists", "users"],
@ -56,7 +56,5 @@ export default define(meta, paramDef, async (ps, me) => {
}); });
// Pull the user // Pull the user
await UserListJoinings.delete({ userListId: userList.id, userId: user.id }); await pullUserFromUserList(user, userList);
publishUserListStream(userList.id, "userRemoved", await Users.pack(user));
}); });

View file

@ -1,6 +1,7 @@
import { UserLists } from "@/models/index.js"; import { UserListJoinings, UserLists, Users } from "@/models/index.js";
import define from "../../../define.js"; import define from "../../../define.js";
import { ApiError } from "../../../error.js"; import { ApiError } from "../../../error.js";
import { publishUserEvent } from "@/services/stream.js";
export const meta = { export const meta = {
tags: ["lists"], tags: ["lists"],
@ -32,8 +33,9 @@ export const paramDef = {
properties: { properties: {
listId: { type: "string", format: "misskey:id" }, listId: { type: "string", format: "misskey:id" },
name: { type: "string", minLength: 1, maxLength: 100 }, name: { type: "string", minLength: 1, maxLength: 100 },
hideFromHomeTl: { type: "boolean", nullable: true },
}, },
required: ["listId", "name"], required: ["listId"],
} as const; } as const;
export default define(meta, paramDef, async (ps, user) => { export default define(meta, paramDef, async (ps, user) => {
@ -47,9 +49,20 @@ export default define(meta, paramDef, async (ps, user) => {
throw new ApiError(meta.errors.noSuchList); throw new ApiError(meta.errors.noSuchList);
} }
await UserLists.update(userList.id, { const partial = {
name: ps.name, name: ps.name ?? undefined,
}); hideFromHomeTl: ps.hideFromHomeTl ?? undefined
};
if (Object.keys(partial).length > 0) await UserLists.update(userList.id, partial);
if (ps.hideFromHomeTl != null) {
UserListJoinings.findBy({ userListId: ps.listId })
.then(members => {
for (const member of members) {
publishUserEvent(userList.userId, ps.hideFromHomeTl ? "userHidden" : "userUnhidden", member.userId);
}
});
}
return await UserLists.pack(userList.id); return await UserLists.pack(userList.id);
}); });

View file

@ -39,7 +39,8 @@ export function setupEndpointsList(router: Router): void {
const body = ctx.request.body as any; const body = ctx.request.body as any;
const title = (body.title ?? '').trim(); const title = (body.title ?? '').trim();
ctx.body = await ListHelpers.updateList(list, title, ctx); const exclusive = body.exclusive ?? undefined as boolean | undefined;
ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx);
}, },
); );
router.delete<{ Params: { id: string } }>( router.delete<{ Params: { id: string } }>(

View file

@ -2,5 +2,6 @@ namespace MastodonEntity {
export type List = { export type List = {
id: string; id: string;
title: string; title: string;
exclusive: boolean;
}; };
} }

View file

@ -4,9 +4,10 @@ import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js";
import { UserList } from "@/models/entities/user-list.js"; import { UserList } from "@/models/entities/user-list.js";
import { pushUserToUserList } from "@/services/user-list/push.js"; import { pushUserToUserList } from "@/services/user-list/push.js";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { publishUserListStream } from "@/services/stream.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { MastoContext } from "@/server/api/mastodon/index.js"; import { MastoContext } from "@/server/api/mastodon/index.js";
import { pullUserFromUserList } from "@/services/user-list/pull.js";
import { publishUserEvent } from "@/services/stream.js";
export class ListHelpers { export class ListHelpers {
public static async getLists(ctx: MastoContext): Promise<MastodonEntity.List[]> { public static async getLists(ctx: MastoContext): Promise<MastodonEntity.List[]> {
@ -15,7 +16,8 @@ export class ListHelpers {
return UserLists.findBy({ userId: user.id }).then(p => p.map(list => { return UserLists.findBy({ userId: user.id }).then(p => p.map(list => {
return { return {
id: list.id, id: list.id,
title: list.name title: list.name,
exclusive: list.hideFromHomeTl
} }
})); }));
} }
@ -26,7 +28,8 @@ export class ListHelpers {
return UserLists.findOneByOrFail({ userId: user.id, id: id }).then(list => { return UserLists.findOneByOrFail({ userId: user.id, id: id }).then(list => {
return { return {
id: list.id, id: list.id,
title: list.name title: list.name,
exclusive: list.hideFromHomeTl
} }
}); });
} }
@ -110,8 +113,7 @@ export class ListHelpers {
}); });
if (!exist) continue; if (!exist) continue;
await UserListJoinings.delete({ userListId: list.id, userId: user.id }); await pullUserFromUserList(user, list);
publishUserListStream(list.id, "userRemoved", await Users.pack(user));
} }
} }
@ -128,23 +130,35 @@ export class ListHelpers {
return { return {
id: list.id, id: list.id,
title: list.name title: list.name,
exclusive: list.hideFromHomeTl
}; };
} }
public static async updateList(list: UserList, title: string, ctx: MastoContext) { public static async updateList(list: UserList, title: string, exclusive: boolean | undefined, ctx: MastoContext): Promise<MastodonEntity.List> {
if (title.length < 1) throw new MastoApiError(400, "Title must not be empty"); if (title.length < 1 && exclusive === undefined) throw new MastoApiError(400, "Either title or exclusive must be set");
const user = ctx.user as ILocalUser; const user = ctx.user as ILocalUser;
if (user.id != list.userId) throw new Error("List is not owned by user"); if (user.id != list.userId) throw new Error("List is not owned by user");
const partial = { name: title }; const name = title.length > 0 ? title : undefined;
const partial = { name: name, hideFromHomeTl: exclusive };
const result = await UserLists.update(list.id, partial) const result = await UserLists.update(list.id, partial)
.then(async _ => await UserLists.findOneByOrFail({ id: list.id })); .then(async _ => await UserLists.findOneByOrFail({ id: list.id }));
if (exclusive !== undefined) {
UserListJoinings.findBy({ userListId: list.id })
.then(members => {
for (const member of members) {
publishUserEvent(list.userId, exclusive ? "userHidden" : "userUnhidden", member.userId);
}
});
}
return { return {
id: result.id, id: result.id,
title: result.name title: result.name,
exclusive: result.hideFromHomeTl
}; };
} }
@ -162,7 +176,8 @@ export class ListHelpers {
.then(results => results.map(result => { .then(results => results.map(result => {
return { return {
id: result.id, id: result.id,
title: result.name title: result.name,
exclusive: result.hideFromHomeTl
} }
})); }));
} }

View file

@ -20,6 +20,7 @@ import { unique } from "@/prelude/array.js";
import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js";
import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js"; import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js";
import { MastoContext } from "@/server/api/mastodon/index.js"; import { MastoContext } from "@/server/api/mastodon/index.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export class TimelineHelpers { export class TimelineHelpers {
public static async getHomeTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise<Note[]> { public static async getHomeTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise<Note[]> {
@ -43,6 +44,7 @@ export class TimelineHelpers {
) )
.leftJoinAndSelect("note.renote", "renote"); .leftJoinAndSelect("note.renote", "renote");
generateListQuery(query, user);
generateChannelQuery(query, user); generateChannelQuery(query, user);
generateRepliesQuery(query, true, user); generateRepliesQuery(query, true, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);

View file

@ -31,6 +31,10 @@ export abstract class MastodonStream {
return this.connection.blocking; return this.connection.blocking;
} }
protected get hidden() {
return this.connection.hidden;
}
protected get subscriber() { protected get subscriber() {
return this.connection.subscriber; return this.connection.subscriber;
} }

View file

@ -90,12 +90,14 @@ export class MastodonStreamUser extends MastodonStream {
private async shouldProcessNote(note: Note): Promise<boolean> { private async shouldProcessNote(note: Note): Promise<boolean> {
if (note.visibility === "hidden") return false; if (note.visibility === "hidden") return false;
if (note.visibility === "specified") return note.userId === this.user.id || note.visibleUserIds?.includes(this.user.id); if (note.userId === this.user.id) return true;
if (note.visibility === "specified") return note.visibleUserIds?.includes(this.user.id);
if (note.channelId) return false; if (note.channelId) return false;
if (this.user!.id !== note.userId && !this.following.has(note.userId)) return false; if (this.user!.id !== note.userId && !this.following.has(note.userId)) return false;
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return false; if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return false;
if (isUserRelated(note, this.muting)) return false; if (isUserRelated(note, this.muting)) return false;
if (isUserRelated(note, this.blocking)) return false; if (isUserRelated(note, this.blocking)) return false;
if (isUserRelated(note, this.hidden)) return false;
if (note.renote && !isQuote(note) && this.renoteMuting.has(note.userId)) return false; if (note.renote && !isQuote(note) && this.renoteMuting.has(note.userId)) return false;
if (this.userProfile && (await getWordHardMute(note, this.user, this.userProfile.mutedWords))) return false; if (this.userProfile && (await getWordHardMute(note, this.user, this.userProfile.mutedWords))) return false;

View file

@ -2,7 +2,7 @@ import type { EventEmitter } from "events";
import type * as websocket from "websocket"; import type * as websocket from "websocket";
import type { ILocalUser, User } from "@/models/entities/user.js"; import type { ILocalUser, User } from "@/models/entities/user.js";
import type { MastodonStream } from "./channel.js"; import type { MastodonStream } from "./channel.js";
import { Blockings, Followings, Mutings, RenoteMutings, UserProfiles, } from "@/models/index.js"; import { Blockings, Followings, Mutings, RenoteMutings, UserListJoinings, UserProfiles, } from "@/models/index.js";
import type { UserProfile } from "@/models/entities/user-profile.js"; import type { UserProfile } from "@/models/entities/user-profile.js";
import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js"; import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js";
import { apiLogger } from "@/server/api/logger.js"; import { apiLogger } from "@/server/api/logger.js";
@ -40,6 +40,7 @@ export class MastodonStreamingConnection {
public muting: Set<User["id"]> = new Set(); public muting: Set<User["id"]> = new Set();
public renoteMuting: Set<User["id"]> = new Set(); public renoteMuting: Set<User["id"]> = new Set();
public blocking: Set<User["id"]> = new Set(); public blocking: Set<User["id"]> = new Set();
public hidden: Set<User["id"]> = new Set();
public token?: OAuthToken; public token?: OAuthToken;
private wsConnection: websocket.connection; private wsConnection: websocket.connection;
private channels: MastodonStream[] = []; private channels: MastodonStream[] = [];
@ -69,6 +70,7 @@ export class MastodonStreamingConnection {
this.updateMuting(); this.updateMuting();
this.updateRenoteMuting(); this.updateRenoteMuting();
this.updateBlocking(); this.updateBlocking();
this.updateHidden();
this.updateUserProfile(); this.updateUserProfile();
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
@ -98,6 +100,12 @@ export class MastodonStreamingConnection {
case "unmute": case "unmute":
this.muting.delete(data.body.id); this.muting.delete(data.body.id);
break; break;
case "userHidden":
this.hidden.add(data.body);
break;
case "userUnhidden":
this.hidden.delete(data.body);
break;
// TODO: renote mute events // TODO: renote mute events
// TODO: block events // TODO: block events
@ -247,6 +255,17 @@ export class MastodonStreamingConnection {
this.blocking = new Set<string>(blockings.map((x) => x.blockerId)); this.blocking = new Set<string>(blockings.map((x) => x.blockerId));
} }
private async updateHidden() {
const hidden = await UserListJoinings.find({
where: {
userList: { userId: this.user!.id, hideFromHomeTl: true },
},
select: ["userId"],
});
this.hidden = new Set<string>(hidden.map((x) => x.userId));
}
private async updateUserProfile() { private async updateUserProfile() {
this.userProfile = await UserProfiles.findOneBy({ this.userProfile = await UserProfiles.findOneBy({
userId: this.user!.id, userId: this.user!.id,

View file

@ -38,6 +38,10 @@ export default abstract class Channel {
return this.connection.blocking; return this.connection.blocking;
} }
protected get hidden() {
return this.connection.hidden;
}
protected get followingChannels() { protected get followingChannels() {
return this.connection.followingChannels; return this.connection.followingChannels;
} }

View file

@ -57,6 +57,8 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// Members of lists with hideFromHome set
if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;

View file

@ -74,6 +74,8 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return; if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return; if (isUserRelated(note, this.blocking)) return;
// Members of lists with hideFromHome set
if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return;
if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return;

View file

@ -10,7 +10,7 @@ import {
RenoteMutings, RenoteMutings,
UserProfiles, UserProfiles,
ChannelFollowings, ChannelFollowings,
Blockings, Blockings, UserListJoinings,
} from "@/models/index.js"; } from "@/models/index.js";
import type { AccessToken } from "@/models/entities/access-token.js"; import type { AccessToken } from "@/models/entities/access-token.js";
import type { UserProfile } from "@/models/entities/user-profile.js"; import type { UserProfile } from "@/models/entities/user-profile.js";
@ -35,7 +35,8 @@ export default class Connection {
public following: Set<User["id"]> = new Set(); public following: Set<User["id"]> = new Set();
public muting: Set<User["id"]> = new Set(); public muting: Set<User["id"]> = new Set();
public renoteMuting: Set<User["id"]> = new Set(); public renoteMuting: Set<User["id"]> = new Set();
public blocking: Set<User["id"]> = new Set(); // "被"blocking public blocking: Set<User["id"]> = new Set();
public hidden: Set<User["id"]> = new Set();
public followingChannels: Set<ChannelModel["id"]> = new Set(); public followingChannels: Set<ChannelModel["id"]> = new Set();
public token?: AccessToken; public token?: AccessToken;
private wsConnection: websocket.connection; private wsConnection: websocket.connection;
@ -79,6 +80,7 @@ export default class Connection {
this.updateMuting(); this.updateMuting();
this.updateRenoteMuting(); this.updateRenoteMuting();
this.updateBlocking(); this.updateBlocking();
this.updateHidden();
this.updateFollowingChannels(); this.updateFollowingChannels();
this.updateUserProfile(); this.updateUserProfile();
@ -122,6 +124,14 @@ export default class Connection {
this.followingChannels.delete(data.body.id); this.followingChannels.delete(data.body.id);
break; break;
case "userHidden":
this.hidden.add(data.body);
break;
case "userUnhidden":
this.hidden.delete(data.body);
break;
case "updateUserProfile": case "updateUserProfile":
this.userProfile = data.body; this.userProfile = data.body;
break; break;
@ -432,6 +442,17 @@ export default class Connection {
this.blocking = new Set<string>(blockings.map((x) => x.blockerId)); this.blocking = new Set<string>(blockings.map((x) => x.blockerId));
} }
private async updateHidden() {
const hidden = await UserListJoinings.find({
where: {
userList: { userId: this.user!.id, hideFromHomeTl: true },
},
select: ["userId"],
});
this.hidden = new Set<string>(hidden.map((x) => x.userId));
}
private async updateFollowingChannels() { private async updateFollowingChannels() {
const followings = await ChannelFollowings.find({ const followings = await ChannelFollowings.find({
where: { where: {

View file

@ -74,6 +74,8 @@ export interface UserStreamTypes {
follow: Packed<"UserDetailedNotMe">; follow: Packed<"UserDetailedNotMe">;
unfollow: Packed<"User">; unfollow: Packed<"User">;
userAdded: Packed<"User">; userAdded: Packed<"User">;
userHidden: User["id"];
userUnhidden: User["id"];
} }
export interface MainStreamTypes { export interface MainStreamTypes {

View file

@ -0,0 +1,12 @@
import { publishUserEvent, publishUserListStream } from "@/services/stream.js";
import type { User } from "@/models/entities/user.js";
import type { UserList } from "@/models/entities/user-list.js";
import { UserListJoinings, Users } from "@/models/index.js";
export async function pullUserFromUserList(target: User, list: UserList) {
await UserListJoinings.delete({ userListId: list.id, userId: target.id });
const packed = await Users.pack(target);
publishUserListStream(list.id, "userRemoved", packed);
if (list.hideFromHomeTl) publishUserEvent(list.userId, "userUnhidden", target.id);
}

View file

@ -1,10 +1,9 @@
import { publishUserListStream } from "@/services/stream.js"; import { publishUserEvent, publishUserListStream } from "@/services/stream.js";
import type { User } from "@/models/entities/user.js"; import type { User } from "@/models/entities/user.js";
import type { UserList } from "@/models/entities/user-list.js"; import type { UserList } from "@/models/entities/user-list.js";
import { Followings, UserListJoinings, Users } from "@/models/index.js"; import { UserListJoinings, Users } from "@/models/index.js";
import type { UserListJoining } from "@/models/entities/user-list-joining.js"; import type { UserListJoining } from "@/models/entities/user-list-joining.js";
import { genId } from "@/misc/gen-id.js"; import { genId } from "@/misc/gen-id.js";
import { ApiError } from "@/server/api/error.js";
export async function pushUserToUserList(target: User, list: UserList) { export async function pushUserToUserList(target: User, list: UserList) {
await UserListJoinings.insert({ await UserListJoinings.insert({
@ -14,5 +13,7 @@ export async function pushUserToUserList(target: User, list: UserList) {
userListId: list.id, userListId: list.id,
} as UserListJoining); } as UserListJoining);
publishUserListStream(list.id, "userAdded", await Users.pack(target)); const packed = await Users.pack(target);
publishUserListStream(list.id, "userAdded", packed);
if (list.hideFromHomeTl) publishUserEvent(list.userId, "userHidden", target.id);
} }

View file

@ -20,6 +20,11 @@
<MkButton inline @click="deleteList()">{{ <MkButton inline @click="deleteList()">{{
i18n.ts.delete i18n.ts.delete
}}</MkButton> }}</MkButton>
<FormSection>
<FormSwitch v-model="hideFromHomeTl">{{
i18n.ts.hideFromHome
}}</FormSwitch>
</FormSection>
</div> </div>
</div> </div>
</transition> </transition>
@ -72,12 +77,15 @@ import * as os from "@/os";
import { mainRouter } from "@/router"; import { mainRouter } from "@/router";
import { definePageMetadata } from "@/scripts/page-metadata"; import { definePageMetadata } from "@/scripts/page-metadata";
import { i18n } from "@/i18n"; import { i18n } from "@/i18n";
import FormSwitch from "@/components/form/switch.vue";
import FormSection from "@/components/form/section.vue";
const props = defineProps<{ const props = defineProps<{
listId: string; listId: string;
}>(); }>();
let list = $ref(null); let list = $ref(null);
let hideFromHomeTl = $ref(false);
let users = $ref([]); let users = $ref([]);
function fetchList() { function fetchList() {
@ -85,6 +93,7 @@ function fetchList() {
listId: props.listId, listId: props.listId,
}).then((_list) => { }).then((_list) => {
list = _list; list = _list;
hideFromHomeTl = _list.hideFromHomeTl;
os.api("users/show", { os.api("users/show", {
userIds: list.userIds, userIds: list.userIds,
}).then((_users) => { }).then((_users) => {
@ -142,7 +151,15 @@ async function deleteList() {
mainRouter.push("/my/lists"); mainRouter.push("/my/lists");
} }
async function hideFromHome() {
await os.api("users/lists/update", {
listId: list.id,
hideFromHomeTl: hideFromHomeTl,
});
}
watch(() => props.listId, fetchList, { immediate: true }); watch(() => props.listId, fetchList, { immediate: true });
watch(() => hideFromHomeTl, hideFromHome);
const headerActions = $computed(() => []); const headerActions = $computed(() => []);