1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-07-28 01:34:38 +02:00

Merge branch 'develop' into frontend-browser-metrics-workflow-2

This commit is contained in:
syuilo
2026-07-03 11:02:04 +09:00
39 changed files with 754 additions and 1713 deletions

View File

@@ -128,7 +128,6 @@
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
"sanitize-html": "2.17.5",
"secure-json-parse": "4.1.0",
"semver": "7.8.5",

View File

@@ -5,20 +5,28 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { DataSource } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import { IdService } from '@/core/IdService.js';
import type { MiHashtag } from '@/models/Hashtag.js';
import { MiHashtag } from '@/models/Hashtag.js';
import type { HashtagsRepository, MiMeta } from '@/models/_.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { FeaturedService } from '@/core/FeaturedService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import Logger from '../logger.js';
const logger = new Logger('hashtag/create');
@Injectable()
export class HashtagService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.meta)
private meta: MiMeta,
@@ -60,19 +68,74 @@ export class HashtagService {
// TODO: サンプリング
this.updateHashtagsRanking(tag, user.id);
const index = await this.hashtagsRepository.findOneBy({ name: tag });
{
const index = await this.hashtagsRepository.findOneBy({ name: tag });
if (index == null && !inc) return;
if (index == null && inc) {
try {
if (isUserAttached) {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
await this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
return;
} catch (err) {
if (isDuplicateKeyValueError(err)) {
logger.info(`Duplicate insertion detected. Falling back to update. #${tag}`);
} else {
throw err;
}
}
}
}
if (index != null) {
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
.where('name = :name', { name: tag });
await this.db.transaction(async transactionalEntityManager => {
const transactionalHashtagRepository = transactionalEntityManager
.getRepository(MiHashtag);
const index = await transactionalHashtagRepository
.createQueryBuilder()
.setLock('pessimistic_write')
.where('name = :name', { name: tag })
.getOne();
if (index == null) return;
const set = {} as any;
if (isUserAttached) {
if (inc) {
// 自分が初めてこのタグを使ったなら
// 自分が初めてこのタグを使ったなら
if (!index.attachedUserIds.some(id => id === user.id)) {
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
@@ -117,46 +180,14 @@ export class HashtagService {
}
if (Object.keys(set).length > 0) {
q.set(set);
q.execute();
await transactionalHashtagRepository
.createQueryBuilder()
.update()
.where('id = :id', { id: index.id })
.set(set)
.execute();
}
} else {
if (isUserAttached) {
this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [],
mentionedUsersCount: 0,
mentionedLocalUserIds: [],
mentionedLocalUsersCount: 0,
mentionedRemoteUserIds: [],
mentionedRemoteUsersCount: 0,
attachedUserIds: [user.id],
attachedUsersCount: 1,
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
} as MiHashtag);
} else {
this.hashtagsRepository.insert({
id: this.idService.gen(),
name: tag,
mentionedUserIds: [user.id],
mentionedUsersCount: 1,
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
attachedUserIds: [],
attachedUsersCount: 0,
attachedLocalUserIds: [],
attachedLocalUsersCount: 0,
attachedRemoteUserIds: [],
attachedRemoteUsersCount: 0,
} as MiHashtag);
}
}
});
}
@bindThis

View File

@@ -7,9 +7,8 @@ import { INestApplicationContext } from '@nestjs/common';
process.env.NODE_ENV = 'test';
import { setTimeout } from 'node:timers/promises';
import * as assert from 'assert';
import { afterAll, beforeAll, afterEach, describe, test } from 'vitest';
import { afterAll, beforeAll, afterEach, describe, test, vi } from 'vitest';
import { loadConfig } from '@/config.js';
import { MiRepository, MiUser, UsersRepository, miRepository } from '@/models/_.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
@@ -17,6 +16,11 @@ import { jobQueue } from '@/boot/common.js';
import { api, castAsError, initTestDb, signup, successfulApiCall, uploadFile } from '../utils.js';
import type * as misskey from 'misskey-js';
// i/move 呼び出しで大部分が同期的に完了する後続ジョブ (フォロー解除/ブロック・ミュート引き継ぎ/リスト更新等) を待つ
const waitForMoveJobOptions = { timeout: 5000, interval: 50 };
// AccountMoveService がテスト環境向けに 10000ms に短縮している遅延アンフォロージョブを待つ (安全マージンを載せた上限)
const waitForDelayedUnfollowJobOptions = { timeout: 15000, interval: 100 };
describe('Account Move', () => {
let jq: INestApplicationContext;
let url: URL;
@@ -273,53 +277,63 @@ describe('Account Move', () => {
assert.strictEqual(move.status, 200);
await setTimeout(1000 * 3); // wait for jobs to finish
// Unfollow delayed?
const aliceFollowings = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3);
await vi.waitFor(async () => {
const aliceFollowings = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(aliceFollowings.status, 200);
assert.ok(aliceFollowings);
assert.strictEqual(aliceFollowings.body.length, 3);
}, waitForMoveJobOptions);
const carolFollowings = await api('users/following', {
userId: carol.id,
}, carol);
assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
await vi.waitFor(async () => {
const carolFollowings = await api('users/following', {
userId: carol.id,
}, carol);
assert.strictEqual(carolFollowings.status, 200);
assert.ok(carolFollowings);
assert.strictEqual(carolFollowings.body.length, 2);
assert.strictEqual(carolFollowings.body[0].followeeId, bob.id);
assert.strictEqual(carolFollowings.body[1].followeeId, alice.id);
}, waitForMoveJobOptions);
const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
await vi.waitFor(async () => {
const blockings = await api('blocking/list', {}, dave);
assert.strictEqual(blockings.status, 200);
assert.ok(blockings);
assert.strictEqual(blockings.body.length, 2);
assert.strictEqual(blockings.body[0].blockeeId, bob.id);
assert.strictEqual(blockings.body[1].blockeeId, alice.id);
}, waitForMoveJobOptions);
const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id);
await vi.waitFor(async () => {
const mutings = await api('mute/list', {}, dave);
assert.strictEqual(mutings.status, 200);
assert.ok(mutings);
assert.strictEqual(mutings.body.length, 2);
assert.strictEqual(mutings.body[0].muteeId, bob.id);
assert.strictEqual(mutings.body[1].muteeId, alice.id);
}, waitForMoveJobOptions);
const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2);
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
await vi.waitFor(async () => {
const rootLists = await api('users/lists/list', {}, root);
assert.strictEqual(rootLists.status, 200);
assert.ok(rootLists);
assert.ok(rootLists.body[0].userIds);
assert.strictEqual(rootLists.body[0].userIds.length, 2);
assert.ok(rootLists.body[0].userIds.find((id: string) => id === bob.id));
assert.ok(rootLists.body[0].userIds.find((id: string) => id === alice.id));
}, waitForMoveJobOptions);
const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
await vi.waitFor(async () => {
const eveLists = await api('users/lists/list', {}, eve);
assert.strictEqual(eveLists.status, 200);
assert.ok(eveLists);
assert.ok(eveLists.body[0].userIds);
assert.strictEqual(eveLists.body[0].userIds.length, 1);
assert.ok(eveLists.body[0].userIds.find((id: string) => id === bob.id));
}, waitForMoveJobOptions);
});
test('A locked account automatically accept the follow request if it had already accepted the old account.', async () => {
@@ -340,14 +354,14 @@ describe('Account Move', () => {
});
test('Unfollowed after 10 sec (24 hours in production).', async () => {
await setTimeout(1000 * 8);
await vi.waitFor(async () => {
const following = await api('users/following', {
userId: alice.id,
}, alice);
const following = await api('users/following', {
userId: alice.id,
}, alice);
assert.strictEqual(following.status, 200);
assert.strictEqual(following.body.length, 0);
assert.strictEqual(following.status, 200);
assert.strictEqual(following.body.length, 0);
}, waitForDelayedUnfollowJobOptions);
});
test('Unable to move if the destination account has already moved.', async () => {

View File

@@ -8,9 +8,9 @@ process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { describe, beforeAll, test } from 'vitest';
import { WebSocket } from 'ws';
import { MiFollowing } from '@/models/Following.js';
import { api, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js';
import { api, connectStream, createAppToken, initTestDb, port, post, signup, waitFire } from '../utils.js';
import type * as misskey from 'misskey-js';
import { MiFollowing } from '@/models/Following.js';
describe('Streaming', () => {
let Followings: any;
@@ -744,164 +744,83 @@ describe('Streaming', () => {
assert.strictEqual(fired, true);
});
// XXX: QueryFailedError: duplicate key value violates unique constraint "IDX_347fec870eafea7b26c8a73bac"
/*
describe('Hashtag Timeline', () => {
test('指定したハッシュタグの投稿が流れる', () => new Promise<void>(async done => {
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
assert.deepStrictEqual(body.text, '#foo');
ws.close();
done();
}
}, {
q: [
['foo'],
],
});
test('指定したハッシュタグの投稿が流れる', async () => {
const fired = await waitFire(
chitose, 'hashtag',
() => api('notes/create', { text: '#foo' }, chitose),
msg => msg.type === 'note' && msg.body.text === '#foo',
{ q: [['foo']] },
);
post(chitose, {
text: '#foo',
});
}));
assert.strictEqual(fired, true);
});
test('指定したハッシュタグの投稿が流れる (AND)', () => new Promise<void>(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
test('指定したハッシュタグの投稿が流れる (AND)', async () => {
const received: string[] = [];
const ws = await connectStream(chitose, 'hashtag', (msg) => {
if (msg.type === 'note') received.push(msg.body.text);
}, { q: [['foo', 'bar']] });
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
if (body.text === '#foo') fooCount++;
if (body.text === '#bar') barCount++;
if (body.text === '#foo #bar') fooBarCount++;
}
}, {
q: [
['foo', 'bar'],
],
});
await Promise.all([
await api('notes/create', { text: '#foo' }, chitose),
await api('notes/create', { text: '#bar' }, chitose),
await api('notes/create', { text: '#foo #bar' }, chitose),
]);
post(chitose, {
text: '#foo',
});
await new Promise(r => setTimeout(r, 1000));
ws.close();
post(chitose, {
text: '#bar',
});
assert.strictEqual(received.includes('#foo'), false);
assert.strictEqual(received.includes('#bar'), false);
assert.strictEqual(received.includes('#foo #bar'), true);
});
post(chitose, {
text: '#foo #bar',
});
test('指定したハッシュタグの投稿が流れる (OR)', async () => {
const received: string[] = [];
const ws = await connectStream(chitose, 'hashtag', (msg) => {
if (msg.type === 'note') received.push(msg.body.text);
}, { q: [['foo'], ['bar']] });
setTimeout(() => {
assert.strictEqual(fooCount, 0);
assert.strictEqual(barCount, 0);
assert.strictEqual(fooBarCount, 1);
ws.close();
done();
}, 3000);
}));
await Promise.all([
await api('notes/create', { text: '#foo' }, chitose),
await api('notes/create', { text: '#bar' }, chitose),
await api('notes/create', { text: '#foo #bar' }, chitose),
await api('notes/create', { text: '#piyo' }, chitose),
]);
test('指定したハッシュタグの投稿が流れる (OR)', () => new Promise<void>(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
let piyoCount = 0;
await new Promise(r => setTimeout(r, 1000));
ws.close();
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
if (body.text === '#foo') fooCount++;
if (body.text === '#bar') barCount++;
if (body.text === '#foo #bar') fooBarCount++;
if (body.text === '#piyo') piyoCount++;
}
}, {
q: [
['foo'],
['bar'],
],
});
assert.strictEqual(received.includes('#foo'), true);
assert.strictEqual(received.includes('#bar'), true);
assert.strictEqual(received.includes('#foo #bar'), true);
assert.strictEqual(received.includes('#piyo'), false);
});
post(chitose, {
text: '#foo',
});
test('指定したハッシュタグの投稿が流れる (AND + OR)', async () => {
const received: string[] = [];
const ws = await connectStream(chitose, 'hashtag', (msg) => {
if (msg.type === 'note') received.push(msg.body.text);
}, { q: [['foo', 'bar'], ['piyo']] });
post(chitose, {
text: '#bar',
});
await Promise.all([
api('notes/create', { text: '#foo' }, chitose),
api('notes/create', { text: '#bar' }, chitose),
api('notes/create', { text: '#foo #bar' }, chitose),
api('notes/create', { text: '#piyo' }, chitose),
api('notes/create', { text: '#waaa' }, chitose),
]);
post(chitose, {
text: '#foo #bar',
});
await new Promise(r => setTimeout(r, 1000));
ws.close();
post(chitose, {
text: '#piyo',
});
setTimeout(() => {
assert.strictEqual(fooCount, 1);
assert.strictEqual(barCount, 1);
assert.strictEqual(fooBarCount, 1);
assert.strictEqual(piyoCount, 0);
ws.close();
done();
}, 3000);
}));
test('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise<void>(async done => {
let fooCount = 0;
let barCount = 0;
let fooBarCount = 0;
let piyoCount = 0;
let waaaCount = 0;
const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => {
if (type === 'note') {
if (body.text === '#foo') fooCount++;
if (body.text === '#bar') barCount++;
if (body.text === '#foo #bar') fooBarCount++;
if (body.text === '#piyo') piyoCount++;
if (body.text === '#waaa') waaaCount++;
}
}, {
q: [
['foo', 'bar'],
['piyo'],
],
});
post(chitose, {
text: '#foo',
});
post(chitose, {
text: '#bar',
});
post(chitose, {
text: '#foo #bar',
});
post(chitose, {
text: '#piyo',
});
post(chitose, {
text: '#waaa',
});
setTimeout(() => {
assert.strictEqual(fooCount, 0);
assert.strictEqual(barCount, 0);
assert.strictEqual(fooBarCount, 1);
assert.strictEqual(piyoCount, 1);
assert.strictEqual(waaaCount, 0);
ws.close();
done();
}, 3000);
}));
assert.strictEqual(received.includes('#foo'), false);
assert.strictEqual(received.includes('#bar'), false);
assert.strictEqual(received.includes('#foo #bar'), true);
assert.strictEqual(received.includes('#piyo'), true);
assert.strictEqual(received.includes('#waaa'), false);
});
});
*/
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -149,7 +149,7 @@ const isRenote = Misskey.note.isPureRenote(note.value);
const rootEl = shallowRef<HTMLElement>();
const renoteTime = shallowRef<HTMLElement>();
const appearNote = computed(() => getAppearNote(note.value));
const appearNote = computed(() => getAppearNote(note.value) ?? note.value);
const showContent = ref(false);
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
const isLong = shouldCollapsed(appearNote.value, []);

View File

@@ -244,7 +244,7 @@ const fetchMore = async (): Promise<void> => {
if (i === 10) item._shouldInsertAd_ = true;
}
const reverseConcat = _res => {
const reverseConcat = (_res: MisskeyEntity[]) => {
const oldHeight = scrollableElement.value ? scrollableElement.value.scrollHeight : getBodyScrollHeight();
const oldScroll = scrollableElement.value ? scrollableElement.value.scrollTop : window.scrollY;

View File

@@ -27,8 +27,8 @@ const initialReactions = new Set(Object.keys(props.note.reactions));
const reactions = ref<[string, number][]>([]);
const hasMoreReactions = ref(false);
if (props.note.myReaction && !Object.keys(reactions.value).includes(props.note.myReaction)) {
reactions.value[props.note.myReaction] = props.note.reactions[props.note.myReaction];
if (props.note.myReaction != null && !(props.note.myReaction in props.note.reactions)) {
reactions.value.push([props.note.myReaction, props.note.reactions[props.note.myReaction]]);
}
function onMockToggleReaction(emoji: string, count: number) {

View File

@@ -46,6 +46,9 @@ const parsed = computed(() => {
});
const render = () => {
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
// slotsの型はTの条件型で決まり文字列インデックスアクセスができないため、
// frontend側の同名コンポーネント (packages/frontend/src/components/global/I18n.vue) と同じくanyでキャストする
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return h(props.tag, parsed.value.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : (slots as any)[x.arg]()));
};
</script>

View File

@@ -45,11 +45,11 @@ export async function fetchCustomEmojis(force = false) {
set('lastEmojisFetchedAt', now);
}
let cachedTags;
let cachedTags: string[] | null = null;
export function getCustomEmojiTags() {
if (cachedTags) return cachedTags;
const tags = new Set();
const tags = new Set<string>();
for (const emoji of customEmojis.value) {
for (const tag of emoji.aliases) {
tags.add(tag);

View File

@@ -85,8 +85,6 @@
"@storybook/core-events": "8.6.18",
"@storybook/manager-api": "8.6.18",
"@storybook/preview-api": "8.6.18",
"@storybook/react": "10.4.6",
"@storybook/react-vite": "10.4.6",
"@storybook/test": "8.6.18",
"@storybook/theming": "8.6.18",
"@storybook/types": "8.6.18",
@@ -110,10 +108,7 @@
"@typescript-eslint/parser": "8.61.1",
"@vitest/coverage-v8": "4.1.9",
"@vue/compiler-core": "3.5.38",
"acorn": "8.17.0",
"astring": "1.9.0",
"cross-env": "10.1.0",
"cypress": "15.17.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.9.2",
"happy-dom": "20.10.6",
@@ -132,7 +127,6 @@
"rollup-plugin-visualizer": "7.0.1",
"sass-embedded": "1.100.0",
"seedrandom": "3.0.5",
"start-server-and-test": "3.0.11",
"storybook": "10.4.6",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.22.4",

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-if="show" ref="el" :class="[$style.root]">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
<div v-if="!thin_ && (narrow || deviceKind === 'smartphone') && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i"/>
</div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div>
@@ -62,6 +62,7 @@ import { onMounted, onUnmounted, ref, inject, useTemplateRef, computed } from 'v
import { scrollToTop } from '@@/js/scroll.js';
import XTabs from './MkPageHeader.tabs.vue';
import { getAccountMenu } from '@/accounts.js';
import { deviceKind } from '@/utility/device-kind.js';
import { $i } from '@/i.js';
import { DI } from '@/di.js';
import * as os from '@/os.js';
@@ -160,10 +161,12 @@ onUnmounted(() => {
align-items: center;
height: var(--height);
.tabs:first-child {
.tabs:first-child,
&:not(.slim) > :not(.titleContainer) ~ .tabs {
margin-left: auto;
padding: 0 12px;
}
.tabs {
margin-right: auto;
}

View File

@@ -154,6 +154,7 @@ function onDrop(ev: DragEvent): void {
}
function onKeydown(ev: KeyboardEvent) {
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
if (ev.key === 'Enter') {
if (prefer.s['chat.sendOnEnter']) {
if (!(ev.ctrlKey || ev.metaKey || ev.shiftKey)) {