1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-22 09:24:22 +02:00

Merge branch 'develop' into mahjong

This commit is contained in:
zyoshoka
2025-04-18 19:31:12 +09:00
115 changed files with 3626 additions and 2821 deletions

View File

@@ -43,6 +43,41 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
};
}
export function chatMessage(room = false, id = 'somechatmessageid', text = 'Hello!'): entities.ChatMessage {
const fromUser = userLite();
const toRoom = chatRoom();
const toUser = userLite('touserid');
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
fromUserId: fromUser.id,
fromUser,
text,
isRead: false,
reactions: [],
...room ? {
toRoomId: toRoom.id,
toRoom,
} : {
toUserId: toUser.id,
toUser,
},
};
}
export function chatRoom(id = 'somechatroomid', name = 'Some Chat Room'): entities.ChatRoom {
const owner = userLite('someownerid');
return {
id,
createdAt: '2016-12-28T22:49:51.000Z',
ownerId: owner.id,
owner,
name,
description: 'A chat room for testing',
isMuted: false,
};
}
export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
return {
id,

View File

@@ -24,7 +24,7 @@
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.1.4",
"@sentry/vue": "9.8.0",
"@sentry/vue": "9.12.0",
"@syuilo/aiscript": "0.19.0",
"@tabler/icons-webfont": "3.31.0",
"@twemoji/parser": "15.1.1",
@@ -33,7 +33,7 @@
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16",
"astring": "1.9.0",
"broadcast-channel": "7.0.0",
"broadcast-channel": "7.1.0",
"buraha": "0.0.1",
"canvas-confetti": "1.9.3",
"chart.js": "4.4.8",
@@ -41,7 +41,7 @@
"chartjs-chart-matrix": "2.1.1",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "11.27.0",
"chromatic": "11.28.0",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0",
"date-fns": "4.1.0",
@@ -61,65 +61,65 @@
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"rollup": "4.36.0",
"rollup": "4.39.0",
"sanitize-html": "2.15.0",
"sass": "1.86.0",
"shiki": "3.2.1",
"sass": "1.86.3",
"shiki": "3.2.2",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.174.0",
"three": "0.175.0",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.11",
"tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0",
"typescript": "5.8.2",
"typescript": "5.8.3",
"uuid": "11.1.0",
"v-code-diff": "1.13.1",
"vite": "6.2.4",
"vite": "6.3.1",
"vue": "3.5.13",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.0",
"@storybook/addon-actions": "8.6.7",
"@storybook/addon-essentials": "8.6.7",
"@storybook/addon-interactions": "8.6.7",
"@storybook/addon-links": "8.6.7",
"@storybook/addon-mdx-gfm": "8.6.7",
"@storybook/addon-storysource": "8.6.7",
"@storybook/blocks": "8.6.7",
"@storybook/components": "8.6.7",
"@storybook/core-events": "8.6.7",
"@storybook/manager-api": "8.6.7",
"@storybook/preview-api": "8.6.7",
"@storybook/react": "8.6.7",
"@storybook/react-vite": "8.6.7",
"@storybook/test": "8.6.7",
"@storybook/theming": "8.6.7",
"@storybook/types": "8.6.7",
"@storybook/vue3": "8.6.7",
"@storybook/vue3-vite": "8.6.7",
"@storybook/addon-actions": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-mdx-gfm": "8.6.12",
"@storybook/addon-storysource": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/components": "8.6.12",
"@storybook/core-events": "8.6.12",
"@storybook/manager-api": "8.6.12",
"@storybook/preview-api": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@storybook/theming": "8.6.12",
"@storybook/types": "8.6.12",
"@storybook/vue3": "8.6.12",
"@storybook/vue3-vite": "8.6.12",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.6",
"@types/estree": "1.0.7",
"@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9",
"@types/node": "22.13.11",
"@types/node": "22.14.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.13.0",
"@types/sanitize-html": "2.15.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.0",
"@typescript-eslint/eslint-plugin": "8.27.0",
"@typescript-eslint/parser": "8.27.0",
"@vitest/coverage-v8": "3.0.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitest/coverage-v8": "3.1.1",
"@vue/compiler-core": "3.5.13",
"@vue/runtime-core": "3.5.13",
"acorn": "8.14.1",
"cross-env": "7.0.3",
"cypress": "14.2.0",
"cypress": "14.3.0",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-vue": "10.0.0",
"fast-glob": "3.3.3",
@@ -131,18 +131,17 @@
"msw-storybook-addon": "2.0.4",
"nodemon": "3.1.9",
"prettier": "3.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.11",
"storybook": "8.6.7",
"storybook": "8.6.12",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"vite-node": "3.0.9",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.0.9",
"vitest": "3.1.1",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "2.2.8",
"vue-eslint-parser": "10.1.1",
"vue-eslint-parser": "10.1.3",
"vue-tsc": "2.2.8"
}
}

View File

@@ -21,14 +21,19 @@ type AccountWithToken = Misskey.entities.MeDetailed & { token: string };
export async function getAccounts(): Promise<{
host: string;
user: Misskey.entities.User;
id: Misskey.entities.User['id'];
username: Misskey.entities.User['username'];
user?: Misskey.entities.User | null;
token: string | null;
}[]> {
const tokens = store.s.accountTokens;
const accountInfos = store.s.accountInfos;
const accounts = prefer.s.accounts;
return accounts.map(([host, user]) => ({
host,
user,
id: user.id,
username: user.username,
user: accountInfos[host + '/' + user.id],
token: tokens[host + '/' + user.id] ?? null,
}));
}
@@ -36,7 +41,8 @@ export async function getAccounts(): Promise<{
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
prefer.commit('accounts', [...prefer.s.accounts, [host, user]]);
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
prefer.commit('accounts', [...prefer.s.accounts, [host, { id: user.id, username: user.username }]]);
}
}
@@ -44,6 +50,10 @@ export async function removeAccount(host: string, id: AccountWithToken['id']) {
const tokens = JSON.parse(JSON.stringify(store.s.accountTokens));
delete tokens[host + '/' + id];
store.set('accountTokens', tokens);
const accountInfos = JSON.parse(JSON.stringify(store.s.accountInfos));
delete accountInfos[host + '/' + id];
store.set('accountInfos', accountInfos);
prefer.commit('accounts', prefer.s.accounts.filter(x => x[0] !== host || x[1].id !== id));
}
@@ -121,14 +131,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
}
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
// TODO: $iのホストも比較したいけど通常null
if (user.id === $i.id) {
return [host, $i];
} else {
return [host, user];
}
}));
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
$i.token = token;
miLocalStorage.setItem('account', JSON.stringify($i));
}
@@ -138,17 +141,9 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie
for (const [key, value] of Object.entries(accountData)) {
$i[key] = value;
}
prefer.commit('accounts', prefer.s.accounts.map(([host, user]) => {
// TODO: $iのホストも比較したいけど通常null
if (user.id === $i.id) {
const newUser = JSON.parse(JSON.stringify($i));
for (const [key, value] of Object.entries(accountData)) {
newUser[key] = value;
}
return [host, newUser];
}
return [host, user];
}));
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
miLocalStorage.setItem('account', JSON.stringify($i));
}
@@ -223,25 +218,42 @@ export async function openAccountMenu(opts: {
}, ev: MouseEvent) {
if (!$i) return;
function createItem(host: string, account: Misskey.entities.User): MenuItem {
return {
type: 'user' as const,
user: account,
active: opts.active != null ? opts.active === account.id : false,
action: async () => {
if (opts.onChoose) {
opts.onChoose(account);
} else {
switchAccount(host, account.id);
}
},
};
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem {
if (account) {
return {
type: 'user' as const,
user: account,
active: opts.active != null ? opts.active === id : false,
action: async () => {
if (opts.onChoose) {
opts.onChoose(account);
} else {
switchAccount(host, id);
}
},
};
} else {
return {
type: 'button' as const,
text: username,
active: opts.active != null ? opts.active === id : false,
action: async () => {
if (opts.onChoose) {
fetchAccount(token, id).then(account => {
opts.onChoose(account);
});
} else {
switchAccount(host, id);
}
},
};
}
}
const menuItems: MenuItem[] = [];
// TODO: $iのホストも比較したいけど通常null
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.user.id !== $i.id))).map(a => createItem(a.host, a.user));
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
if (opts.withExtraOperation) {
menuItems.push({
@@ -254,7 +266,7 @@ export async function openAccountMenu(opts: {
});
if (opts.includeCurrentAccount) {
menuItems.push(createItem(host, $i));
menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
}
menuItems.push(...accountItems);
@@ -290,7 +302,7 @@ export async function openAccountMenu(opts: {
});
} else {
if (opts.includeCurrentAccount) {
menuItems.push(createItem(host, $i));
menuItems.push(createItem(host, $i.id, $i.username, $i, $i.token));
}
menuItems.push(...accountItems);

View File

@@ -157,7 +157,7 @@ async function init() {
const accounts = await getAccounts();
const accountIdsToFetch = accounts.map(a => a.user.id).filter(id => !users.value.has(id));
const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id));
if (accountIdsToFetch.length > 0) {
const usersRes = await misskeyApi('users/show', {
@@ -169,7 +169,7 @@ async function init() {
users.value.set(user.id, {
...user,
token: accounts.find(a => a.user.id === user.id)!.token,
token: accounts.find(a => a.id === user.id)!.token,
});
}
}

View File

@@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</li>
<li tabindex="-1" :class="$style.item" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
</ol>
<ol v-else-if="hashtags.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'hashtag' && hashtags.length > 0" ref="suggests" :class="$style.list">
<li v-for="hashtag in hashtags" tabindex="-1" :class="$style.item" @click="complete(type, hashtag)" @keydown="onKeydown">
<span class="name">{{ hashtag }}</span>
</li>
</ol>
<ol v-else-if="emojis.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'emoji' || type === 'emojiComplete' && emojis.length > 0" ref="suggests" :class="$style.list">
<li v-for="emoji in emojis" :key="emoji.emoji" :class="$style.item" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
@@ -30,12 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
</li>
</ol>
<ol v-else-if="mfmTags.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'mfmTag' && mfmTags.length > 0" ref="suggests" :class="$style.list">
<li v-for="tag in mfmTags" tabindex="-1" :class="$style.item" @click="complete(type, tag)" @keydown="onKeydown">
<span>{{ tag }}</span>
</li>
</ol>
<ol v-else-if="mfmParams.length > 0" ref="suggests" :class="$style.list">
<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
<span>{{ param }}</span>
</li>
@@ -58,12 +58,44 @@ import { store } from '@/store.js';
import { i18n } from '@/i18n.js';
import { miLocalStorage } from '@/local-storage.js';
import { customEmojis } from '@/custom-emojis.js';
import { searchEmoji } from '@/utility/search-emoji.js';
import { searchEmoji, searchEmojiExact } from '@/utility/search-emoji.js';
import { prefer } from '@/preferences.js';
export type CompleteInfo = {
user: {
payload: any;
query: string | null;
},
hashtag: {
payload: string;
query: string;
},
// `:emo` -> `:emoji:` or some unicode emoji
emoji: {
payload: string;
query: string;
},
// like emoji but for `:emoji:` -> unicode emoji
emojiComplete: {
payload: string;
query: string;
},
mfmTag: {
payload: string;
query: string;
},
mfmParam: {
payload: string;
query: {
tag: string;
params: string[];
};
},
};
const lib = emojilist.filter(x => x.category !== 'flags');
const emojiDb = computed(() => {
const unicodeEmojiDB = computed(() => {
//#region Unicode Emoji
const char2path = prefer.r.emojiStyle.value === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
@@ -87,6 +119,12 @@ const emojiDb = computed(() => {
}
unicodeEmojiDB.sort((a, b) => a.name.length - b.name.length);
return unicodeEmojiDB;
});
const emojiDb = computed(() => {
//#region Unicode Emoji
//#endregion
//#region Custom Emoji
@@ -114,7 +152,7 @@ const emojiDb = computed(() => {
customEmojiDB.sort((a, b) => a.name.length - b.name.length);
//#endregion
return markRaw([...customEmojiDB, ...unicodeEmojiDB]);
return markRaw([...customEmojiDB, ...unicodeEmojiDB.value]);
});
export default {
@@ -123,18 +161,23 @@ export default {
};
</script>
<script lang="ts" setup>
const props = defineProps<{
type: string;
q: any;
textarea: HTMLTextAreaElement;
<script lang="ts" setup generic="T extends keyof CompleteInfo">
type PropsType<T extends keyof CompleteInfo> = {
type: T;
q: CompleteInfo[T]['query'];
// なぜかわからないけど HTMLTextAreaElement | HTMLInputElement だと addEventListener/removeEventListenerがエラー
textarea: (HTMLTextAreaElement | HTMLInputElement) & HTMLElement;
close: () => void;
x: number;
y: number;
}>();
};
//const props = defineProps<PropsType<keyof CompleteInfo>>();
// ↑と同じだけど↓にしないとdiscriminated unionにならない。
// https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes-func.html#discriminated-unions
const props = defineProps<PropsType<'user'> | PropsType<'hashtag'> | PropsType<'emoji'> | PropsType<'emojiComplete'> | PropsType<'mfmTag'> | PropsType<'mfmParam'>>();
const emit = defineEmits<{
(event: 'done', value: { type: string; value: any }): void;
<T extends keyof CompleteInfo>(event: 'done', value: { type: T; value: CompleteInfo[T]['payload'] }): void;
(event: 'closed'): void;
}>();
@@ -151,10 +194,10 @@ const mfmParams = ref<string[]>([]);
const select = ref(-1);
const zIndex = os.claimZIndex('high');
function complete(type: string, value: any) {
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
emit('done', { type, value });
emit('closed');
if (type === 'emoji') {
if (type === 'emoji' || type === 'emojiComplete') {
let recents = store.s.recentlyUsedEmojis;
recents = recents.filter((emoji: any) => emoji !== value);
recents.unshift(value);
@@ -243,6 +286,8 @@ function exec() {
}
emojis.value = searchEmoji(props.q, emojiDb.value);
} else if (props.type === 'emojiComplete') {
emojis.value = searchEmojiExact(props.q, unicodeEmojiDB.value);
} else if (props.type === 'mfmTag') {
if (!props.q || props.q === '') {
mfmTags.value = MFM_TAGS;

View File

@@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { http, HttpResponse } from 'msw';
import { action } from '@storybook/addon-actions';
import { chatMessage } from '../../.storybook/fakes';
import MkChatHistories from './MkChatHistories.vue';
import type { StoryObj } from '@storybook/vue3';
import type * as Misskey from 'misskey-js';
export const Default = {
render(args) {
return {
components: {
MkChatHistories,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkChatHistories v-bind="props" />',
};
},
parameters: {
layout: 'centered',
msw: {
handlers: [
http.post('/api/chat/history', async ({ request }) => {
const body = await request.json() as Misskey.entities.ChatHistoryRequest;
action('POST /api/chat/history')(body);
return HttpResponse.json([chatMessage(body.room)]);
}),
],
},
},
} satisfies StoryObj<typeof MkChatHistories>;

View File

@@ -0,0 +1,208 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="history.length > 0" class="_gaps_s">
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
</template>
<script lang="ts" setup>
import { onActivated, onDeactivated, onMounted, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { useInterval } from '@@/js/use-interval.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { ensureSignin } from '@/i.js';
const $i = ensureSignin();
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const initializing = ref(true);
const fetching = ref(false);
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
});
</script>
<style lang="scss" module>
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
@container (max-width: 500px) {
.message {
font-size: 90%;
padding: 14px 20px;
}
}
@container (max-width: 450px) {
.message {
font-size: 80%;
padding: 12px 16px;
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
@container (max-width: 500px) {
.messageAvatar {
width: 45px;
height: 45px;
}
}
@container (max-width: 450px) {
.messageAvatar {
width: 40px;
height: 40px;
}
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
</style>

View File

@@ -0,0 +1,7 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MkDisableSection from './MkDisableSection.vue';
void MkDisableSection;

View File

@@ -626,13 +626,13 @@ function getMenu() {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload',
action: () => {
chooseFileFromPc(true, { keepOriginal: false });
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
},
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => {
chooseFileFromPc(true, { keepOriginal: true });
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
},
}, {
text: i18n.ts.fromUrl,

View File

@@ -31,9 +31,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-for="item in (items2 ?? [])">
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
<span style="opacity: 0.7;">{{ item.text }}</span>
</span>
<div v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label]">
<span>{{ item.text }}</span>
</div>
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
<span><MkEllipsis/></span>
@@ -619,12 +619,6 @@ onBeforeUnmount(() => {
--menuActiveBg: var(--MI_THEME-accentedBg);
}
&.label {
pointer-events: none;
font-size: 0.7em;
padding-bottom: 4px;
}
&.pending {
pointer-events: none;
opacity: 0.7;
@@ -694,6 +688,19 @@ onBeforeUnmount(() => {
font-size: 12px;
}
.label {
position: relative;
padding: 6px 16px;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.7em;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
opacity: 0.7;
pointer-events: none;
}
.divider {
margin: 8px 0;
border-top: solid 0.5px var(--MI_THEME-divider);

View File

@@ -14,7 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #default="{ items: notes }">
<component
:is="prefer.s.animation ? TransitionGroup : 'div'" :class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap }]"
:is="prefer.s.animation ? TransitionGroup : 'div'"
:class="[$style.root, { [$style.noGap]: noGap, '_gaps': !noGap, [$style.reverse]: pagination.reversed }]"
:enterActiveClass="$style.transition_x_enterActive"
:leaveActiveClass="$style.transition_x_leaveActive"
:enterFromClass="$style.transition_x_enterFrom"
@@ -23,13 +24,13 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div"
>
<template v-for="(note, i) in notes" :key="note.id">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]">
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
<MkNote :class="$style.note" :note="note" :withHardMute="true"/>
<div :class="$style.ad">
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
</div>
</div>
<MkNote v-else :class="$style.note" :note="note" :withHardMute="true"/>
<MkNote :class="$style.note" :note="note" :withHardMute="true" :data-scroll-anchor="note.id"/>
</template>
</component>
</template>
@@ -73,6 +74,11 @@ defineExpose({
position: absolute;
}
.reverse {
display: flex;
flex-direction: column-reverse;
}
.root {
container-type: inline-size;

View File

@@ -296,6 +296,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
right: -2px;
width: 20px;
height: 20px;
line-height: 20px;
box-sizing: border-box;
border-radius: 100%;
background: var(--MI_THEME-panel);
@@ -310,73 +311,61 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
}
.t_follow, .t_followRequestAccepted, .t_receiveFollowRequest {
padding: 3px;
background: var(--eventFollow);
pointer-events: none;
}
.t_renote {
padding: 3px;
background: var(--eventRenote);
pointer-events: none;
}
.t_quote {
padding: 3px;
background: var(--eventRenote);
pointer-events: none;
}
.t_reply {
padding: 3px;
background: var(--eventReply);
pointer-events: none;
}
.t_mention {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_pollEnded {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_achievementEarned {
padding: 3px;
background: var(--eventAchievement);
pointer-events: none;
}
.t_exportCompleted {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_roleAssigned {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_login {
padding: 3px;
background: var(--eventLogin);
pointer-events: none;
}
.t_createToken {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}
.t_chatRoomInvitationReceived {
padding: 3px;
background: var(--eventOther);
pointer-events: none;
}

View File

@@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
tag="div"
>
<template v-for="(notification, i) in notifications" :key="notification.id">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true"/>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :class="$style.item" :note="notification.note" :withHardMute="true" :data-scroll-anchor="notification.id"/>
<XNotification v-else :class="$style.item" :notification="notification" :withTime="true" :full="true" :data-scroll-anchor="notification.id"/>
</template>
</component>
</template>

View File

@@ -879,7 +879,7 @@ async function post(ev?: MouseEvent) {
if (postAccount.value) {
const storedAccounts = await getAccounts();
token = storedAccounts.find(x => x.user.id === postAccount.value?.id)?.token;
token = storedAccounts.find(x => x.id === postAccount.value?.id)?.token;
}
posting.value = true;

View File

@@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<g fill-rule="evenodd">
<rect width="200" height="150" :fill="themeVariables.bg"/>
<rect width="64" height="150" :fill="themeVariables.navBg"/>
<rect x="64" width="136" height="41" :fill="themeVariables.bg"/>
<rect x="64" width="136" height="41" :fill="themeVariables.pageHeaderBg"/>
<path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel"/>
</g>
<circle cx="32" cy="83" r="21" :fill="themeVariables.accentedBg"/>
@@ -62,6 +62,7 @@ const themeVariables = ref<{
accent: string;
accentedBg: string;
navBg: string;
pageHeaderBg: string;
success: string;
warn: string;
error: string;
@@ -76,6 +77,7 @@ const themeVariables = ref<{
accent: 'var(--MI_THEME-accent)',
accentedBg: 'var(--MI_THEME-accentedBg)',
navBg: 'var(--MI_THEME-navBg)',
pageHeaderBg: 'var(--MI_THEME-pageHeaderBg)',
success: 'var(--MI_THEME-success)',
warn: 'var(--MI_THEME-warn)',
error: 'var(--MI_THEME-error)',
@@ -104,6 +106,7 @@ watch(() => props.theme, (theme) => {
accent: compiled.accent ?? 'var(--MI_THEME-accent)',
accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)',
navBg: compiled.navBg ?? 'var(--MI_THEME-navBg)',
pageHeaderBg: compiled.pageHeaderBg ?? 'var(--MI_THEME-pageHeaderBg)',
success: compiled.success ?? 'var(--MI_THEME-success)',
warn: compiled.warn ?? 'var(--MI_THEME-warn)',
error: compiled.error ?? 'var(--MI_THEME-error)',

View File

@@ -124,11 +124,18 @@ onUnmounted(() => {
<style lang="scss" module>
.root {
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-bottom: solid 0.5px var(--MI_THEME-divider);
border-bottom: solid 0.5px transparent;
width: 100%;
color: var(--MI_THEME-pageHeaderFg);
}
@container style(--MI_THEME-pageHeaderBg: var(--MI_THEME-bg)) {
.root {
border-bottom: solid 0.5px var(--MI_THEME-divider);
}
}
.upper,

View File

@@ -20,6 +20,7 @@ import { useTemplateRef } from 'vue';
import { scrollInContainer } from '@@/js/scroll.js';
import type { PageHeaderItem } from '@/types/page-header.js';
import type { Tab } from './MkPageHeader.tabs.vue';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
const props = withDefaults(defineProps<{
tabs?: Tab[];
@@ -35,6 +36,8 @@ const props = withDefaults(defineProps<{
const tab = defineModel<string>('tab');
const rootEl = useTemplateRef('rootEl');
useScrollPositionKeeper(rootEl);
defineExpose({
scrollToTop: () => {
if (rootEl.value) scrollInContainer(rootEl.value, { top: 0, behavior: 'smooth' });

View File

@@ -38,6 +38,7 @@ export const columnTypes = [
'mentions',
'direct',
'roleTimeline',
'chat',
] as const;
export type ColumnType = typeof columnTypes[number];

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'overview'" :contentMax="600" :marginMin="20">
<XOverview/>
</MkSpacer>
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
<MkInstanceStats/>
</MkSpacer>
</MkHorizontalSwipe>
</MkSwiper>
</PageWithHeader>
</template>
@@ -28,7 +28,7 @@ import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js';
import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
const XOverview = defineAsyncComponent(() => import('@/pages/about.overview.vue'));
const XEmojis = defineAsyncComponent(() => import('@/pages/about.emojis.vue'));

View File

@@ -10,14 +10,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<XQueue v-if="tab === 'deliver'" domain="deliver"/>
<XQueue v-else-if="tab === 'inbox'" domain="inbox"/>
<br>
<MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton>
<div class="_buttons">
<MkButton @click="promoteAllQueues"><i class="ti ti-reload"></i> {{ i18n.ts.retryAllQueuesNow }}</MkButton>
<MkButton danger @click="clear"><i class="ti ti-trash"></i> {{ i18n.ts.clearQueue }}</MkButton>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import * as config from '@@/js/config.js';
import XQueue from './queue.chart.vue';
import XHeader from './_header_.vue';
import type { Ref } from 'vue';
@@ -38,7 +40,7 @@ function clear() {
}).then(({ canceled }) => {
if (canceled) return;
os.apiWithDialog('admin/queue/clear');
os.apiWithDialog('admin/queue/clear', { type: tab.value, state: '*' });
});
}

View File

@@ -23,10 +23,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-success);"></i>
</span>
<Mfm :text="announcement.title"/>
<Mfm :text="announcement.title" class="_selectable"/>
</div>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<Mfm :text="announcement.text" class="_selectable"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div class="_gaps">
<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo>
<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps">
@@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
</div>
<div :class="$style.content">
<Mfm :text="announcement.text"/>
<Mfm :text="announcement.text" class="_selectable"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
<MkA :to="`/announcements/${announcement.id}`">
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
@@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</section>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -50,7 +50,7 @@ import { ref, computed } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="channel && tab === 'overview'" class="_gaps">
<div class="_panel" :class="$style.bannerContainer">
<XChannelFollowButton :channel="channel" :full="true" :class="$style.subscribe"/>
@@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
</div>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
<template #footer>
<div :class="$style.footer">
@@ -93,7 +93,7 @@ import { prefer } from '@/preferences.js';
import MkNote from '@/components/MkNote.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { isSupportShare } from '@/utility/navigator.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="1200">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'search'" :class="$style.searchRoot">
<div class="_gaps">
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
@@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -67,7 +67,7 @@ import MkInput from '@/components/MkInput.vue';
import MkRadios from '@/components/MkRadios.vue';
import MkButton from '@/components/MkButton.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';

View File

@@ -34,34 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection>
<template #header>{{ i18n.ts._chat.history }}</template>
<div v-if="history.length > 0" class="_gaps_s">
<MkA
v-for="item in history"
:key="item.id"
:class="[$style.message, { [$style.isMe]: item.isMe, [$style.isRead]: item.message.isRead }]"
class="_panel"
:to="item.message.toRoomId ? `/chat/room/${item.message.toRoomId}` : `/chat/user/${item.other!.id}`"
>
<MkAvatar v-if="item.message.toRoomId" :class="$style.messageAvatar" :user="item.message.fromUser" indicator :preview="false"/>
<MkAvatar v-else-if="item.other" :class="$style.messageAvatar" :user="item.other" indicator :preview="false"/>
<div :class="$style.messageBody">
<header v-if="item.message.toRoom" :class="$style.messageHeader">
<span :class="$style.messageHeaderName"><i class="ti ti-users"></i> {{ item.message.toRoom.name }}</span>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<header v-else :class="$style.messageHeader">
<MkUserName :class="$style.messageHeaderName" :user="item.other!"/>
<MkAcct :class="$style.messageHeaderUsername" :user="item.other!"/>
<MkTime :time="item.message.createdAt" :class="$style.messageHeaderTime"/>
</header>
<div :class="$style.messageBodyText"><span v-if="item.isMe" :class="$style.youSaid">{{ i18n.ts.you }}:</span>{{ item.message.text }}</div>
</div>
</MkA>
</div>
<div v-if="!initializing && history.length == 0" class="_fullinfo">
<div>{{ i18n.ts._chat.noHistory }}</div>
</div>
<MkLoading v-if="initializing"/>
<MkChatHistories/>
</MkFoldableSection>
</div>
</template>
@@ -81,20 +54,12 @@ import { updateCurrentAccountPartial } from '@/accounts.js';
import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkChatHistories from '@/components/MkChatHistories.vue';
const $i = ensureSignin();
const router = useRouter();
const initializing = ref(true);
const fetching = ref(false);
const history = ref<{
id: string;
message: Misskey.entities.ChatMessage;
other: Misskey.entities.ChatMessage['fromUser'] | Misskey.entities.ChatMessage['toUser'] | null;
isMe: boolean;
}[]>([]);
const searchQuery = ref('');
const searched = ref(false);
const searchResults = ref<Misskey.entities.ChatMessage[]>([]);
@@ -148,57 +113,8 @@ async function search() {
searched.value = true;
}
async function fetchHistory() {
if (fetching.value) return;
fetching.value = true;
const [userMessages, roomMessages] = await Promise.all([
misskeyApi('chat/history', { room: false }),
misskeyApi('chat/history', { room: true }),
]);
history.value = [...userMessages, ...roomMessages]
.toSorted((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map(m => ({
id: m.id,
message: m,
other: (!('room' in m) || m.room == null) ? (m.fromUserId === $i.id ? m.toUser : m.fromUser) : null,
isMe: m.fromUserId === $i.id,
}));
fetching.value = false;
initializing.value = false;
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
}
let isActivated = true;
onActivated(() => {
isActivated = true;
});
onDeactivated(() => {
isActivated = false;
});
useInterval(() => {
// TODO: DOM的にバックグラウンドになっていないかどうかも考慮する
if (!window.document.hidden && isActivated) {
fetchHistory();
}
}, 1000 * 10, {
immediate: false,
afterMounted: true,
});
onActivated(() => {
fetchHistory();
});
onMounted(() => {
fetchHistory();
updateCurrentAccountPartial({ hasUnreadChatMessages: false });
});
</script>
@@ -207,77 +123,6 @@ onMounted(() => {
margin: 0 auto;
}
.message {
position: relative;
display: flex;
padding: 16px 24px;
&.isRead,
&.isMe {
opacity: 0.8;
}
&:not(.isMe):not(.isRead) {
&::before {
content: '';
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 100%;
background-color: var(--MI_THEME-accent);
}
}
}
.messageAvatar {
width: 50px;
height: 50px;
margin: 0 16px 0 0;
}
.messageBody {
flex: 1;
min-width: 0;
}
.messageHeader {
display: flex;
align-items: center;
margin-bottom: 2px;
white-space: nowrap;
overflow: clip;
}
.messageHeaderName {
margin: 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1em;
font-weight: bold;
}
.messageHeaderUsername {
margin: 0 8px;
}
.messageHeaderTime {
margin-left: auto;
}
.messageBodyText {
overflow: hidden;
overflow-wrap: break-word;
font-size: 1.1em;
}
.youSaid {
font-weight: bold;
margin-right: 0.5em;
}
.searchResultItem {
padding: 12px;
border: solid 1px var(--MI_THEME-divider);

View File

@@ -7,12 +7,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkPolkadots v-if="tab === 'home'" accented/>
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<XHome v-if="tab === 'home'"/>
<XInvitations v-else-if="tab === 'invitations'"/>
<XJoiningRooms v-else-if="tab === 'joiningRooms'"/>
<XOwnedRooms v-else-if="tab === 'ownedRooms'"/>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -25,7 +25,7 @@ import XJoiningRooms from './home.joiningRooms.vue';
import XOwnedRooms from './home.ownedRooms.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import MkPolkadots from '@/components/MkPolkadots.vue';
const tab = ref('home');

View File

@@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'info'" :contentMax="800">
<XFileInfo :fileId="fileId"/>
</MkSpacer>
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800">
<XNotes :fileId="fileId"/>
</MkSpacer>
</MkHorizontalSwipe>
</MkSwiper>
</MkStickyContainer>
</template>
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { computed, ref, defineAsyncComponent } from 'vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
const props = defineProps<{
fileId: string;

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'">
<XFeatured/>
</div>
@@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'roles'">
<XRoles/>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</PageWithHeader>
</template>
@@ -25,7 +25,7 @@ import XFeatured from './explore.featured.vue';
import XUsers from './explore.users.vue';
import XRoles from './explore.roles.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
<div class="_gaps_s">
@@ -33,7 +33,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -43,7 +43,7 @@ import { computed, ref } from 'vue';
import MkFlashPreview from '@/components/MkFlashPreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { useRouter } from '@/router.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="800">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkPagination ref="paginationComponent" :pagination="pagination">
<template #empty>
<div class="_fullinfo">
@@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</template>
</MkPagination>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -52,7 +52,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/i.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
const paginationComponent = useTemplateRef('paginationComponent');

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="1400">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'explore'">
<MkFoldableSection class="_margin">
<template #header><i class="ti ti-clock"></i>{{ i18n.ts.recentPosts }}</template>
@@ -40,7 +40,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -50,7 +50,7 @@ import { watch, ref, computed } from 'vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkGalleryPostPreview from '@/components/MkGalleryPostPreview.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer v-if="instance" :contentMax="600" :marginMin="16" :marginMax="32">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'overview'" class="_gaps_m">
<div class="fnfelxur">
<img :src="faviconUrl" alt="" class="icon"/>
@@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkObjectView tall :value="instance">
</MkObjectView>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -153,7 +153,7 @@ import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
import { dateString } from '@/filters/date.js';
import MkTextarea from '@/components/MkTextarea.vue';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'my'" class="_gaps">
<MkButton primary rounded class="add" @click="create"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'favorites'" class="_gaps">
<MkClipPreview v-for="item in favorites" :key="item.id" :clip="item"/>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -33,7 +33,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { clipsCache } from '@/cache.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
const pagination = {
endpoint: 'clips/list' as const,

View File

@@ -24,7 +24,7 @@ import { computed, ref } from 'vue';
import { notificationTypes } from '@@/js/const.js';
import XNotifications from '@/components/MkNotifications.vue';
import MkNotes from '@/components/MkNotes.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkSpacer :contentMax="700">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<div v-if="tab === 'featured'">
<MkPagination v-slot="{items}" :pagination="featuredPagesPagination">
<div class="_gaps">
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkPagination>
</div>
</MkHorizontalSwipe>
</MkSwiper>
</MkSpacer>
</PageWithHeader>
</template>
@@ -41,7 +41,7 @@ import { computed, ref } from 'vue';
import MkPagePreview from '@/components/MkPagePreview.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { useRouter } from '@/router.js';

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<MkSpacer v-if="tab === 'note'" :contentMax="800">
<div v-if="notesSearchAvailable || ignoreNotesSearchAvailable">
<XNote v-bind="props"/>
@@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer v-else-if="tab === 'user'" :contentMax="800">
<XUser v-bind="props"/>
</MkSpacer>
</MkHorizontalSwipe>
</MkSwiper>
</PageWithHeader>
</template>
@@ -28,7 +28,7 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import { notesSearchAvailable } from '@/utility/check-permissions.js';
import MkInfo from '@/components/MkInfo.vue';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
const props = withDefaults(defineProps<{
query?: string,

View File

@@ -177,7 +177,8 @@ const menuDef = computed<SuperMenuDef[]>(() => [{
action: async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.logoutConfirm,
title: i18n.ts.logoutConfirm,
text: i18n.ts.logoutWillClearClientData,
});
if (canceled) return;
signout();

View File

@@ -52,12 +52,15 @@ import { miLocalStorage } from '@/local-storage.js';
import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js';
import { prefer } from '@/preferences.js';
import { useRouter } from '@/router.js';
import { useScrollPositionKeeper } from '@/use/use-scroll-position-keeper.js';
provide('shouldOmitHeaderTitle', true);
const tlComponent = useTemplateRef('tlComponent');
const rootEl = useTemplateRef('rootEl');
useScrollPositionKeeper(rootEl);
const router = useRouter();
router.useListener('same', () => {
top();

View File

@@ -51,7 +51,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.followedMessage != null" class="followedMessage">
<MkFukidashi class="fukidashi" :tail="narrow ? 'none' : 'left'" negativeMargin>
<div class="messageHeader">{{ i18n.ts.messageToFollower }}</div>
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user"/></MkSparkle></div>
<div><MkSparkle><Mfm :plain="true" :text="user.followedMessage" :author="user" class="_selectable"/></MkSparkle></div>
</MkFukidashi>
</div>
<div v-if="user.roles.length > 0" class="roles">
@@ -84,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="description">
<MkOmit>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user"/>
<Mfm v-if="user.description" :text="user.description" :isNote="false" :author="user" class="_selectable"/>
<p v-else class="empty">{{ i18n.ts.noAccountDescription }}</p>
</MkOmit>
</div>
@@ -105,10 +105,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="user.fields.length > 0" class="fields">
<dl v-for="(field, i) in user.fields" :key="i" class="field">
<dt class="name">
<Mfm :text="field.name" :author="user" :plain="true" :colored="false"/>
<Mfm :text="field.name" :author="user" :plain="true" :colored="false" class="_selectable"/>
</dt>
<dd class="value">
<Mfm :text="field.value" :author="user" :colored="false"/>
<Mfm :text="field.value" :author="user" :colored="false" class="_selectable"/>
<i v-if="user.verifiedLinks.includes(field.value)" v-tooltip:dialog="i18n.ts.verifiedLink" class="ti ti-circle-check" :class="$style.verifiedLink"></i>
</dd>
</dl>

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="tab" :tabs="headerTabs" :actions="headerActions">
<div v-if="user">
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
<MkSwiper v-model:tab="tab" :tabs="headerTabs">
<XHome v-if="tab === 'home'" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
<MkSpacer v-else-if="tab === 'notes'" :contentMax="800" style="padding-top: 0">
<XTimeline :user="user"/>
@@ -21,7 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XFlashs v-else-if="tab === 'flashs'" :user="user"/>
<XGallery v-else-if="tab === 'gallery'" :user="user"/>
<XRaw v-else-if="tab === 'raw'" :user="user"/>
</MkHorizontalSwipe>
</MkSwiper>
</div>
<MkError v-else-if="error" @retry="fetchUser()"/>
<MkLoading v-else/>
@@ -36,7 +36,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
import MkSwiper from '@/components/MkSwiper.vue';
import { serverContext, assertServerContext } from '@/server-context.js';
const XHome = defineAsyncComponent(() => import('./home.vue'));

View File

@@ -32,10 +32,11 @@ export type SoundStore = {
// NOTE: デフォルト値は他の設定の状態に依存してはならない(依存していた場合、ユーザーがその設定項目単体で「初期値にリセット」した場合不具合の原因になる)
export const PREF_DEF = {
// TODO: 持つのはホストやユーザーID、ユーザー名など最低限にしといて、その他のプロフィール情報はpreferences外で管理した方が綺麗そう
// 現状だと、updateCurrentAccount/updateCurrentAccountPartialが呼ばれるたびに「設定」へのcommitが行われて不自然(明らかに設定の更新とは捉えにくい)だし
accounts: {
default: [] as [host: string, user: Misskey.entities.User][],
default: [] as [host: string, user: {
id: string;
username: string;
}][],
},
pinnedUserLists: {

View File

@@ -4,28 +4,48 @@
*/
import { apiUrl } from '@@/js/config.js';
import { defaultMemoryStorage } from '@/memory-storage';
import { cloudBackup } from '@/preferences/utility.js';
import { store } from '@/store.js';
import { waiting } from '@/os.js';
import { unisonReload, reloadChannel } from '@/utility/unison-reload.js';
import { unisonReload } from '@/utility/unison-reload.js';
import { clear } from '@/utility/idb-proxy.js';
import { $i } from '@/i.js';
export async function signout() {
if (!$i) return;
// TODO: preferの自動バックアップがオンの場合、いろいろ消す前に強制バックアップ
waiting();
localStorage.clear();
defaultMemoryStorage.clear();
if (store.s.enablePreferencesAutoCloudBackup) {
await cloudBackup();
}
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise<void>((res, rej) => {
localStorage.clear();
const idbAbortController = new AbortController();
const timeout = window.setTimeout(() => idbAbortController.abort(), 5000);
const idbPromises = ['MisskeyClient'].map((name, i, arr) => new Promise<void>((res, rej) => {
const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res();
delidb.onerror = e => rej(e);
delidb.onblocked = () => idbAbortController.signal.aborted && rej(new Error('Operation aborted'));
}));
await Promise.all(idbPromises);
try {
await Promise.race([
Promise.all([
...idbPromises,
// idb keyval-storeはidb-keyvalライブラリによる別管理
clear(),
]),
new Promise((_, rej) => idbAbortController.signal.addEventListener('abort', () => rej(new Error('Operation timed out')))),
]);
} catch {
// nothing
} finally {
window.clearTimeout(timeout);
}
//#region Remove service worker registration
try {
@@ -50,7 +70,9 @@ export async function signout() {
.then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister()));
});
} catch (err) {}
} catch {
// nothing
}
//#endregion
unisonReload('/');

View File

@@ -108,6 +108,10 @@ export const store = markRaw(new Pizzax('base', {
where: 'device',
default: {} as Record<string, string>, // host/userId, token
},
accountInfos: {
where: 'device',
default: {} as Record<string, Misskey.entities.User>, // host/userId, user
},
enablePreferencesAutoCloudBackup: {
where: 'device',

View File

@@ -164,7 +164,6 @@ rt {
.ti {
width: 1.28em;
vertical-align: -12%;
line-height: 1em;
&::before {
font-size: 128%;

View File

@@ -77,14 +77,17 @@ watch(rootEl, () => {
<style lang="scss" module>
.root {
position: relative;
z-index: 1;
padding: 12px 12px max(12px, env(safe-area-inset-bottom, 0px)) 12px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-gap: 8px;
width: 100%;
box-sizing: border-box;
background: var(--MI_THEME-bg);
border-top: solid 0.5px var(--MI_THEME-divider);
background: var(--MI_THEME-navBg);
color: var(--MI_THEME-navFg);
box-shadow: 0px 0px 6px 6px #0000000f;
}
.item {
@@ -109,19 +112,17 @@ watch(rootEl, () => {
padding: 0;
aspect-ratio: 1;
width: 100%;
max-width: 50px;
max-width: 45px;
margin: auto;
align-content: center;
border-radius: 100%;
background: var(--MI_THEME-panel);
color: var(--MI_THEME-fg);
&:hover {
background: var(--MI_THEME-panelHighlight);
}
&:active {
background: hsl(from var(--MI_THEME-panel) h s calc(l - 2));
background: var(--MI_THEME-panelHighlight);
}
}
@@ -131,14 +132,16 @@ watch(rootEl, () => {
.itemIndicator {
position: absolute;
top: 0;
bottom: -4px;
left: 0;
right: 0;
color: var(--MI_THEME-indicator);
font-size: 16px;
font-size: 10px;
pointer-events: none;
&:has(.itemIndicateValueIcon) {
animation: none;
font-size: 12px;
font-size: 8px;
}
}
</style>

View File

@@ -97,6 +97,7 @@ import XWidgetsColumn from '@/ui/deck/widgets-column.vue';
import XMentionsColumn from '@/ui/deck/mentions-column.vue';
import XDirectColumn from '@/ui/deck/direct-column.vue';
import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue';
import XChatColumn from '@/ui/deck/chat-column.vue';
import { mainRouter } from '@/router.js';
import { columns, layout, columnTypes, switchProfileMenu, addColumn as addColumnToStore, deleteProfile as deleteProfile_ } from '@/deck.js';
@@ -114,6 +115,7 @@ const columnComponents = {
mentions: XMentionsColumn,
direct: XDirectColumn,
roleTimeline: XRoleTimelineColumn,
chat: XChatColumn,
};
mainRouter.navHook = (path, flag): boolean => {

View File

@@ -0,0 +1,27 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<XColumn :column="column" :isStacked="isStacked">
<template #header><i class="ti ti-messages" style="margin-right: 8px;"></i>{{ column.name || i18n.ts._deck._columns.chat }}</template>
<div style="padding: 8px;">
<MkChatHistories/>
</div>
</XColumn>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { i18n } from '../../i18n.js';
import XColumn from './column.vue';
import type { Column } from '@/deck.js';
import MkChatHistories from '@/components/MkChatHistories.vue';
defineProps<{
column: Column;
isStacked: boolean;
}>();
</script>

View File

@@ -0,0 +1,77 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { throttle } from 'throttle-debounce';
import { nextTick, onActivated, onDeactivated, onUnmounted, watch } from 'vue';
import type { Ref } from 'vue';
// note render skippingがオンだとズレるため、遷移直前にスクロール範囲に表示されているdata-scroll-anchor要素を特定して、復元時に当該要素までスクロールするようにする
// TODO: data-scroll-anchor がひとつも存在しない場合、または手動で useAnchor みたいなフラグをfalseで呼ばれた場合、単純にスクロール位置を使用する処理にフォールバックするようにする
export function useScrollPositionKeeper(scrollContainerRef: Ref<HTMLElement | null | undefined>): void {
let anchorId: string | null = null;
let ready = true;
watch(scrollContainerRef, (el) => {
if (!el) return;
const onScroll = () => {
if (!el) return;
if (!ready) return;
const scrollContainerRect = el.getBoundingClientRect();
const viewPosition = scrollContainerRect.height / 2;
const anchorEls = el.querySelectorAll('[data-scroll-anchor]');
for (let i = anchorEls.length - 1; i > -1; i--) { // 下から見た方が速い
const anchorEl = anchorEls[i] as HTMLElement;
const anchorRect = anchorEl.getBoundingClientRect();
const anchorTop = anchorRect.top;
const anchorBottom = anchorRect.bottom;
if (anchorTop <= viewPosition && anchorBottom >= viewPosition) {
anchorId = anchorEl.getAttribute('data-scroll-anchor');
break;
}
}
};
// ほんとはscrollイベントじゃなくてonBeforeDeactivatedでやりたい
// https://github.com/vuejs/vue/issues/9454
// https://github.com/vuejs/rfcs/pull/284
el.addEventListener('scroll', throttle(1000, onScroll), { passive: true });
}, {
immediate: true,
});
const restore = () => {
if (!anchorId) return;
const scrollContainer = scrollContainerRef.value;
if (!scrollContainer) return;
const scrollAnchorEl = scrollContainer.querySelector(`[data-scroll-anchor="${anchorId}"]`);
if (!scrollAnchorEl) return;
scrollAnchorEl.scrollIntoView({
behavior: 'instant',
block: 'center',
inline: 'center',
});
};
onDeactivated(() => {
ready = false;
});
onActivated(() => {
restore();
nextTick(() => {
restore();
window.setTimeout(() => {
restore();
ready = true;
}, 100);
});
});
}

View File

@@ -7,6 +7,7 @@ import { nextTick, ref, defineAsyncComponent } from 'vue';
import getCaretCoordinates from 'textarea-caret';
import { toASCII } from 'punycode.js';
import type { Ref } from 'vue';
import type { CompleteInfo } from '@/components/MkAutocomplete.vue';
import { popup } from '@/os.js';
export type SuggestionType = 'user' | 'hashtag' | 'emoji' | 'mfmTag' | 'mfmParam';
@@ -19,7 +20,7 @@ export class Autocomplete {
close: () => void;
} | null;
private textarea: HTMLInputElement | HTMLTextAreaElement;
private currentType: string;
private currentType: keyof CompleteInfo | undefined;
private textRef: Ref<string | number | null>;
private opening: boolean;
private onlyType: SuggestionType[];
@@ -74,7 +75,7 @@ export class Autocomplete {
* テキスト入力時
*/
private onInput() {
const caretPos = this.textarea.selectionStart;
const caretPos = Number(this.textarea.selectionStart);
const text = this.text.substring(0, caretPos).split('\n').pop()!;
const mentionIndex = text.lastIndexOf('@');
@@ -101,6 +102,8 @@ export class Autocomplete {
const isMfmParam = mfmParamIndex !== -1 && afterLastMfmParam?.includes('.') && !afterLastMfmParam.includes(' ');
const isMfmTag = mfmTagIndex !== -1 && !isMfmParam;
const isEmoji = emojiIndex !== -1 && text.split(/:[a-z0-9_+\-]+:/).pop()!.includes(':');
// :ok:などを🆗にするたいおぷ
const isEmojiCompleteToUnicode = !isEmoji && emojiIndex === text.length - 1;
let opened = false;
@@ -137,6 +140,14 @@ export class Autocomplete {
}
}
if (isEmojiCompleteToUnicode && !opened && this.onlyType.includes('emoji')) {
const emoji = text.substring(text.lastIndexOf(':', text.length - 2) + 1, text.length - 1);
if (!emoji.includes(' ')) {
this.open('emojiComplete', emoji);
opened = true;
}
}
if (isMfmTag && !opened && this.onlyType.includes('mfmTag')) {
const mfmTag = text.substring(mfmTagIndex + 1);
if (!mfmTag.includes(' ')) {
@@ -164,7 +175,7 @@ export class Autocomplete {
/**
* サジェストを提示します。
*/
private async open(type: string, q: any) {
private async open<T extends keyof CompleteInfo>(type: T, q: CompleteInfo[T]['query']) {
if (type !== this.currentType) {
this.close();
}
@@ -231,10 +242,10 @@ export class Autocomplete {
/**
* オートコンプリートする
*/
private complete({ type, value }) {
private complete<T extends keyof CompleteInfo>({ type, value }: { type: T; value: CompleteInfo[T]['payload'] }) {
this.close();
const caret = this.textarea.selectionStart;
const caret = Number(this.textarea.selectionStart);
if (type === 'user') {
const source = this.text;
@@ -280,6 +291,22 @@ export class Autocomplete {
// 挿入
this.text = trimmedBefore + value + after;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();
const pos = trimmedBefore.length + value.length;
this.textarea.setSelectionRange(pos, pos);
});
} else if (type === 'emojiComplete') {
const source = this.text;
const before = source.substring(0, caret);
const trimmedBefore = before.substring(0, before.lastIndexOf(':', before.length - 2));
const after = source.substring(caret);
// 挿入
this.text = trimmedBefore + value + after;
// キャレットを戻す
nextTick(() => {
this.textarea.focus();

View File

@@ -9,6 +9,7 @@ import {
get as iget,
set as iset,
del as idel,
clear as iclear,
} from 'idb-keyval';
import { miLocalStorage } from '@/local-storage.js';
@@ -51,3 +52,7 @@ export async function del(key: string) {
if (idbAvailable) return idel(key);
return miLocalStorage.removeItem(`${PREFIX}${key}`);
}
export async function clear() {
if (idbAvailable) return iclear();
}

View File

@@ -104,3 +104,33 @@ export function searchEmoji(query: string | null, emojiDb: EmojiDef[], max = 30)
.slice(0, max)
.map(it => it.emoji);
}
export function searchEmojiExact(query: string | null, emojiDb: EmojiDef[], max = 30): EmojiDef[] {
if (!query) {
return [];
}
const matched = new Map<string, EmojiScore>();
// 完全一致(エイリアスなし)
emojiDb.some(x => {
if (x.name === query && !x.aliasOf) {
matched.set(x.name, { emoji: x, score: query.length + 3 });
}
return matched.size === max;
});
// 完全一致(エイリアス込み)
if (matched.size < max) {
emojiDb.some(x => {
if (x.name === query && !matched.has(x.aliasOf ?? x.name)) {
matched.set(x.aliasOf ?? x.name, { emoji: x, score: query.length + 2 });
}
return matched.size === max;
});
}
return [...matched.values()]
.sort((x, y) => y.score - x.score)
.slice(0, max)
.map(it => it.emoji);
}

View File

@@ -18,5 +18,5 @@ if (isTouchSupported && !isTouchUsing) {
}, { passive: true });
}
/** (MkHorizontalSwipe) 横スワイプ中か? */
/** (MkSwiper) 横スワイプ中か? */
export const isHorizontalSwipeSwiping = ref(false);

View File

@@ -0,0 +1,52 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkContainer :showHeader="widgetProps.showHeader" class="mkw-chat">
<template #icon><i class="ti ti-users"></i></template>
<template #header>{{ i18n.ts._widgets.chat }}</template>
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configure()"><i class="ti ti-settings"></i></button></template>
<div>
<MkChatHistories/>
</div>
</MkContainer>
</template>
<script lang="ts" setup>
import { } from 'vue';
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { i18n } from '@/i18n.js';
import MkChatHistories from '@/components/MkChatHistories.vue';
const name = 'chat';
const widgetPropsDef = {
showHeader: {
type: 'boolean' as const,
default: true,
},
};
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
const props = defineProps<WidgetComponentProps<WidgetProps>>();
const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
const { widgetProps, configure, save } = useWidgetPropsManager(name,
widgetPropsDef,
props,
emit,
);
defineExpose<WidgetComponentExpose>({
name,
configure,
id: props.widget ? props.widget.id : null,
});
</script>

View File

@@ -35,6 +35,7 @@ export default function(app: App) {
app.component('WidgetUserList', defineAsyncComponent(() => import('./WidgetUserList.vue')));
app.component('WidgetClicker', defineAsyncComponent(() => import('./WidgetClicker.vue')));
app.component('WidgetBirthdayFollowings', defineAsyncComponent(() => import('./WidgetBirthdayFollowings.vue')));
app.component('WidgetChat', defineAsyncComponent(() => import('./WidgetChat.vue')));
}
// 連合関連のウィジェット(連合無効時に隠す)
@@ -70,6 +71,7 @@ export const widgets = [
'userList',
'clicker',
'birthdayFollowings',
'chat',
...federationWidgets,
];

View File

@@ -1,3 +0,0 @@
import { defineConfig } from 'vite';
export default defineConfig({});