Recommended timeline!

This commit is contained in:
ThatOneCalculator 2022-07-28 21:28:13 -07:00
parent 8fc5600282
commit bcacfc0811
18 changed files with 305 additions and 4 deletions

View File

@ -34,6 +34,7 @@
- Yarn 3
- Saner defaults
- Star as default reaction
- Recommended Instances timeline
- Rosé Pine by default (+ non-themable elements made Rosé Pine)
- Better sidebar/navbar
- MOTD (customizable by admins!)

View File

@ -41,7 +41,7 @@ Misskey documentation can be found at [Misskey Hub](https://misskey-hub.net/).
## 🚚 Migrating from Misskey to Calckey
You need at least 🐢 NodeJS v16.15.0 (v18.4.0 reccomended!) and *exactly* 🧶 Yarn v3.2.1!
You need at least 🐢 NodeJS v16.15.0 (v18.4.0 recommended!) and *exactly* 🧶 Yarn v3.2.1!
### 📩 Install dependencies

View File

@ -322,6 +322,7 @@ connectService: "Connect"
disconnectService: "Disconnect"
enableLocalTimeline: "Enable local timeline"
enableGlobalTimeline: "Enable global timeline"
enableRecommendedTimeline: "Enable recommended timeline"
disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all timelines, even if they are not enabled."
registration: "Register"
enableRegistration: "Enable new user registration"
@ -789,6 +790,7 @@ previewNoteText: "Show preview"
customCss: "Custom CSS"
customCssWarn: "This setting should only be used if you know what it does. Entering improper values may cause the client to stop functioning normally."
global: "Global"
recommended: "Recommended"
squareAvatars: "Display squared avatars"
sent: "Sent"
received: "Received"
@ -906,6 +908,8 @@ customMOTDDescription: "Custom messages for the MOTD (splash screen) separated b
customSplashIcons: "Custom splash screen icons (urls)"
customSplashIconsDescription: "URLs for custom splash screen icons separated by line breaks to be shown randomly every time a user loads/reloads the page. Please make sure the images are on a static URL, preferably all resized to 192x192."
showUpdates: "Show a popup when Calckey updates"
recommendedInstances: "Recommended instances"
recommendedInstancesDescription: "Recommended instances seperated by line breaks to appear in the recommended timeline. Do NOT add `https://`, ONLY the domain."
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing NSFW media via Machine Learning. This will slightly increase the load on the server."
@ -1382,6 +1386,7 @@ _instanceCharts:
_timelines:
home: "Home"
local: "Local"
recommended: "Recommended"
social: "Social"
global: "Global"
_pages:

View File

@ -0,0 +1,11 @@
export class RecommendedTimeline1659042130648 {
name = 'RecommendedTimeline1659042130648'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "disableRecommendedTimeline" boolean NOT NULL DEFAULT true`);
await queryRunner.query(`ALTER TABLE "meta" ADD "recommendedInstances" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "disableRecommendedTimeline"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "recommendedInstances"`);
}
}

View File

@ -47,6 +47,11 @@ export class Meta {
})
public disableLocalTimeline: boolean;
@Column('boolean', {
default: true,
})
public disableRecommendedTimeline: boolean;
@Column('boolean', {
default: false,
})
@ -67,6 +72,11 @@ export class Meta {
})
public pinnedUsers: string[];
@Column('varchar', {
length: 256, array: true, default: '{}',
})
public recommendedInstances: string[];
@Column('varchar', {
length: 256, array: true, default: '{}',
})

View File

@ -267,6 +267,7 @@ import * as ep___pages_show from './endpoints/pages/show.js';
import * as ep___pages_unlike from './endpoints/pages/unlike.js';
import * as ep___pages_update from './endpoints/pages/update.js';
import * as ep___ping from './endpoints/ping.js';
import * as ep___recommendedInstances from './endpoints/recommended-instances.js';
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___customMOTD from './endpoints/custom-motd.js';
import * as ep___customSplashIcons from './endpoints/custom-splash-icons.js';
@ -587,6 +588,7 @@ const eps = [
['pages/update', ep___pages_update],
['ping', ep___ping],
['pinned-users', ep___pinnedUsers],
['recommended-instances', ep___recommendedInstances],
['custom-motd', ep___customMOTD],
['custom-motd', ep___customSplashIcons],
['promo/read', ep___promo_read],

View File

@ -163,6 +163,14 @@ export const meta = {
type: 'boolean',
optional: true, nullable: false,
},
recommendedInstances: {
type: 'array',
optional: true, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
pinnedUsers: {
type: 'array',
optional: true, nullable: false,
@ -388,6 +396,7 @@ export default define(meta, paramDef, async (ps, me) => {
feedbackUrl: instance.feedbackUrl,
disableRegistration: instance.disableRegistration,
disableLocalTimeline: instance.disableLocalTimeline,
disableRecommendedTimeline: instance.disableRecommendedTimeline,
disableGlobalTimeline: instance.disableGlobalTimeline,
driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
@ -417,6 +426,7 @@ export default define(meta, paramDef, async (ps, me) => {
pinnedClipId: instance.pinnedClipId,
cacheRemoteFiles: instance.cacheRemoteFiles,
useStarForReactionFallback: instance.useStarForReactionFallback,
recommendedInstances: instance.recommendedInstances,
pinnedUsers: instance.pinnedUsers,
customMOTD: instance.customMOTD,
customSplashIcons: instance.customSplashIcons,

View File

@ -16,8 +16,12 @@ export const paramDef = {
properties: {
disableRegistration: { type: 'boolean', nullable: true },
disableLocalTimeline: { type: 'boolean', nullable: true },
disableRecommendedTimeline: { type: 'boolean', nullable: true },
disableGlobalTimeline: { type: 'boolean', nullable: true },
useStarForReactionFallback: { type: 'boolean', nullable: true },
recommendedInstances: { type: 'array', nullable: true, items: {
type: 'string',
} },
pinnedUsers: { type: 'array', nullable: true, items: {
type: 'string',
} },
@ -129,6 +133,10 @@ export default define(meta, paramDef, async (ps, me) => {
set.disableLocalTimeline = ps.disableLocalTimeline;
}
if (typeof ps.disableRecommendedTimeline === 'boolean') {
set.disableRecommendedTimeline = ps.disableRecommendedTimeline;
}
if (typeof ps.disableGlobalTimeline === 'boolean') {
set.disableGlobalTimeline = ps.disableGlobalTimeline;
}
@ -149,6 +157,10 @@ export default define(meta, paramDef, async (ps, me) => {
set.customSplashIcons = ps.customSplashIcons.filter(Boolean);
}
if (Array.isArray(ps.recommendedInstances)) {
set.recommendedInstances = ps.recommendedInstances.filter(Boolean);
}
if (Array.isArray(ps.hiddenTags)) {
set.hiddenTags = ps.hiddenTags.filter(Boolean);
}

View File

@ -80,6 +80,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
disableRecommendedTimeline: {
type: 'boolean',
optional: false, nullable: false,
},
disableGlobalTimeline: {
type: 'boolean',
optional: false, nullable: false,
@ -248,6 +252,11 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
recommended
TimeLine: {
type: 'boolean',
optional: false, nullable: false,
},
globalTimeLine: {
type: 'boolean',
optional: false, nullable: false,
@ -356,6 +365,7 @@ export default define(meta, paramDef, async (ps, me) => {
disableRegistration: instance.disableRegistration,
disableLocalTimeline: instance.disableLocalTimeline,
disableRecommendedTimeline: instance.disableRecommendedTimeline,
disableGlobalTimeline: instance.disableGlobalTimeline,
driveCapacityPerLocalUserMb: instance.localDriveCapacityMb,
driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb,
@ -412,6 +422,8 @@ export default define(meta, paramDef, async (ps, me) => {
response.features = {
registration: !instance.disableRegistration,
localTimeLine: !instance.disableLocalTimeline,
recommended
Timeline: !instance.disableRecommendedTimeline,
globalTimeLine: !instance.disableGlobalTimeline,
emailRequiredForSignup: instance.emailRequiredForSignup,
elasticsearch: config.elasticsearch ? true : false,

View File

@ -0,0 +1,120 @@
import { Brackets } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { Notes, Users } from '@/models/index.js';
import { activeUsersChart } from '@/services/chart/index.js';
import define from '../../define.js';
import { ApiError } from '../../error.js';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js';
import { makePaginationQuery } from '../../common/make-pagination-query.js';
import { generateVisibilityQuery } from '../../common/generate-visibility-query.js';
import { generateRepliesQuery } from '../../common/generate-replies-query.js';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js';
import { generateChannelQuery } from '../../common/generate-channel-query.js';
import { generateBlockedUserQuery } from '../../common/generate-block-query.js';
export const meta = {
tags: ['notes'],
requireCredentialPrivateMode: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Note',
},
},
errors: {
ltlDisabled: {
message: 'Recommended timeline has been disabled.',
code: 'LTL_DISABLED',
id: '45a6eb02-7695-4393-b023-dd3be9aaaefe',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
withFiles: {
type: 'boolean',
default: false,
description: 'Only show notes that have attached files.',
},
fileType: { type: 'array', items: {
type: 'string',
} },
excludeNsfw: { type: 'boolean', default: false },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
const m = await fetchMeta();
if (m.disableLocalTimeline) {
if (user == null || (!user.isAdmin && !user.isModerator)) {
throw new ApiError(meta.errors.ltlDisabled);
}
}
//#region Construct query
const query = makePaginationQuery(Notes.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('(note.visibility = \'public\') AND (note.userHost = ANY(meta.recommendedInstances))')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
generateChannelQuery(query, user);
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.fileType != null) {
query.andWhere('note.fileIds != \'{}\'');
query.andWhere(new Brackets(qb => {
for (const type of ps.fileType!) {
const i = ps.fileType!.indexOf(type);
qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type });
}
}));
if (ps.excludeNsfw) {
query.andWhere('note.cw IS NULL');
query.andWhere('0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)');
}
}
//#endregion
const timeline = await query.take(ps.limit).getMany();
process.nextTick(() => {
if (user) {
activeUsersChart.read(user);
}
});
return await Notes.packMany(timeline, user);
});

View File

@ -0,0 +1,32 @@
// import { IsNull } from 'typeorm';
import { fetchMeta } from '@/misc/fetch-meta.js';
import define from '../define.js';
export const meta = {
tags: ['meta'],
requireCredential: false,
requireCredentialPrivateMode: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async () => {
const meta = await fetchMeta();
const instances = await Promise.all(meta.recommendedInstances.map(x => x));
return instances;
});

View File

@ -0,0 +1,61 @@
import Channel from '../channel.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { checkWordMute } from '@/misc/check-word-mute.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { Packed } from '@/misc/schema.js';
export default class extends Channel {
public readonly chName = 'recommendedTimeline';
public static shouldShare = true;
public static requireCredential = false;
constructor(id: string, connection: Channel['connection']) {
super(id, connection);
this.onNote = this.withPackedNote(this.onNote.bind(this));
}
public async init(params: any) {
const meta = await fetchMeta();
if (meta.disableRecommendedTimeline) {
if (this.user == null || (!this.user.isAdmin && !this.user.isModerator)) return;
}
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
}
private async onNote(note: Packed<'Note'>) {
const meta = await fetchMeta();
if (note.user.host !== null && !meta.recommendedInstances.includes(note.user.host)) return;
if (note.visibility !== 'public') return;
if (note.channelId != null && !this.followingChannels.has(note.channelId)) return;
// 関係ない返信は除外
if (note.reply && !this.user!.showTimelineReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、
// レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。
// そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる
if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return;
this.connection.cacheNote(note);
this.send('note', note);
}
public dispose() {
// Unsubscribe events
this.subscriber.off('notesStream', this.onNote);
}
}

View File

@ -67,6 +67,7 @@ const nodeinfo2 = async () => {
feedbackUrl: meta.feedbackUrl,
disableRegistration: meta.disableRegistration,
disableLocalTimeline: meta.disableLocalTimeline,
disableRecommendedTimeline: meta.disableRecommendedTimeline,
disableGlobalTimeline: meta.disableGlobalTimeline,
emailRequiredForSignup: meta.emailRequiredForSignup,
enableHcaptcha: meta.enableHcaptcha,

View File

@ -57,9 +57,15 @@
<FormSection>
<FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch>
<FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch>
<FormSwitch v-model="enableRecommendedTimeline" class="_formBlock">{{ i18n.ts.enableRecommendedTimeline }}</FormSwitch>
<FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo>
</FormSection>
<FormTextarea v-model="recommendedInstances" class="_formBlock">
<template #label>{{ i18n.ts.recommendedInstances }}</template>
<template #caption>{{ i18n.ts.recommendedInstancesDescription }}</template>
</FormTextarea>
<FormSection>
<template #label>{{ i18n.ts.theme }}</template>
@ -185,8 +191,10 @@ let defaultLightTheme: any = $ref(null);
let defaultDarkTheme: any = $ref(null);
let enableLocalTimeline: boolean = $ref(false);
let enableGlobalTimeline: boolean = $ref(false);
let enableRecommendedTimeline: boolean = $ref(false);
let pinnedUsers: string = $ref('');
let customMOTD: string = $ref('');
let recommendedInstances: string = $ref('');
let customSplashIcons: string = $ref('');
let cacheRemoteFiles: boolean = $ref(false);
let localDriveCapacityMb: any = $ref(0);
@ -214,9 +222,11 @@ async function init() {
maintainerEmail = meta.maintainerEmail;
enableLocalTimeline = !meta.disableLocalTimeline;
enableGlobalTimeline = !meta.disableGlobalTimeline;
enableRecommendedTimeline = !meta.disableRecommendedTimeline;
pinnedUsers = meta.pinnedUsers.join('\n');
customMOTD = meta.customMOTD.join('\n');
customSplashIcons = meta.customSplashIcons.join('\n');
recommendedInstances = meta.recommendedInstances.join('\n');
cacheRemoteFiles = meta.cacheRemoteFiles;
localDriveCapacityMb = meta.driveCapacityPerLocalUserMb;
remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb;
@ -244,9 +254,11 @@ function save() {
maintainerEmail,
disableLocalTimeline: !enableLocalTimeline,
disableGlobalTimeline: !enableGlobalTimeline,
disableRecommendedTimeline: !enableRecommendedTimeline,
pinnedUsers: pinnedUsers.split('\n'),
customMOTD: customMOTD.split('\n'),
customSplashIcons: customSplashIcons.split('\n'),
recommendedInstances: recommendedInstances.split('\n'),
cacheRemoteFiles,
localDriveCapacityMb: parseInt(localDriveCapacityMb, 10),
remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10),

View File

@ -36,6 +36,7 @@ import { definePageMetadata } from '@/scripts/page-metadata';
const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue'));
const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
const isRecommendedTimelineAvailable = !instance.disableRecommendedTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin));
const keymap = {
't': focus,
@ -143,6 +144,13 @@ const headerTabs = $computed(() => [{
title: i18n.ts._timelines.local,
icon: 'fas fa-user-group',
iconOnly: true,
}, ...(isRecommendedTimelineAvailable ? [{
key: 'recommended
',
title: i18n.ts._timelines.recommended
,
icon: 'fas fa-comet',
iconOnly: true,
}, {
key: 'social',
title: i18n.ts._timelines.social,
@ -172,7 +180,8 @@ const headerTabsWhenNotLogin = $computed(() => [
definePageMetadata(computed(() => ({
title: i18n.ts.timeline,
icon: src === 'local' ? 'fas fa-user-group' : src === 'social' ? 'fas fa-handshake-simple' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
icon: src === 'local' ? 'fas fa-user-group' : src === 'social' ? 'fas fa-handshake-simple' : src === 'recommended
' ? 'fas fa-comet' : src === 'global' ? 'fas fa-globe' : 'fas fa-home',
})));
</script>

View File

@ -49,6 +49,7 @@ onMounted(() => {
} else if ($i) {
disabled = !$i.isModerator && !$i.isAdmin && (
instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) ||
instance.disableRecommendedTimeline && ['recommended'].includes(props.column.tl) ||
instance.disableGlobalTimeline && ['global'].includes(props.column.tl));
}
});
@ -60,6 +61,8 @@ async function setType() {
value: 'home' as const, text: i18n.ts._timelines.home,
}, {
value: 'local' as const, text: i18n.ts._timelines.local,
}, {
value: 'recommended' as const, text: i18n.ts._timelines.recommended,
}, {
value: 'social' as const, text: i18n.ts._timelines.social,
}, {

View File

@ -77,7 +77,7 @@ const announcements = {
endpoint: 'announcements',
limit: 10,
};
const isTimelineAvailable = !instance.disableLocalTimeline || !instance.disableGlobalTimeline;
const isTimelineAvailable = !instance.disableLocalTimeline || !instance.disableRecommendedTimeline || !instance.disableGlobalTimeline;
let showMenu = $ref(false);
let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD);
let narrow = $ref(window.innerWidth < 1280);

View File

@ -60,7 +60,7 @@ export default defineComponent({
return {
narrow: null,
showMenu: false,
isTimelineAvailable: !instance.disableLocalTimeline || !instance.disableGlobalTimeline,
isTimelineAvailable: !instance.disableLocalTimeline || !instance.disableRecommendedTimeline || !instance.disableGlobalTimeline,
};
},