merge upstream again
This commit is contained in:
commit
a4dd19fdd4
167 changed files with 6779 additions and 3952 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 : prefer.r.emojiStyle.value === 'tossface' ? char2tossfaceFilePath : 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.normalize('NFC').toLowerCase(), emojiDb.value);
|
||||
} else if (props.type === 'emojiComplete') {
|
||||
emojis.value = searchEmojiExact(props.q.normalize('NFC').toLowerCase(), unicodeEmojiDB.value);
|
||||
} else if (props.type === 'mfmTag') {
|
||||
if (!props.q || props.q === '') {
|
||||
mfmTags.value = MFM_TAGS;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
208
packages/frontend/src/components/MkChatHistories.vue
Normal file
208
packages/frontend/src/components/MkChatHistories.vue
Normal 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>
|
||||
|
|
@ -12,8 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #fallback>
|
||||
<MkLoading/>
|
||||
</template>
|
||||
<XCode v-if="show && lang" :code="code" :lang="lang"/>
|
||||
<pre v-else-if="show" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/>
|
||||
<pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
|
||||
<div :class="$style.codePlaceholderContainer">
|
||||
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
|
||||
|
|
@ -70,11 +70,9 @@ function copy() {
|
|||
.codeBlockFallbackRoot {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
background: var(--MI_THEME-bg);
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
border-radius: var(--MI-radius-sm);
|
||||
}
|
||||
|
||||
.codeBlockFallbackCode {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDisableSection from './MkDisableSection.vue';
|
||||
void MkDisableSection;
|
||||
|
|
@ -640,13 +640,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,
|
||||
|
|
|
|||
|
|
@ -38,15 +38,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" :class="$style.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div v-if="$slots.header" :class="$style.inBodyHeader">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="$slots.footer" :class="$style.inBodyFooter">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
|
@ -230,14 +241,21 @@ onMounted(() => {
|
|||
|
||||
&.bgSame {
|
||||
background: var(--MI_THEME-bg);
|
||||
|
||||
.inBodyHeader {
|
||||
background: color(from var(--MI_THEME-bg) srgb r g b / 0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky !important;
|
||||
z-index: 1;
|
||||
bottom: var(--MI-stickyBottom, 0px);
|
||||
left: 0;
|
||||
.inBodyHeader {
|
||||
background: color(from var(--MI_THEME-panel) 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);
|
||||
}
|
||||
|
||||
.inBodyFooter {
|
||||
padding: 12px;
|
||||
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -22,14 +23,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:moveClass=" $style.transition_x_move"
|
||||
tag="div"
|
||||
>
|
||||
<template v-for="note in notes" :key="note.id">
|
||||
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]">
|
||||
<template v-for="(note, i) in notes" :key="note.id">
|
||||
<div v-if="note._shouldInsertAd_" :class="[$style.noteWithAd, { '_gaps': !noGap }]" :data-scroll-anchor="note.id">
|
||||
<DynamicNote :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/>
|
||||
<div :class="$style.ad">
|
||||
<MkAd :preferForms="['horizontal', 'horizontal-big']"/>
|
||||
</div>
|
||||
</div>
|
||||
<DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true"/>
|
||||
<DynamicNote v-else :class="$style.note" :note="note as Misskey.entities.Note" :withHardMute="true" :data-scroll-anchor="note.id"/>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
|
@ -74,6 +75,11 @@ defineExpose({
|
|||
position: absolute;
|
||||
}
|
||||
|
||||
.reverse {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.root {
|
||||
container-type: inline-size;
|
||||
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
right: -2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--MI-radius-full);
|
||||
background: var(--MI_THEME-panel);
|
||||
|
|
@ -360,73 +361,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
tag="div"
|
||||
>
|
||||
<template v-for="(notification, i) in notifications" :key="notification.id">
|
||||
<DynamicNote 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"/>
|
||||
<DynamicNote 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>
|
||||
|
|
|
|||
|
|
@ -956,7 +956,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;
|
||||
|
|
|
|||
235
packages/frontend/src/components/MkTabs.vue
Normal file
235
packages/frontend/src/components/MkTabs.vue
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.tabs">
|
||||
<div :class="$style.tabsInner">
|
||||
<button
|
||||
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
|
||||
class="_button" :class="[$style.tab, { [$style.active]: t.key != null && t.key === props.tab, [$style.animate]: prefer.s.animation }]"
|
||||
@mousedown="(ev) => onTabMousedown(t, ev)" @click="(ev) => onTabClick(t, ev)"
|
||||
>
|
||||
<div :class="$style.tabInner">
|
||||
<i v-if="t.icon" :class="[$style.tabIcon, t.icon]"></i>
|
||||
<div
|
||||
v-if="!t.iconOnly || (!prefer.s.animation && t.key === tab)"
|
||||
:class="$style.tabTitle"
|
||||
>
|
||||
{{ t.title }}
|
||||
</div>
|
||||
<Transition
|
||||
v-else mode="in-out" @enter="enter" @afterEnter="afterEnter" @leave="leave"
|
||||
@afterLeave="afterLeave"
|
||||
>
|
||||
<div v-show="t.key === tab" :class="[$style.tabTitle, $style.animate]">{{ t.title }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref="tabHighlightEl"
|
||||
:class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export type Tab = {
|
||||
key: string;
|
||||
onClick?: (ev: MouseEvent) => void;
|
||||
} & (
|
||||
| {
|
||||
iconOnly?: false;
|
||||
title: string;
|
||||
icon?: string;
|
||||
}
|
||||
| {
|
||||
iconOnly: true;
|
||||
icon: string;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
tabs?: Tab[];
|
||||
tab?: string;
|
||||
}>(), {
|
||||
tabs: () => ([] as Tab[]),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:tab', key: string);
|
||||
(ev: 'tabClick', key: string);
|
||||
}>();
|
||||
|
||||
const tabHighlightEl = useTemplateRef('tabHighlightEl');
|
||||
const tabRefs: Record<string, HTMLElement | null> = {};
|
||||
|
||||
function onTabMousedown(tab: Tab, ev: MouseEvent): void {
|
||||
// ユーザビリティの観点からmousedown時にはonClickは呼ばない
|
||||
if (tab.key) {
|
||||
emit('update:tab', tab.key);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabClick(t: Tab, ev: MouseEvent): void {
|
||||
emit('tabClick', t.key);
|
||||
|
||||
if (t.onClick) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
t.onClick(ev);
|
||||
}
|
||||
|
||||
if (t.key) {
|
||||
emit('update:tab', t.key);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const tabEl = props.tab ? tabRefs[props.tab] : undefined;
|
||||
if (tabEl && tabHighlightEl.value && tabHighlightEl.value.parentElement) {
|
||||
// offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある
|
||||
// https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4
|
||||
const parentRect = tabHighlightEl.value.parentElement.getBoundingClientRect();
|
||||
const rect = tabEl.getBoundingClientRect();
|
||||
tabHighlightEl.value.style.width = rect.width + 'px';
|
||||
tabHighlightEl.value.style.left = (rect.left - parentRect.left + tabHighlightEl.value.parentElement.scrollLeft) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
let entering = false;
|
||||
|
||||
async function enter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
entering = true;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
nextTick(() => {
|
||||
entering = false;
|
||||
});
|
||||
|
||||
window.setTimeout(renderTab, 170);
|
||||
}
|
||||
|
||||
function afterEnter(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
// element.style.width = '';
|
||||
}
|
||||
|
||||
async function leave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
const elementWidth = el.getBoundingClientRect().width;
|
||||
el.style.width = `${elementWidth}px`;
|
||||
el.style.paddingLeft = '';
|
||||
el.offsetWidth; // reflow
|
||||
el.style.width = '0';
|
||||
el.style.paddingLeft = '0';
|
||||
}
|
||||
|
||||
function afterLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.width = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch([() => props.tab, () => props.tabs], () => {
|
||||
nextTick(() => {
|
||||
if (entering) return;
|
||||
renderTab();
|
||||
});
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.tabs {
|
||||
--height: 40px;
|
||||
|
||||
display: block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
height: var(--height);
|
||||
font-size: 85%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tabsInner {
|
||||
display: inline-block;
|
||||
height: var(--height);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
height: 100%;
|
||||
font-weight: normal;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.animate {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.tabInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tabIcon + .tabTitle {
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
overflow: hidden;
|
||||
|
||||
&.animate {
|
||||
transition: width .15s linear, padding-left .15s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.tabHighlight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
background: var(--MI_THEME-accent);
|
||||
border-radius: 999px;
|
||||
transition: none;
|
||||
pointer-events: none;
|
||||
|
||||
&.animate {
|
||||
transition: width 0.15s ease, left 0.15s ease;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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)',
|
||||
|
|
|
|||
173
packages/frontend/src/components/MkTl.vue
Normal file
173
packages/frontend/src/components/MkTl.vue
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.items">
|
||||
<template v-for="(item, i) in items" :key="item.id">
|
||||
<div :class="$style.left">
|
||||
<slot v-if="item.type === 'event'" name="left" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot>
|
||||
</div>
|
||||
<div :class="[$style.center, item.type === 'date' ? $style.date : '']">
|
||||
<div :class="$style.centerLine"></div>
|
||||
<div :class="$style.centerPoint"></div>
|
||||
</div>
|
||||
<div :class="$style.right">
|
||||
<slot v-if="item.type === 'event'" name="right" :event="item.data" :timestamp="item.timestamp" :delta="item.delta"></slot>
|
||||
<div v-else :class="$style.dateLabel"><i class="ti ti-chevron-up"></i> {{ item.prevText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
events: {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
data: any;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const events = computed(() => {
|
||||
return props.events.toSorted((a, b) => b.timestamp - a.timestamp);
|
||||
});
|
||||
|
||||
function getDateText(dateInstance: Date) {
|
||||
const year = dateInstance.getFullYear();
|
||||
const month = dateInstance.getMonth() + 1;
|
||||
const date = dateInstance.getDate();
|
||||
const hour = dateInstance.getHours();
|
||||
return `${year.toString()}/${month.toString()}/${date.toString()} ${hour.toString().padStart(2, '0')}:00:00`;
|
||||
}
|
||||
|
||||
const items = computed<({
|
||||
id: string;
|
||||
type: 'event';
|
||||
timestamp: number;
|
||||
delta: number;
|
||||
data: any;
|
||||
} | {
|
||||
id: string;
|
||||
type: 'date';
|
||||
prev: Date;
|
||||
prevText: string;
|
||||
next: Date | null;
|
||||
nextText: string;
|
||||
})[]>(() => {
|
||||
const results = [];
|
||||
for (let i = 0; i < events.value.length; i++) {
|
||||
const item = events.value[i];
|
||||
|
||||
const date = new Date(item.timestamp);
|
||||
const nextDate = events.value[i + 1] ? new Date(events.value[i + 1].timestamp) : null;
|
||||
|
||||
results.push({
|
||||
id: item.id,
|
||||
type: 'event',
|
||||
timestamp: item.timestamp,
|
||||
delta: i === events.value.length - 1 ? 0 : item.timestamp - events.value[i + 1].timestamp,
|
||||
data: item.data,
|
||||
});
|
||||
|
||||
if (
|
||||
i !== events.value.length - 1 &&
|
||||
nextDate != null && (
|
||||
date.getFullYear() !== nextDate.getFullYear() ||
|
||||
date.getMonth() !== nextDate.getMonth() ||
|
||||
date.getDate() !== nextDate.getDate() ||
|
||||
date.getHours() !== nextDate.getHours()
|
||||
)
|
||||
) {
|
||||
results.push({
|
||||
id: `date-${item.id}`,
|
||||
type: 'date',
|
||||
prev: date,
|
||||
prevText: getDateText(date),
|
||||
next: nextDate,
|
||||
nextText: getDateText(nextDate),
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 18px 1fr;
|
||||
gap: 0 8px;
|
||||
}
|
||||
|
||||
.item {
|
||||
}
|
||||
|
||||
.center {
|
||||
position: relative;
|
||||
|
||||
&.date {
|
||||
.centerPoint::before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--MI_THEME-bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.centerLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%);
|
||||
}
|
||||
.centerPoint {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
background: color-mix(in srgb, var(--MI_THEME-accent), var(--MI_THEME-bg) 75%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.left {
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
justify-self: right;
|
||||
}
|
||||
|
||||
.right {
|
||||
min-width: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.dateLabel {
|
||||
opacity: 0.7;
|
||||
font-size: 90%;
|
||||
padding: 4px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -67,7 +67,7 @@ async function reload(): Promise<void> {
|
|||
// An additional request is needed to "upgrade" the object.
|
||||
misskeyApi('users/show', { userId: props.userId }),
|
||||
|
||||
// Wait for 1 second to match the animation effects in MkHorizontalSwipe, MkPullToRefresh, and MkPagination.
|
||||
// Wait for 1 second to match the animation effects in MkSwiper, MkPullToRefresh, and MkPagination.
|
||||
// Otherwise, the page appears to load "backwards".
|
||||
new Promise(resolve => window.setTimeout(resolve, 1000)),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -140,11 +140,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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
@ -36,6 +37,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' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue