[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
No known key found for this signature in database
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)"
alternative: "Alternative (Firefish-like)"
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.",
})
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,
createdAt: userList.createdAt.toISOString(),
name: userList.name,
hideFromHomeTl: userList.hideFromHomeTl,
userIds: users.map((x) => x.userId),
};
},

View file

@ -19,6 +19,11 @@ export const packedUserListSchema = {
optional: false,
nullable: false,
},
hideFromHomeTl: {
type: "boolean",
optional: false,
nullable: false,
},
userIds: {
type: "array",
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 { generateBlockedUserQuery } from "../../common/generate-block-query.js";
import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export const meta = {
tags: ["notes"],
@ -108,6 +109,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.setParameters(followingQuery.getParameters());
generateListQuery(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, ps.withReplies, 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 { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js";
import { ApiError } from "../../error.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export const meta = {
tags: ["notes"],
@ -104,6 +105,7 @@ export default define(meta, paramDef, async (ps, user) => {
.leftJoinAndSelect("renoteUser.banner", "renoteUserBanner")
.setParameters(followingQuery.getParameters());
generateListQuery(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, ps.withReplies, user);
generateVisibilityQuery(query, user);

View file

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

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 { ApiError } from "../../../error.js";
import { publishUserEvent } from "@/services/stream.js";
export const meta = {
tags: ["lists"],
@ -32,8 +33,9 @@ export const paramDef = {
properties: {
listId: { type: "string", format: "misskey:id" },
name: { type: "string", minLength: 1, maxLength: 100 },
hideFromHomeTl: { type: "boolean", nullable: true },
},
required: ["listId", "name"],
required: ["listId"],
} as const;
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);
}
await UserLists.update(userList.id, {
name: ps.name,
const partial = {
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);
});

View file

@ -39,7 +39,8 @@ export function setupEndpointsList(router: Router): void {
const body = ctx.request.body as any;
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 } }>(

View file

@ -2,5 +2,6 @@ namespace MastodonEntity {
export type List = {
id: 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 { pushUserToUserList } from "@/services/user-list/push.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 { MastoContext } from "@/server/api/mastodon/index.js";
import { pullUserFromUserList } from "@/services/user-list/pull.js";
import { publishUserEvent } from "@/services/stream.js";
export class ListHelpers {
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 {
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 {
id: list.id,
title: list.name
title: list.name,
exclusive: list.hideFromHomeTl
}
});
}
@ -110,8 +113,7 @@ export class ListHelpers {
});
if (!exist) continue;
await UserListJoinings.delete({ userListId: list.id, userId: user.id });
publishUserListStream(list.id, "userRemoved", await Users.pack(user));
await pullUserFromUserList(user, list);
}
}
@ -128,23 +130,35 @@ export class ListHelpers {
return {
id: list.id,
title: list.name
title: list.name,
exclusive: list.hideFromHomeTl
};
}
public static async updateList(list: UserList, title: string, ctx: MastoContext) {
if (title.length < 1) throw new MastoApiError(400, "Title must not be empty");
public static async updateList(list: UserList, title: string, exclusive: boolean | undefined, ctx: MastoContext): Promise<MastodonEntity.List> {
if (title.length < 1 && exclusive === undefined) throw new MastoApiError(400, "Either title or exclusive must be set");
const user = ctx.user as ILocalUser;
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)
.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 {
id: result.id,
title: result.name
title: result.name,
exclusive: result.hideFromHomeTl
};
}
@ -162,7 +176,8 @@ export class ListHelpers {
.then(results => results.map(result => {
return {
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 { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js";
import { MastoContext } from "@/server/api/mastodon/index.js";
import { generateListQuery } from "@/server/api/common/generate-list-query.js";
export class TimelineHelpers {
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");
generateListQuery(query, user);
generateChannelQuery(query, user);
generateRepliesQuery(query, true, user);
generateVisibilityQuery(query, user);

View file

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

View file

@ -90,12 +90,14 @@ export class MastodonStreamUser extends MastodonStream {
private async shouldProcessNote(note: Note): Promise<boolean> {
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 (this.user!.id !== note.userId && !this.following.has(note.userId)) return false;
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return false;
if (isUserRelated(note, this.muting)) 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 (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 { ILocalUser, User } from "@/models/entities/user.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 { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js";
import { apiLogger } from "@/server/api/logger.js";
@ -40,6 +40,7 @@ export class MastodonStreamingConnection {
public muting: Set<User["id"]> = new Set();
public renoteMuting: Set<User["id"]> = new Set();
public blocking: Set<User["id"]> = new Set();
public hidden: Set<User["id"]> = new Set();
public token?: OAuthToken;
private wsConnection: websocket.connection;
private channels: MastodonStream[] = [];
@ -69,6 +70,7 @@ export class MastodonStreamingConnection {
this.updateMuting();
this.updateRenoteMuting();
this.updateBlocking();
this.updateHidden();
this.updateUserProfile();
this.subscriber.on(`user:${this.user.id}`, this.onUserEvent);
@ -98,6 +100,12 @@ export class MastodonStreamingConnection {
case "unmute":
this.muting.delete(data.body.id);
break;
case "userHidden":
this.hidden.add(data.body);
break;
case "userUnhidden":
this.hidden.delete(data.body);
break;
// TODO: renote mute events
// TODO: block events
@ -247,6 +255,17 @@ export class MastodonStreamingConnection {
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() {
this.userProfile = await UserProfiles.findOneBy({
userId: this.user!.id,

View file

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

View file

@ -57,6 +57,8 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
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;

View file

@ -74,6 +74,8 @@ export default class extends Channel {
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
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;

View file

@ -10,7 +10,7 @@ import {
RenoteMutings,
UserProfiles,
ChannelFollowings,
Blockings,
Blockings, UserListJoinings,
} from "@/models/index.js";
import type { AccessToken } from "@/models/entities/access-token.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 muting: 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 token?: AccessToken;
private wsConnection: websocket.connection;
@ -79,6 +80,7 @@ export default class Connection {
this.updateMuting();
this.updateRenoteMuting();
this.updateBlocking();
this.updateHidden();
this.updateFollowingChannels();
this.updateUserProfile();
@ -122,6 +124,14 @@ export default class Connection {
this.followingChannels.delete(data.body.id);
break;
case "userHidden":
this.hidden.add(data.body);
break;
case "userUnhidden":
this.hidden.delete(data.body);
break;
case "updateUserProfile":
this.userProfile = data.body;
break;
@ -432,6 +442,17 @@ export default class Connection {
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() {
const followings = await ChannelFollowings.find({
where: {

View file

@ -74,6 +74,8 @@ export interface UserStreamTypes {
follow: Packed<"UserDetailedNotMe">;
unfollow: Packed<"User">;
userAdded: Packed<"User">;
userHidden: User["id"];
userUnhidden: User["id"];
}
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 { 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 { genId } from "@/misc/gen-id.js";
import { ApiError } from "@/server/api/error.js";
export async function pushUserToUserList(target: User, list: UserList) {
await UserListJoinings.insert({
@ -14,5 +13,7 @@ export async function pushUserToUserList(target: User, list: UserList) {
userListId: list.id,
} 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()">{{
i18n.ts.delete
}}</MkButton>
<FormSection>
<FormSwitch v-model="hideFromHomeTl">{{
i18n.ts.hideFromHome
}}</FormSwitch>
</FormSection>
</div>
</div>
</transition>
@ -72,12 +77,15 @@ import * as os from "@/os";
import { mainRouter } from "@/router";
import { definePageMetadata } from "@/scripts/page-metadata";
import { i18n } from "@/i18n";
import FormSwitch from "@/components/form/switch.vue";
import FormSection from "@/components/form/section.vue";
const props = defineProps<{
listId: string;
}>();
let list = $ref(null);
let hideFromHomeTl = $ref(false);
let users = $ref([]);
function fetchList() {
@ -85,6 +93,7 @@ function fetchList() {
listId: props.listId,
}).then((_list) => {
list = _list;
hideFromHomeTl = _list.hideFromHomeTl;
os.api("users/show", {
userIds: list.userIds,
}).then((_users) => {
@ -142,7 +151,15 @@ async function deleteList() {
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(() => hideFromHomeTl, hideFromHome);
const headerActions = $computed(() => []);