Merge branch 'develop' into release/2024.5.0

This commit is contained in:
syuilo 2024-05-21 17:37:35 +09:00 committed by GitHub
commit 4a3c01d198
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 1449 additions and 417 deletions

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ChannelIdDenormalizedForMiPoll1716129964060 {
name = 'ChannelIdDenormalizedForMiPoll1716129964060'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "poll" ADD "channelId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`);
await queryRunner.query(`CREATE INDEX "IDX_c1240fcc9675946ea5d6c2860e" ON "poll" ("channelId") `);
await queryRunner.query(`UPDATE "poll" SET "channelId" = "note"."channelId" FROM "note" WHERE "poll"."noteId" = "note"."id"`);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_c1240fcc9675946ea5d6c2860e"`);
await queryRunner.query(`COMMENT ON COLUMN "poll"."channelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "poll" DROP COLUMN "channelId"`);
}
}

View file

@ -117,7 +117,7 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "14.2.1",
"happy-dom": "14.7.1",
"happy-dom": "10.0.3",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",

View file

@ -61,8 +61,8 @@ export class FanoutTimelineEndpointService {
// 呼び出し元と以下の処理をシンプルにするためにdbFallbackを置き換える
if (!ps.useDbFallback) ps.dbFallback = () => Promise.resolve([]);
const shouldPrepend = ps.sinceId && !ps.untilId;
const idCompare: (a: string, b: string) => number = shouldPrepend ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1;
const ascending = ps.sinceId && !ps.untilId;
const idCompare: (a: string, b: string) => number = ascending ? (a, b) => a < b ? -1 : 1 : (a, b) => a > b ? -1 : 1;
const redisResult = await this.fanoutTimelineService.getMulti(ps.redisTimelines, ps.untilId, ps.sinceId);
@ -142,9 +142,7 @@ export class FanoutTimelineEndpointService {
if (ps.allowPartial ? redisTimeline.length !== 0 : redisTimeline.length >= ps.limit) {
// 十分Redisからとれた
const result = redisTimeline.slice(0, ps.limit);
if (shouldPrepend) result.reverse();
return result;
return redisTimeline.slice(0, ps.limit);
}
}
@ -152,8 +150,7 @@ export class FanoutTimelineEndpointService {
const remainingToRead = ps.limit - redisTimeline.length;
let dbUntil: string | null;
let dbSince: string | null;
if (shouldPrepend) {
redisTimeline.reverse();
if (ascending) {
dbUntil = ps.untilId;
dbSince = noteIds[noteIds.length - 1];
} else {
@ -161,7 +158,7 @@ export class FanoutTimelineEndpointService {
dbSince = ps.sinceId;
}
const gotFromDb = await ps.dbFallback(dbUntil, dbSince, remainingToRead);
return shouldPrepend ? [...gotFromDb, ...redisTimeline] : [...redisTimeline, ...gotFromDb];
return [...redisTimeline, ...gotFromDb];
}
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);

View file

@ -473,6 +473,7 @@ export class NoteCreateService implements OnApplicationShutdown {
noteVisibility: insert.visibility,
userId: user.id,
userHost: user.host,
channelId: insert.channelId,
});
await transactionalEntityManager.insert(MiPoll, poll);

View file

@ -228,7 +228,7 @@ export type SchemaTypeDef<p extends Schema> =
p['items']['allOf'] extends ReadonlyArray<Schema> ? UnionToIntersection<UnionSchemaType<NonNullable<p['items']['allOf']>>>[] :
never
) :
p['items'] extends NonNullable<Schema> ? SchemaTypeDef<p['items']>[] :
p['items'] extends NonNullable<Schema> ? SchemaType<p['items']>[] :
any[]
) :
p['anyOf'] extends ReadonlyArray<Schema> ? UnionSchemaType<p['anyOf']> & PartialIntersection<UnionSchemaType<p['anyOf']>> :

View file

@ -8,6 +8,7 @@ import { noteVisibilities } from '@/types.js';
import { id } from './util/id.js';
import { MiNote } from './Note.js';
import type { MiUser } from './User.js';
import type { MiChannel } from "@/models/Channel.js";
@Entity('poll')
export class MiPoll {
@ -58,6 +59,14 @@ export class MiPoll {
comment: '[Denormalized]',
})
public userHost: string | null;
@Index()
@Column({
...id(),
nullable: true,
comment: '[Denormalized]',
})
public channelId: MiChannel['id'] | null;
//#endregion
constructor(data: Partial<MiPoll>) {

View file

@ -7,7 +7,7 @@ import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository } from '@/models/_.js';
import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js';
import { FilterUnionByProperty, notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
@ -84,27 +84,51 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const limit = ps.limit + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1
const notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`,
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-',
'COUNT', limit);
let sinceTime = ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime().toString() : null;
let untilTime = ps.untilId ? this.idService.parse(ps.untilId).date.getTime().toString() : null;
if (notificationsRes.length === 0) {
return [];
}
let notifications: MiNotification[];
for (;;) {
let notificationsRes: [id: string, fields: string[]][];
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[];
// sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照
if (sinceTime && !untilTime) {
notificationsRes = await this.redisClient.xrange(
`notificationTimeline:${me.id}`,
'(' + sinceTime,
'+',
'COUNT', ps.limit);
} else {
notificationsRes = await this.redisClient.xrevrange(
`notificationTimeline:${me.id}`,
untilTime ? '(' + untilTime : '+',
sinceTime ? '(' + sinceTime : '-',
'COUNT', ps.limit);
}
if (includeTypes && includeTypes.length > 0) {
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
} else if (excludeTypes && excludeTypes.length > 0) {
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
}
if (notificationsRes.length === 0) {
return [];
}
if (notifications.length === 0) {
return [];
notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[];
if (includeTypes && includeTypes.length > 0) {
notifications = notifications.filter(notification => includeTypes.includes(notification.type));
} else if (excludeTypes && excludeTypes.length > 0) {
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
}
if (notifications.length !== 0) {
// 通知が1件以上ある場合は返す
break;
}
// フィルタしたことで通知が0件になった場合、次のページを取得する
if (ps.sinceId && !ps.untilId) {
sinceTime = notificationsRes[notificationsRes.length - 1][0];
} else {
untilTime = notificationsRes[notificationsRes.length - 1][0];
}
}
// Mark all as read

View file

@ -32,6 +32,7 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
excludeChannels: { type: 'boolean', default: false },
},
required: [],
} as const;
@ -86,6 +87,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.setParameters(mutingQuery.getParameters());
//#endregion
//#region exclude channels
if (ps.excludeChannels) {
query.andWhere('poll.channelId IS NULL');
}
//#endregion
const polls = await query
.orderBy('poll.noteId', 'DESC')
.limit(ps.limit)

View file

@ -110,9 +110,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
// リクエストされた通りに並べ替え
// 順番は保持されるけど数は減ってる可能性がある
const _users: MiUser[] = [];
for (const id of ps.userIds) {
_users.push(users.find(x => x.id === id)!);
const user = users.find(x => x.id === id);
if (user != null) _users.push(user);
}
return await Promise.all(_users.map(u => this.userEntityService.pack(u, me, {

View file

@ -438,7 +438,7 @@ export class ClientServerService {
//#endregion
const renderBase = async (reply: FastifyReply) => {
const renderBase = async (reply: FastifyReply, data: { [key: string]: any } = {}) => {
const meta = await this.metaService.fetch();
reply.header('Cache-Control', 'public, max-age=30');
return await reply.view('base', {
@ -447,6 +447,7 @@ export class ClientServerService {
title: meta.name ?? 'Misskey',
desc: meta.description,
...await this.generateCommonPugData(meta),
...data,
});
};
@ -744,6 +745,18 @@ export class ClientServerService {
});
//#endregion
//region noindex pages
// Tags
fastify.get<{ Params: { clip: string; } }>('/tags/:tag', async (request, reply) => {
return await renderBase(reply, { noindex: true });
});
// User with Tags
fastify.get<{ Params: { clip: string; } }>('/user-tags/:tag', async (request, reply) => {
return await renderBase(reply, { noindex: true });
});
//endregion
fastify.get('/_info_card_', async (request, reply) => {
const meta = await this.metaService.fetch(true);

View file

@ -50,6 +50,9 @@ html
block title
= title || 'Misskey'
if noindex
meta(name='robots' content='noindex')
block desc
meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨')

View file

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true" as="image" type="image/png" crossorigin="anonymous">
<link rel="preload" href="https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true" as="image" type="image/jpeg" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@3.3.0/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@3.3.0/dist/tabler-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/@fontsource/m-plus-rounded-1c/index.css">
<style>
html {

View file

@ -29,7 +29,7 @@
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.0.4",
"@vue/compiler-sfc": "3.4.26",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.4",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9",
"astring": "1.8.6",
"broadcast-channel": "7.0.0",
"buraha": "0.0.1",
@ -119,7 +119,7 @@
"eslint-plugin-import": "2.29.1",
"eslint-plugin-vue": "9.25.0",
"fast-glob": "3.3.2",
"happy-dom": "14.7.1",
"happy-dom": "10.0.3",
"intersection-observer": "0.12.2",
"micromatch": "4.0.5",
"msw": "2.2.14",

View file

@ -276,8 +276,11 @@ const align = () => {
const onOpened = () => {
emit('opened');
// NOTE: Chromatic undefined
if (content.value == null) return;
//
const el = content.value!.children[0];
const el = content.value.children[0];
el.addEventListener('mousedown', ev => {
contentClicking = true;
window.addEventListener('mouseup', ev => {

View file

@ -190,7 +190,7 @@ const localOnly = ref(props.initialLocalOnly ?? (defaultStore.state.rememberNote
const visibility = ref(props.initialVisibility ?? (defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility));
const visibleUsers = ref<Misskey.entities.UserDetailed[]>([]);
if (props.initialVisibleUsers) {
props.initialVisibleUsers.forEach(pushVisibleUser);
props.initialVisibleUsers.forEach(u => pushVisibleUser(u));
}
const reactionAcceptance = ref(defaultStore.state.reactionAcceptance);
const autocomplete = ref(null);
@ -336,7 +336,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib
misskeyApi('users/show', {
userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply?.userId),
}).then(users => {
users.forEach(pushVisibleUser);
users.forEach(u => pushVisibleUser(u));
});
}
@ -967,11 +967,7 @@ onMounted(() => {
}
if (draft.data.visibleUserIds) {
misskeyApi('users/show', { userIds: draft.data.visibleUserIds }).then(users => {
for (let i = 0; i < users.length; i++) {
if (users[i].id === draft.data.visibleUserIds[i]) {
pushVisibleUser(users[i]);
}
}
users.forEach(u => pushVisibleUser(u));
});
}
}

View file

@ -11,6 +11,10 @@ import { i18n } from '@/i18n.js';
let lock: Promise<undefined> | undefined;
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const common = {
render(args) {
return {
@ -43,6 +47,8 @@ const common = {
lock = new Promise(r => resolve = r);
try {
// NOTE: sleep しないと何故か落ちる
await sleep(100);
const canvas = within(canvasElement);
const a = canvas.getByRole<HTMLAnchorElement>('link');
// await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
@ -53,7 +59,7 @@ const common = {
const i = buttons[0];
await expect(i).toBeInTheDocument();
await userEvent.click(i);
// await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(canvasElement).toHaveTextContent(i18n.ts._ad.back);
await expect(a).not.toBeInTheDocument();
await expect(i).not.toBeInTheDocument();
buttons = canvas.getAllByRole<HTMLButtonElement>('button');

View file

@ -231,6 +231,15 @@ const patronsWithIcon = [{
}, {
name: 'Takeno',
icon: 'https://assets.misskey-hub.net/patrons/6fba81536aea48fe94a30909c502dfa1.jpg',
}, {
name: 'くびすじ',
icon: 'https://assets.misskey-hub.net/patrons/aa5789850b2149aeb5b89ebe2e9083db.jpg',
}, {
name: '古道京紗@ぷらいべったー',
icon: 'https://assets.misskey-hub.net/patrons/18346d0519704963a4beabe6abc170af.jpg',
}, {
name: '越貝鯛丸',
icon: 'https://assets.misskey-hub.net/patrons/86c7374de37849b882d8ebbc833dc968.jpg',
}];
const patrons = [

View file

@ -29,6 +29,9 @@ const paginationForPolls = {
endpoint: 'notes/polls/recommendation' as const,
limit: 10,
offsetMode: true,
params: {
excludeChannels: true,
},
};
const tab = ref('notes');

View file

@ -64,7 +64,34 @@ async function init() {
// Google
if (text?.startsWith(`${title.value}.\n`)) noteText += text.replace(`${title.value}.\n`, '');
else if (text && title.value !== text) noteText += `${text}\n`;
if (url) noteText += `${url}`;
if (url) {
try {
// Normalize the URL to URL-encoded and puny-coded from with the URL constructor.
//
// It's common to use unicode characters in the URL for better visibility of URL
// like: https://ja.wikipedia.org/wiki/
// or like: https://.moe/
// However, in the MFM, the unicode characters must be URL-encoded to be parsed as `url` node
// like: https://ja.wikipedia.org/wiki/%E3%83%9F%E3%82%B9%E3%82%AD%E3%83%BC
// or like: https://xn--931a.moe/
// Therefore, we need to normalize the URL to URL-encoded form.
//
// The URL constructor will parse the URL and normalize unicode characters
// in the host to punycode and in the path component to URL-encoded form.
// (see url.spec.whatwg.org)
//
// In addition, the current MFM renderer decodes the URL-encoded path and / punycode encoded host name so
// this normalization doesn't make the visible URL ugly.
// (see MkUrl.vue)
noteText += new URL(url).href;
} catch {
// fallback to original URL if the URL is invalid.
// note that this is extremely rare since the `url` parameter is designed to share a URL and
// the URL constructor will throw TypeError only if failure, which means the URL is not valid.
noteText += url;
}
}
initialText.value = noteText.trim();
if (visibility.value === 'specified') {

View file

@ -518,6 +518,7 @@ export function getRenoteMenu(props: {
const channelRenoteItems: MenuItem[] = [];
const normalRenoteItems: MenuItem[] = [];
const normalExternalChannelRenoteItems: MenuItem[] = [];
if (appearNote.channel) {
channelRenoteItems.push(...[{
@ -596,12 +597,49 @@ export function getRenoteMenu(props: {
});
},
}]);
normalExternalChannelRenoteItems.push({
type: 'parent',
icon: 'ti ti-repeat',
text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
children: async () => {
const channels = await misskeyApi('channels/my-favorites', {
limit: 30,
});
return channels.filter((channel) => {
if (!appearNote.channelId) return true;
return channel.id !== appearNote.channelId;
}).map((channel) => ({
text: channel.name,
action: () => {
const el = props.renoteButton.value;
if (el) {
const rect = el.getBoundingClientRect();
const x = rect.left + (el.offsetWidth / 2);
const y = rect.top + (el.offsetHeight / 2);
os.popup(MkRippleEffect, { x, y }, {}, 'end');
}
if (!props.mock) {
misskeyApi('notes/create', {
renoteId: appearNote.id,
channelId: channel.id,
}).then(() => {
os.toast(i18n.tsx.renotedToX({ name: channel.name }));
});
}
},
}));
},
});
}
const renoteItems = [
...normalRenoteItems,
...(channelRenoteItems.length > 0 && normalRenoteItems.length > 0) ? [{ type: 'divider' }] as MenuItem[] : [],
...channelRenoteItems,
...(normalExternalChannelRenoteItems.length > 0 && (normalRenoteItems.length > 0 || channelRenoteItems.length > 0)) ? [{ type: 'divider' }] as MenuItem[] : [],
...normalExternalChannelRenoteItems,
];
return {

View file

@ -21019,6 +21019,8 @@ export type operations = {
limit?: number;
/** @default 0 */
offset?: number;
/** @default false */
excludeChannels?: boolean;
};
};
};